Skip to main content

decern_ledger/
lib.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: 2026 Anivar Aravind
3#![forbid(unsafe_code)]
4//! decern-ledger — the tamper-evident decision ledger (the audit column).
5//!
6//! Every authority decision is appended as a hash-chained, Ed25519-signed
7//! record: `hash = SHA-256(entry_bytes ‖ prev_hash)`, signature over `hash`.
8//! Any edit, reorder or in-place deletion breaks the chain; a wholesale
9//! rewrite fails signature verification against the ledger key. What the
10//! chain alone cannot detect is truncation of the *tail* — that is what
11//! `root()` is for: export the head hash and anchor it externally (a
12//! regulator, a notary, another system). Anchored root + intact chain =
13//! complete, unmodified history.
14//!
15//! The chain hash covers the EXACT entry bytes as stored on disk (captured
16//! via serde_json's RawValue at verify time), never a re-serialization — so
17//! byte-stability is structural, not an assumption about JSON round-trips.
18//! (Float round-tripping is NOT stable in serde_json without the
19//! `float_roundtrip` feature; hashing re-serialized bytes was a confirmed
20//! false-tamper bug that could brick an honest ledger.)
21
22use std::fs::{self, File, OpenOptions};
23use std::io::Write;
24use std::path::{Path, PathBuf};
25
26use base64::Engine;
27use base64::engine::general_purpose::STANDARD as B64;
28use decern_crypto::{Signer, SigningKey, VerifyingKey};
29use std::collections::BTreeMap;
30
31use serde::{Deserialize, Serialize};
32use sha2::{Digest, Sha256};
33
34pub mod jcs;
35pub mod merkle;
36mod segment;
37pub mod sharded;
38pub use jcs::{canonicalize, digest};
39pub use segment::RolloverPolicy;
40pub use sharded::{ShardVerification, ShardedLedger, UNATTRIBUTED_SHARD, verify_sharded_dir};
41
42/// Where a [`Ledger`]'s bytes live: a single append-only file (the default)
43/// or a segmented directory (opt-in, see
44/// [`Ledger::open_segmented`]). `detect` is used ONLY by the path-taking free
45/// functions (`verify`, `verify_with_keys`, `read_verified`,
46/// `ledger_extends_checkpoint`) so they stay
47/// segmentation-transparent for external callers with zero signature changes;
48/// a `Ledger`'s own methods already know their kind from how they were
49/// opened and never need to re-detect it.
50enum Location {
51    Single(PathBuf),
52    Segmented(PathBuf),
53}
54
55impl Location {
56    fn detect(path: &Path) -> Self {
57        if path.is_dir() {
58            Location::Segmented(path.to_path_buf())
59        } else {
60            Location::Single(path.to_path_buf())
61        }
62    }
63
64    /// Every file this location's bytes live in, in seq order. `Single` is
65    /// always exactly one path (even if it doesn't exist yet — the same
66    /// "doesn't exist" a plain `File::open` would report); `Segmented` reads
67    /// the manifest and fails closed if a listed segment is missing from
68    /// disk.
69    fn resolved_paths(&self) -> Result<Vec<PathBuf>, LedgerError> {
70        match self {
71            Location::Single(p) => Ok(vec![p.clone()]),
72            Location::Segmented(dir) => segment::segment_paths(dir),
73        }
74    }
75
76    fn lines(&self) -> Result<segment::ChainedLines, LedgerError> {
77        Ok(segment::chained_lines(self.resolved_paths()?))
78    }
79
80    /// The lines of this location's PREFIX — every fully `\n`-terminated line —
81    /// with any crash-torn trailing fragment (bytes after the final newline of
82    /// the last file) excluded. Returns the iterator plus `Some(TornFragment)` when
83    /// such a fragment exists, or `None` when the last file ends cleanly (the
84    /// normal case). Verification and root re-derivation both consume THIS, so a
85    /// torn fragment is never mistaken for a corrupt record.
86    fn prefix_lines(&self) -> Result<(segment::ChainedLines, Option<TornFragment>), LedgerError> {
87        let paths = self.resolved_paths()?;
88        let torn = match paths.last() {
89            Some(last) => scan_torn_tail(last)?,
90            None => None,
91        };
92        let limit = torn.as_ref().map(|t| t.offset).unwrap_or(u64::MAX);
93        Ok((segment::chained_lines_bounded(paths, limit), torn))
94    }
95}
96
97/// A crash-torn trailing fragment discovered on the last file: the file does
98/// not end in `\n`, so everything from `offset` (the byte just past the final
99/// newline, or 0 if the file has no newline at all) to EOF was only partially
100/// written and must be discarded to recover the verified prefix.
101struct TornFragment {
102    path: PathBuf,
103    offset: u64,
104}
105
106/// Inspect `path`'s LAST byte. Returns `None` (no torn tail) when the file is
107/// missing, empty, or already ends in `\n`. Otherwise the file's final record
108/// was not newline-terminated → returns the offset just past its last `\n` (0
109/// if none), i.e. where the log must be truncated to drop the torn fragment.
110/// Keyed purely on newline-termination: given `append` writes `line + "\n"` in a
111/// single `write_all`, a present terminator proves the whole record reached the
112/// file, and its absence proves a partial write — so a terminated line is never
113/// a torn tail (a terminated-but-corrupt final record stays `Tamper`), and an
114/// unterminated one always is (even if its bytes happen to parse — it was never
115/// acked, and keeping it would fuse onto the next append on one physical line).
116fn scan_torn_tail(path: &Path) -> Result<Option<TornFragment>, LedgerError> {
117    use std::io::{Read, Seek, SeekFrom};
118    let mut f = match File::open(path) {
119        Ok(f) => f,
120        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
121        Err(e) => return Err(io_err(path, e)),
122    };
123    let len = f.metadata().map_err(|e| io_err(path, e))?.len();
124    if len == 0 {
125        return Ok(None);
126    }
127    // Cheap last-byte check first: a clean, newline-terminated file (the common
128    // case) costs one seek + one byte read and exits here.
129    f.seek(SeekFrom::End(-1)).map_err(|e| io_err(path, e))?;
130    let mut last = [0u8; 1];
131    f.read_exact(&mut last).map_err(|e| io_err(path, e))?;
132    if last[0] == b'\n' {
133        return Ok(None);
134    }
135    // Unterminated: scan backward in chunks for the final newline. The torn
136    // fragment is a single partially-written record, but records can be large,
137    // so loop rather than assume it fits one chunk.
138    const CHUNK: u64 = 64 * 1024;
139    let mut pos = len;
140    let mut buf = vec![0u8; CHUNK as usize];
141    while pos > 0 {
142        let read_len = CHUNK.min(pos);
143        let start = pos - read_len;
144        f.seek(SeekFrom::Start(start))
145            .map_err(|e| io_err(path, e))?;
146        let slice = &mut buf[..read_len as usize];
147        f.read_exact(slice).map_err(|e| io_err(path, e))?;
148        if let Some(idx) = slice.iter().rposition(|&b| b == b'\n') {
149            // Byte just past this newline, in absolute file coordinates.
150            return Ok(Some(TornFragment {
151                path: path.to_path_buf(),
152                offset: start + idx as u64 + 1,
153            }));
154        }
155        pos = start;
156    }
157    // No newline anywhere: the whole file is one torn (never-acked) record.
158    Ok(Some(TornFragment {
159        path: path.to_path_buf(),
160        offset: 0,
161    }))
162}
163
164pub const GENESIS: &str = "0000000000000000000000000000000000000000000000000000000000000000";
165
166/// One record in the ledger: a decision — "what happened, with everything needed
167/// to replay it". Several fields below are reserved and inert (see
168/// each field's note): no shipped path sets them, they are retained only for
169/// struct/type stability, and a plain decision leaves them at their defaults, which
170/// serialize to no bytes — so every existing writer and stored line is unchanged.
171#[derive(Debug, Clone, Default, Serialize, Deserialize)]
172pub struct Entry {
173    pub seq: u64,
174    pub ts_ms: u64,
175    pub subject_type: String,
176    pub subject_id: String,
177    pub action: String,
178    pub resource_type: String,
179    pub resource_id: String,
180    pub context: serde_json::Value,
181    pub decision: bool,
182    pub reasons: Vec<String>,
183    /// RFC 8785 SHA-256 digest of the parameters a decision was made over — binds a
184    /// record to the EXACT arguments, closing the TOCTOU gap between "authorized"
185    /// and "executed". Set by `decern-serve` on decide / mission transitions.
186
187    /// The authority-graph edge type: `Attenuate` (default, omitted) = offline
188    /// narrowing WITHIN the delegator's namespace (a decern tenant); `Mint` = a
189    /// trusted-issuer crossing that no offline delegate can produce. Reserved and
190    /// inert: never set by any shipped path, defaulted and
191    /// skipped-when-default, so existing records' bytes and hashes are unchanged.
192    #[serde(default, skip_serializing_if = "edge_is_attenuate")]
193    pub edge: EdgeType,
194    /// The accountable-owner — who stands behind `subject_id` existing and
195    /// acting AT ALL. Resolved server-side from the directory's delegation chain —
196    /// never a decision input (stripped before the kernel) and safe to store in the
197    /// clear: it names a principal already visible elsewhere in the same tenant's
198    /// directory, not third-party PII — EXCEPT for a self-sponsored root principal,
199    /// where this equals `subject_id` verbatim. `None` on every record before this
200    /// field existed, and on any subject the directory doesn't recognize (e.g. a
201    /// global/static-token caller) — existing bytes and hashes are unchanged.
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub sponsor: Option<Party>,
204    /// Whether `sponsor` above was computed (`Derived`, the default — the pure
205    /// root of the delegation chain) or set by an admin override, constrained
206    /// to that same chain. Lets an auditor tell asserted from computed without
207    /// re-deriving it. Default + skipped-when-default, so existing records'
208    /// bytes and hashes are unchanged.
209    #[serde(default, skip_serializing_if = "is_derived_sponsor")]
210    pub sponsor_source: SponsorSource,
211    /// The Mission that justified this decision, when decide ran under a live
212    /// approval. `None` when no mission was bound (or on pre-mission records).
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub mission: Option<MissionRef>,
215    /// The party the decision is *about* — the one it is taken upon, distinct
216    /// from the acting `subject_id` and from the accountable `sponsor`.
217    /// Descriptive, never an authorization input. Present only when that party
218    /// is a third party: a decision about the requester, or about the owner of
219    /// the resource named, carries none, because the record already says so.
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    pub decision_subject: Option<DecisionSubject>,
222    /// The caller the server verified when it took this request — who ASSERTED the
223    /// subject, distinct from the subject itself and from the accountable sponsor.
224    /// Present only under bearer validation, where it is what the token proved, on
225    /// decision records and mission lifecycle records (`Mission.Approve`, `Mission.Terminate`).
226    /// Absent under a trusted front: an assertion the server did not verify itself does not
227    /// belong on a permanent record. Descriptive, never a decision input.
228    ///
229    /// The token's `sub` is written verbatim, permanently, and the subject-side
230    /// projection returns whole records — so front service identities here, not
231    /// end-user tokens: a person's identifier in `sub` becomes visible to anyone
232    /// holding a decision-subject handle on the same record, and cannot be redacted
233    /// after the fact.
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub asserted_by: Option<AssertedBy>,
236    /// Whether this decision is one an affected party should be told about. Recorded, not
237    /// acted on: telling them is the job of whoever enforces the decision, and this server
238    /// does not enforce. Recording it is what makes a notice that never went out a gap
239    /// someone can point at rather than a thing nobody can prove either way.
240    #[serde(default, skip_serializing_if = "is_false")]
241    pub notice_required: bool,
242    /// A challenge from the party this decision was about, and how it was answered.
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub challenge: Option<ChallengeRecord>,
245    /// Digests of the things this record was bound to, by name.
246    ///
247    /// [`DIGEST_PARAMETERS`] binds the arguments a decision authorized. This binds
248    /// everything else worth pinning, without a new column each time something is: a
249    /// consumer of this crate records what its own decisions depend on under names it
250    /// chooses, and a reader who does not know a name can still see that something was
251    /// pinned and that it does not match.
252    ///
253    /// `decern-serve` writes [`DIGEST_AUTHORITY`]. The chain already proves a record was
254    /// not altered afterwards; it says nothing about what the record was decided
255    /// *against*, and that moves. Revoke a delegation tomorrow and an allow recorded today
256    /// still reads as an allow, with nothing to say what was true when — the trail is
257    /// immutable while the thing it refers to is not. A digest of the authority state
258    /// makes the decision addressable: a later reading can tell whether the authority it
259    /// was taken against is still the same one.
260    ///
261    /// Ordered, so the serialization is deterministic — this is inside the bytes the chain
262    /// hashes, and a map that serialized in a different order each time would break it.
263    /// Values are digests, not content: whatever is being pinned may be large, may be
264    /// about a person, and cannot be taken back out of an append-only log.
265    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
266    pub digests: BTreeMap<String, String>,
267}
268
269/// The exact arguments a decision authorized — what it was asked, not what it knew.
270/// Binding them means a later reading can tell that the thing authorized is the thing
271/// that was requested, rather than something substituted after the check.
272pub const DIGEST_PARAMETERS: &str = "parameters";
273
274/// The authority a decision was taken against — policy, schema and entity graph.
275pub const DIGEST_AUTHORITY: &str = "authority";
276
277fn is_false(b: &bool) -> bool {
278    !*b
279}
280
281/// The verified caller of the request that produced a record: the token's subject, the
282/// client acting for it, and the issuer that vouched — enough for a reader to ask the
283/// right party why this request was made, and nothing a caller can write for itself.
284#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
285pub struct AssertedBy {
286    /// The token's `sub` — the party the issuer authenticated.
287    pub sub: String,
288    /// The token's `client_id` — the client acting for that party.
289    pub client_id: String,
290    /// The issuer whose signature the server verified.
291    pub iss: String,
292}
293
294/// A challenge and its answer, on the record.
295///
296/// Written here rather than kept beside the log because a challenge nobody can find later
297/// is the same as one that was never made — and because the point of answering is that the
298/// answer, and its reason, are as durable as the decision they concern.
299#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
300pub struct ChallengeRecord {
301    /// The decision that was challenged.
302    pub decision_ref: String,
303    /// The handle the challenger proved standing as.
304    pub decision_subject: String,
305    /// The grounds given.
306    pub basis: Vec<String>,
307    /// What the challenger asked for, which is not what they necessarily got.
308    pub requested_effect: String,
309    /// What was done: the decision stood, or it was made again with the challenge in view.
310    pub outcome: String,
311    /// Why. An answer without a reason is a dismissal.
312    pub outcome_basis: String,
313    /// A digest of the evidence submitted, when any was — not the evidence itself.
314    /// Whatever a party sends to argue their case is likely to be about them, and this
315    /// log is append-only and signed: what lands here cannot be taken back. The digest
316    /// is enough to show later that what was weighed is what was sent.
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub evidence_digest: Option<String>,
319}
320
321/// The party a decision is about, as a pseudonymous reference.
322///
323/// A handle, not an identity: it addresses a party without naming one, so a
324/// record can say who a decision concerned without becoming a place personal
325/// data accumulates. Resolving it back to a person is a separate authority's
326/// job, and deliberately not this one's.
327///
328/// Its integrity comes from the record that carries it — every entry here is
329/// signed and chained — so a handle read out of a verified record is as
330/// trustworthy as the record, and one read anywhere else is not trustworthy at
331/// all.
332#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
333pub struct DecisionSubject {
334    /// The pseudonymous reference itself.
335    pub handle: String,
336    /// The namespace the handle belongs to, and so how it could be resolved.
337    #[serde(skip_serializing_if = "Option::is_none")]
338    pub scheme: Option<String>,
339    /// What the handle was minted for. Pairwise per purpose, so the same party
340    /// is not linkable across two of them.
341    #[serde(skip_serializing_if = "Option::is_none")]
342    pub purpose: Option<String>,
343}
344
345impl<'de> Deserialize<'de> for DecisionSubject {
346    /// Accepts a bare handle or the full object, since a caller with nothing to
347    /// say about scheme or purpose should not have to write an object to say it.
348    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
349        #[derive(Deserialize)]
350        #[serde(untagged)]
351        enum Wire {
352            Handle(String),
353            Full {
354                handle: String,
355                #[serde(default)]
356                scheme: Option<String>,
357                #[serde(default)]
358                purpose: Option<String>,
359            },
360        }
361        Ok(match Wire::deserialize(d)? {
362            Wire::Handle(handle) => DecisionSubject {
363                handle,
364                scheme: None,
365                purpose: None,
366            },
367            Wire::Full {
368                handle,
369                scheme,
370                purpose,
371            } => DecisionSubject {
372                handle,
373                scheme,
374                purpose,
375            },
376        })
377    }
378}
379
380/// A Mission reference recorded on a decision Entry.
381#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
382pub struct MissionRef {
383    pub approver: String,
384    pub s256: String,
385}
386
387/// A party referenced by a record — the accountable owner named by `sponsor`,
388/// or the `decision_subject`. The acting subject is `subject_id` on the entry.
389#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
390pub struct Party {
391    pub kind: String,
392    pub id: String,
393}
394
395/// How an authority-graph edge came to be — the attenuate-vs-mint distinction.
396/// Typing every issuance edge lets the record tell offline narrowing apart from a
397/// trusted-issuer crossing; the two carry different safety properties (only the
398/// former is safe to delegate offline). Only `Attenuate` is used;
399/// `Mint` is reserved and inert, retained so the enum stays stable.
400#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
401pub enum EdgeType {
402    /// Narrowing WITHIN a subject namespace (a decern tenant): child scopes ⊆ delegator,
403    /// same tenant. Offline-delegable — an intermediate delegate produces it with no
404    /// issuer. The default and overwhelmingly common edge.
405    #[default]
406    Attenuate,
407    /// CROSSING a subject namespace: a fresh trusted-issuer binding (an external
408    /// token verified against its JWKS, or a redeemed cross-app assertion). NEVER
409    /// offline-delegable — "you cannot narrow your way into a subject you were never
410    /// given." Reserved and inert: no shipped path emits it.
411    Mint,
412}
413
414fn edge_is_attenuate(e: &EdgeType) -> bool {
415    matches!(e, EdgeType::Attenuate)
416}
417
418/// How `Entry::sponsor` was determined (see [`Entry::sponsor`]).
419#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
420pub enum SponsorSource {
421    /// The pure root of `subject_id`'s delegation chain — no admin override.
422    #[default]
423    Derived,
424    /// An admin explicitly set this sponsor, constrained at write time to
425    /// `subject_id`'s own delegation chain (never a genuine outsider).
426    Explicit,
427}
428
429fn is_derived_sponsor(s: &SponsorSource) -> bool {
430    matches!(s, SponsorSource::Derived)
431}
432
433#[derive(Debug, Clone, Serialize, Deserialize)]
434pub struct Record {
435    pub entry: Entry,
436    pub prev: String,
437    pub hash: String,
438    pub sig_b64: String,
439    /// The fingerprint (hex Ed25519 public key) of the key that signed this record —
440    /// so a KEY-ROTATED log stays verifiable: each record names which key to check
441    /// it against, and a keyring verify picks that key. `None` on legacy records
442    /// written before rotation support (they are all signed by the ledger's original
443    /// key). Envelope-only — NOT part of the hashed entry, so adding it left every
444    /// existing record's hash and signature unchanged.
445    #[serde(default, skip_serializing_if = "Option::is_none")]
446    pub kid: Option<String>,
447}
448
449/// Write-side record: `entry` is pre-serialized text, inlined verbatim, so
450/// the bytes we hash are exactly the bytes that land on disk.
451#[derive(Serialize)]
452struct RecordOut<'a> {
453    entry: &'a serde_json::value::RawValue,
454    prev: &'a str,
455    hash: &'a str,
456    sig_b64: &'a str,
457    #[serde(skip_serializing_if = "Option::is_none")]
458    kid: Option<&'a str>,
459}
460
461/// Read-side record: `entry` captures the exact byte span from the line, so
462/// verification hashes what is actually stored, not a re-serialization.
463#[derive(Deserialize)]
464struct RecordIn {
465    entry: Box<serde_json::value::RawValue>,
466    prev: String,
467    hash: String,
468    sig_b64: String,
469    #[serde(default)]
470    kid: Option<String>,
471}
472
473#[derive(Debug, thiserror::Error)]
474#[non_exhaustive]
475pub enum LedgerError {
476    #[error("ledger I/O error at {path}: {err}")]
477    Io { path: String, err: String },
478    #[error("ledger serialization error: {0}")]
479    Serde(String),
480    #[error("TAMPER at seq {seq}: {why}")]
481    Tamper { seq: u64, why: String },
482    /// The log's FINAL physical line is structurally incomplete — the file does
483    /// not end in a newline, so the trailing record was only partially written
484    /// (a crash between/mid the single `append` write and its `flush`/`sync`).
485    /// DISTINCT from [`Tamper`](LedgerError::Tamper) on purpose: a benign
486    /// crash-during-append must never be reported as an attack. The verified
487    /// PREFIX — every fully-terminated, chain-valid, signature-valid record —
488    /// is intact and is the ledger; `healed_entries` is its length and
489    /// `healed_root` its head hash. The open path HEALS this (truncates the torn
490    /// fragment at `torn_from_offset` in `torn_path`) after first confirming the
491    /// prefix still extends any persisted anchor — because a crash can only ever
492    /// drop an un-acked tail, whereas a shorter-than-anchor prefix means acked
493    /// history was deleted and stays [`Tamper`](LedgerError::Tamper). The
494    /// read-only `verify*` entry points surface this variant rather than healing.
495    #[error(
496        "TORN TAIL: unterminated trailing record (crash mid-append); \
497         {healed_entries} verified records intact before it"
498    )]
499    TornTail {
500        healed_entries: u64,
501        healed_root: Option<String>,
502        torn_path: String,
503        torn_from_offset: u64,
504    },
505}
506
507/// A record's signature is over this 32-byte hash and nothing else. That is what keeps it
508/// out of [`commitment_bytes`]'s space without a tag of its own: every commitment is a
509/// tagged string far longer than 32 bytes, so no record signature can be replayed as one.
510/// Anything else signed by a ledger key must keep that property or take a tag.
511fn chain_hash(entry_bytes: &[u8], prev_hex: &str) -> [u8; 32] {
512    let mut h = Sha256::new();
513    h.update(entry_bytes);
514    h.update(prev_hex.as_bytes());
515    h.finalize().into()
516}
517
518fn io_err(path: &Path, e: impl std::fmt::Display) -> LedgerError {
519    LedgerError::Io {
520        path: path.display().to_string(),
521        err: e.to_string(),
522    }
523}
524
525/// Append-only writer. Opening an existing ledger verifies the whole chain
526/// (and, with this key, every signature) before accepting new entries —
527/// fail-closed: a corrupt audit trail refuses further writes.
528pub struct Ledger {
529    location: Location,
530    /// The path `file` is currently open on: the single file's path in
531    /// unsegmented mode, or the active segment's path in segmented mode. Used
532    /// only for error messages on the write path — reads always go through
533    /// `location`, never this field.
534    active_path: PathBuf,
535    key: SigningKey,
536    file: File,
537    last_hash: String,
538    next_seq: u64,
539    /// The full set of keys trusted to have signed this log: every RETIRED key plus
540    /// the CURRENT signing key. A key-rotated log has entries under more than one
541    /// key; verification (open, [`self_verify`](Ledger::self_verify)) checks each
542    /// record against the key its `kid` names, drawn from this ring — so rotation
543    /// never bricks a long-lived log.
544    verifiers: Vec<VerifyingKey>,
545    /// When true, every append `sync_data()`s to disk before returning — crash-DURABLE
546    /// (a "complete" log cannot lose its tail on power loss), at a per-append fsync cost.
547    /// Off by default (crash-consistent, fast); opt in via [`Ledger::set_sync`] for a
548    /// regulated deployment that must not lose a recorded decision.
549    sync: bool,
550    /// `Some` only for a segmented ledger (opened via
551    /// [`Ledger::open_segmented`]) — the rollover trigger policy plus the
552    /// in-memory manifest state `append` consults/updates. `None` for every
553    /// single-file ledger, the overwhelming majority, which never rolls over.
554    rollover: Option<RolloverState>,
555}
556
557struct RolloverState {
558    policy: RolloverPolicy,
559    manifest: segment::Manifest,
560}
561
562/// Resolve the head `(last_hash, next_seq)` an open should start appending from,
563/// healing a crash-torn tail iff it does not erase acked history.
564///
565/// This is the code that implements the task's core distinction: the anchor is
566/// the line between "a crash dropped an un-acked tail" (heal) and "someone
567/// deleted acked records" (tamper). On [`LedgerError::TornTail`] the persisted
568/// anchor is consulted BEFORE the file is mutated — if the verified prefix no
569/// longer extends the last committed height, the torn tail is really a ragged
570/// truncation of acked history and stays [`LedgerError::Tamper`], with the file
571/// left untouched. Only once the prefix is proven to still cover the anchor is
572/// the torn fragment physically discarded.
573fn resolve_open_head(
574    location: &Location,
575    verifiers: &[VerifyingKey],
576    anchor: Option<&Path>,
577) -> Result<(String, u64), LedgerError> {
578    match verify_inner(location, verifiers, None) {
579        Ok(report) => Ok((
580            report.root.unwrap_or_else(|| GENESIS.to_owned()),
581            report.entries,
582        )),
583        Err(LedgerError::TornTail {
584            healed_entries,
585            healed_root,
586            torn_path,
587            torn_from_offset,
588        }) => {
589            // Consult the anchor BEFORE any mutation.
590            if let Some(anchor_path) = anchor
591                && let Some(cp) = load_anchor(anchor_path)?
592            {
593                if !verifiers.iter().any(|k| verify_checkpoint_sig(&cp, k)) {
594                    return Err(LedgerError::Tamper {
595                        seq: cp.count,
596                        why: "anchor signature is not from a trusted ledger key \
597                                  (forged or wrong-key anchor)"
598                            .into(),
599                    });
600                }
601                // `ledger_extends_checkpoint_at` re-derives the head over the
602                // PREFIX only (torn fragment excluded), so a prefix shorter
603                // than the committed height reports `false` here.
604                if !ledger_extends_checkpoint_at(location, &cp)? {
605                    return Err(LedgerError::Tamper {
606                        seq: cp.count,
607                        why: format!(
608                            "ledger no longer extends its anchor at count {} — the trailing \
609                                 record is unterminated AND the verified prefix is below the last \
610                                 committed height (acked history truncated, not a crash tail)",
611                            cp.count
612                        ),
613                    });
614                }
615            }
616            // Prefix covers the anchor (or there is none): the torn fragment was
617            // never acked. Discard it durably, then adopt the healed head.
618            heal_torn_tail(Path::new(&torn_path), torn_from_offset)?;
619            Ok((
620                healed_root.unwrap_or_else(|| GENESIS.to_owned()),
621                healed_entries,
622            ))
623        }
624        Err(e) => Err(e),
625    }
626}
627
628/// Physically discard a crash-torn trailing fragment by truncating `path` to
629/// `offset` (the byte just past the log's final newline). fsync'd so the
630/// recovery is itself durable — a second crash cannot resurrect the fragment.
631/// Called only after [`resolve_open_head`] has proven the prefix still covers
632/// any anchor, so this never deletes acked history.
633fn heal_torn_tail(path: &Path, offset: u64) -> Result<(), LedgerError> {
634    let f = OpenOptions::new()
635        .write(true)
636        .open(path)
637        .map_err(|e| io_err(path, e))?;
638    f.set_len(offset).map_err(|e| io_err(path, e))?;
639    f.sync_all().map_err(|e| io_err(path, e))?;
640    Ok(())
641}
642
643/// Open the ledger for append, owner-only.
644///
645/// The record holds decision subjects and the pseudonymous handles the subject-side audit
646/// route is keyed by. It was being created at the process umask — commonly `0644` — while
647/// the signing key and the mission registry beside it are `0600`, which made the audit log
648/// the readable one. `.mode()` applies only when the file is created, so an existing file
649/// is corrected too; both are needed, the same reasoning `decern-store` documents for the
650/// mission registry.
651///
652/// Unlike the signing key this does not refuse a group- or other-readable file: a ledger
653/// is not a secret in the way a key is, existing deployments have readable ones, and
654/// failing their next append would be a worse outcome than tightening it in place.
655fn open_append_owner_only(path: &Path) -> Result<File, LedgerError> {
656    #[cfg(unix)]
657    {
658        use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
659        let file = OpenOptions::new()
660            .create(true)
661            .append(true)
662            .mode(0o600)
663            .open(path)
664            .map_err(|e| io_err(path, e))?;
665        let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
666        Ok(file)
667    }
668    #[cfg(not(unix))]
669    {
670        OpenOptions::new()
671            .create(true)
672            .append(true)
673            .open(path)
674            .map_err(|e| io_err(path, e))
675    }
676}
677
678impl Ledger {
679    /// Open a single-key ledger (the common case). Equivalent to
680    /// [`open_with_verifiers`](Ledger::open_with_verifiers) with no retired keys.
681    pub fn open(path: &Path, key: SigningKey) -> Result<Self, LedgerError> {
682        Self::open_with_verifiers(path, key, Vec::new())
683    }
684
685    /// Open a ledger that may have been KEY-ROTATED: `key` is the current signing
686    /// key, `retired` the public keys of every previously-active signing key. The
687    /// existing chain is verified against the whole keyring (each record by the key
688    /// its `kid` names; legacy kid-less records against any trusted key), so a
689    /// rotated log reopens cleanly. Fail-closed: a record signed by a key NOT in the
690    /// ring is tamper.
691    pub fn open_with_verifiers(
692        path: &Path,
693        key: SigningKey,
694        retired: Vec<VerifyingKey>,
695    ) -> Result<Self, LedgerError> {
696        Self::open_single_inner(path, key, retired, None)
697    }
698
699    /// Shared single-file open. `anchor` is passed through to
700    /// [`resolve_open_head`] so that, when a torn tail is found, the committed
701    /// height is consulted BEFORE the torn fragment is truncated — a ragged
702    /// truncation of acked history is rejected as tamper without ever mutating
703    /// the file.
704    fn open_single_inner(
705        path: &Path,
706        key: SigningKey,
707        retired: Vec<VerifyingKey>,
708        anchor: Option<&Path>,
709    ) -> Result<Self, LedgerError> {
710        if path.is_dir() {
711            return Err(LedgerError::Io {
712                path: path.display().to_string(),
713                err: "this path is a segmented ledger directory — use Ledger::open_segmented \
714                      instead of open/open_with_verifiers"
715                    .into(),
716            });
717        }
718        let mut verifiers = retired;
719        let current = key.verifying_key();
720        if !verifiers.iter().any(|v| v.to_bytes() == current.to_bytes()) {
721            verifiers.push(current);
722        }
723        let location = Location::Single(path.to_owned());
724        let (last_hash, next_seq) = if path.exists() {
725            resolve_open_head(&location, &verifiers, anchor)?
726        } else {
727            (GENESIS.to_owned(), 0)
728        };
729        let file = open_append_owner_only(path)?;
730        Ok(Ledger {
731            location,
732            active_path: path.to_owned(),
733            key,
734            file,
735            last_hash,
736            next_seq,
737            verifiers,
738            sync: false,
739            rollover: None,
740        })
741    }
742
743    /// Open a ledger AND fail-closed check it against its persisted anchor in one
744    /// step — the constructor a server's startup uses. Equivalent to
745    /// [`open_with_verifiers`](Ledger::open_with_verifiers) followed by
746    /// [`verify_against_anchor`](Ledger::verify_against_anchor): the log must be
747    /// internally consistent under the keyring AND still extend its last committed
748    /// height, so a truncation across a restart refuses the open rather than silently
749    /// serving a shortened audit trail.
750    pub fn open_anchored(
751        path: &Path,
752        key: SigningKey,
753        retired: Vec<VerifyingKey>,
754        anchor_path: &Path,
755    ) -> Result<Self, LedgerError> {
756        // Pass the anchor INTO the open so a torn tail is classified against the
757        // committed height before any heal; the post-open check then also covers
758        // the non-torn truncation (records dropped at a clean line boundary, file
759        // still newline-terminated, so no torn tail was raised).
760        let ledger = Self::open_single_inner(path, key, retired, Some(anchor_path))?;
761        ledger.verify_against_anchor(anchor_path)?;
762        Ok(ledger)
763    }
764
765    /// Open (or create) a SEGMENTED ledger at `dir` — the opt-in alternative
766    /// to the single ever-growing file, for a long-lived sovereign deployment.
767    /// `dir` becomes a directory of numbered segment files plus a
768    /// `manifest.json`; `policy` controls when `append` rolls the active
769    /// segment over to a new one. An existing single-file ledger is never
770    /// silently upgraded — this only ever creates or reopens a directory, and
771    /// [`open_with_verifiers`](Ledger::open_with_verifiers) refuses to open a
772    /// directory in the other direction, so the two modes can't be confused
773    /// for each other by accident.
774    ///
775    /// Reopen is fail-closed exactly like the single-file constructors: the
776    /// whole chain (every segment, in order) is re-verified against the
777    /// keyring before any further append is accepted. Every SEALED segment is
778    /// (re-)marked read-only (0444 on Unix) on open — self-healing after a
779    /// crash that landed between committing the manifest and applying that
780    /// permission, since the permission bit is defense-in-depth only, never
781    /// the source of truth (see the `segment` module).
782    pub fn open_segmented(
783        dir: &Path,
784        key: SigningKey,
785        retired: Vec<VerifyingKey>,
786        policy: RolloverPolicy,
787    ) -> Result<Self, LedgerError> {
788        Self::open_segmented_inner(dir, key, retired, policy, None)
789    }
790
791    /// Shared segmented open — the multi-file analogue of
792    /// [`open_single_inner`](Ledger::open_single_inner). A torn tail can only
793    /// ever be in the ACTIVE (last) segment, since every sealed segment ended on
794    /// a committed boundary; `anchor` gates the heal against the committed height
795    /// exactly as in the single-file case.
796    fn open_segmented_inner(
797        dir: &Path,
798        key: SigningKey,
799        retired: Vec<VerifyingKey>,
800        policy: RolloverPolicy,
801        anchor: Option<&Path>,
802    ) -> Result<Self, LedgerError> {
803        let mut verifiers = retired;
804        let current = key.verifying_key();
805        if !verifiers.iter().any(|v| v.to_bytes() == current.to_bytes()) {
806            verifiers.push(current);
807        }
808        fs::create_dir_all(dir).map_err(|e| io_err(dir, e))?;
809        let manifest = match segment::load_manifest(dir)? {
810            Some(m) => m,
811            None => segment::initialize(dir)?,
812        };
813        let location = Location::Segmented(dir.to_owned());
814
815        // Reassert permissions BEFORE verify/heal: seal every sealed segment,
816        // and — crucially — UNSEAL the active segment first, so that if a torn
817        // tail lands in it, `resolve_open_head`'s `heal_torn_tail` (which opens
818        // the file `write(true)`) can truncate the fragment even when a prior
819        // crash left the active segment defensively 0444. Healing a torn tail is
820        // a write; it must never be blocked by the very permission bit the open
821        // path exists to self-heal.
822        for seg in manifest.segments.iter().filter(|s| s.end_seq.is_some()) {
823            segment::seal_file_permissions(&dir.join(&seg.file));
824        }
825        let active = manifest
826            .active()
827            .ok_or_else(|| LedgerError::Io {
828                path: dir.display().to_string(),
829                err: "segmented ledger manifest has no active (unsealed) segment".into(),
830            })?
831            .clone();
832        let active_path = dir.join(&active.file);
833        segment::unseal_file_permissions(&active_path);
834
835        let (last_hash, next_seq) = resolve_open_head(&location, &verifiers, anchor)?;
836
837        let file = open_append_owner_only(&active_path)?;
838        Ok(Ledger {
839            location,
840            active_path,
841            key,
842            file,
843            last_hash,
844            next_seq,
845            verifiers,
846            sync: false,
847            rollover: Some(RolloverState { policy, manifest }),
848        })
849    }
850
851    /// [`open_segmented`](Ledger::open_segmented) plus the same fail-closed
852    /// anchor check [`open_anchored`](Ledger::open_anchored) applies — a
853    /// segmented ledger's committed height is anchored exactly the same way
854    /// as a single-file one's (root+count don't care how many files the bytes
855    /// span).
856    pub fn open_segmented_anchored(
857        dir: &Path,
858        key: SigningKey,
859        retired: Vec<VerifyingKey>,
860        policy: RolloverPolicy,
861        anchor_path: &Path,
862    ) -> Result<Self, LedgerError> {
863        let ledger = Self::open_segmented_inner(dir, key, retired, policy, Some(anchor_path))?;
864        ledger.verify_against_anchor(anchor_path)?;
865        Ok(ledger)
866    }
867
868    /// Rotate the signing key. Entries already written stay verifiable under the
869    /// retired key (kept in the keyring); every subsequent entry is signed by
870    /// `new_key` and carries its `kid`. The chain is uninterrupted — no re-signing of
871    /// the past, and no republish. On the next restart, pass the retired public key
872    /// to [`open_with_verifiers`](Ledger::open_with_verifiers) so the whole log still
873    /// verifies.
874    pub fn rotate(&mut self, new_key: SigningKey) {
875        let current = new_key.verifying_key();
876        if !self
877            .verifiers
878            .iter()
879            .any(|v| v.to_bytes() == current.to_bytes())
880        {
881            self.verifiers.push(current);
882        }
883        self.key = new_key;
884    }
885
886    /// The public keys of every key trusted to have signed this log (retired +
887    /// current), as hex fingerprints — what an auditor pins across a rotation.
888    pub fn verifier_fingerprints(&self) -> Vec<String> {
889        self.verifiers.iter().map(key_fingerprint).collect()
890    }
891
892    /// Enable/disable fsync-per-append (see the `sync` field). Returns self for
893    /// builder-style config right after `open`.
894    pub fn set_sync(&mut self, sync: bool) -> &mut Self {
895        self.sync = sync;
896        self
897    }
898
899    /// Append `entry` to the log. `entry.seq` is assigned here; the serialized
900    /// bytes are hashed into the chain, signed, and written verbatim, so an
901    /// external verifier recomputes the exact same hash from what is on disk.
902    pub fn append(&mut self, mut entry: Entry) -> Result<Record, LedgerError> {
903        if let Some(state) = &self.rollover {
904            let current_bytes = self.file.metadata().map(|m| m.len()).unwrap_or(0);
905            if Self::should_roll_over(state, self.next_seq, current_bytes, entry.ts_ms) {
906                self.roll_over(entry.ts_ms)?;
907            }
908        }
909        // If this append is the active segment's first-ever record, rebase its
910        // `opened_ms` to this entry's real `ts_ms`. Every segment `roll_over`
911        // creates is already seeded correctly (its `opened_ms` IS the
912        // triggering entry's `ts_ms`, so this is a no-op there); the one case
913        // that needs it is segment 1 of a fresh ledger, whose `opened_ms`
914        // `segment::initialize` hardcodes to 0 before any real entry exists to
915        // read a timestamp from. Left unrebased, EVERY append after the first
916        // would compare a real ts_ms against that stale 0 — landing in a
917        // different epoch bucket almost every time and rolling over one record
918        // after the first no matter how close in time the two really are.
919        let this_seq = self.next_seq;
920        if let Some(state) = &self.rollover {
921            let needs_rebase = state
922                .manifest
923                .active()
924                .is_some_and(|s| s.start_seq == this_seq && s.opened_ms != entry.ts_ms);
925            if needs_rebase {
926                // Mutate a CLONE and only commit it to `self.rollover.manifest`
927                // after the persist below succeeds (mirroring `roll_over`'s own
928                // clone-then-commit-on-success discipline a few lines down) — a
929                // transient `save_manifest` failure (disk full, permission
930                // blip) must not leave the in-memory manifest disagreeing with
931                // what's actually on disk, which would otherwise let a
932                // same-timestamp retry see `opened_ms` already "correct" in
933                // memory and silently skip persisting it forever.
934                let mut rebased = state.manifest.clone();
935                if let Some(active) = rebased.active_mut() {
936                    active.opened_ms = entry.ts_ms;
937                }
938                if let Location::Segmented(dir) = &self.location {
939                    segment::save_manifest(dir, &rebased)?;
940                }
941                if let Some(state) = &mut self.rollover {
942                    state.manifest = rebased;
943                }
944            }
945        }
946        entry.seq = self.next_seq;
947        // Serialize the entry ONCE; these exact bytes are hashed, signed,
948        // and written. Nothing downstream re-serializes.
949        let entry_json =
950            serde_json::to_string(&entry).map_err(|e| LedgerError::Serde(e.to_string()))?;
951        let hash = chain_hash(entry_json.as_bytes(), &self.last_hash);
952        let sig = self.key.sign(&hash);
953        let hash_hex = hex::encode(hash);
954        let sig_b64 = B64.encode(sig.to_bytes());
955        // Stamp the signing key's fingerprint so a key-rotated log stays verifiable:
956        // this record names which key signed it. Envelope-only, not hashed.
957        let kid = key_fingerprint(&self.key.verifying_key());
958
959        let raw_entry = serde_json::value::RawValue::from_string(entry_json)
960            .map_err(|e| LedgerError::Serde(e.to_string()))?;
961        let mut line = serde_json::to_string(&RecordOut {
962            entry: &raw_entry,
963            prev: &self.last_hash,
964            hash: &hash_hex,
965            sig_b64: &sig_b64,
966            kid: Some(&kid),
967        })
968        .map_err(|e| LedgerError::Serde(e.to_string()))?;
969        // Terminate IN the same buffer and write it in ONE `write_all`, so the
970        // newline is never a separate syscall from the record it terminates.
971        // This shrinks the partial-write window and makes newline-termination the
972        // single, reliable signal the reopen path keys torn-tail detection on: a
973        // present `\n` proves the whole record landed, its absence proves a torn
974        // (never-acked) tail. (`write_all` may still short-write on a crash, but
975        // it can no longer succeed at the record yet skip a separate terminator.)
976        line.push('\n');
977
978        self.file
979            .write_all(line.as_bytes())
980            .and_then(|_| self.file.flush())
981            // Crash-DURABLE when enabled: force the bytes to disk before we return the
982            // record, so a power loss cannot drop the tail of a log the caller was told
983            // was written. Off by default (fast, crash-consistent).
984            .and_then(|_| {
985                if self.sync {
986                    self.file.sync_data()
987                } else {
988                    Ok(())
989                }
990            })
991            .map_err(|e| io_err(&self.active_path, e))?;
992
993        let record = Record {
994            entry,
995            prev: std::mem::replace(&mut self.last_hash, hash_hex.clone()),
996            hash: hash_hex,
997            sig_b64,
998            kid: Some(kid),
999        };
1000        self.next_seq += 1;
1001        Ok(record)
1002    }
1003
1004    /// Whether the NEXT append (which will carry `next_ts_ms`, on top of
1005    /// `current_bytes` already written to the active segment) should roll
1006    /// over first. `epoch_ms` compares `next_ts_ms` against the active
1007    /// segment's own `opened_ms` — the CALLER-SUPPLIED entry timestamp, never
1008    /// wall-clock time (this crate makes no wall-clock call of its own, so
1009    /// the decision stays deterministic and testable, matching the
1010    /// caller-supplied-time convention used throughout `decern-cli`).
1011    fn should_roll_over(
1012        state: &RolloverState,
1013        current_seq: u64,
1014        current_bytes: u64,
1015        next_ts_ms: u64,
1016    ) -> bool {
1017        // Never roll over a segment holding zero records yet. Without this,
1018        // the very first append into a fresh epoch-policy ledger rolls over
1019        // before writing anything: `segment::initialize` hardcodes the first
1020        // segment's `opened_ms` to 0 (it's created before any entry exists to
1021        // read a timestamp from), so a real ts_ms (~10^12) almost always
1022        // lands in a different epoch bucket than bucket 0 — producing a
1023        // permanently wasted, empty, sealed segment for no benefit. A segment
1024        // can only ever hold MORE by staying active until its first record.
1025        let active_is_empty = state
1026            .manifest
1027            .active()
1028            .is_some_and(|s| s.start_seq == current_seq);
1029        if active_is_empty {
1030            return false;
1031        }
1032        if let Some(max) = state.policy.max_bytes
1033            && current_bytes >= max
1034        {
1035            return true;
1036        }
1037        if let Some(epoch) = state.policy.epoch_ms {
1038            let opened = state
1039                .manifest
1040                .active()
1041                .map(|s| s.opened_ms)
1042                .unwrap_or(next_ts_ms);
1043            if epoch > 0 && next_ts_ms / epoch != opened / epoch {
1044                return true;
1045            }
1046        }
1047        false
1048    }
1049
1050    /// Seal the active segment and switch to a fresh one. Only ever called
1051    /// (from `append`) when `self.rollover` is `Some`, i.e. only for a
1052    /// segmented ledger — see [`segment::roll_over`] for the crash-safety
1053    /// argument (the manifest rename is the single atomic commit point).
1054    fn roll_over(&mut self, next_ts_ms: u64) -> Result<(), LedgerError> {
1055        let Location::Segmented(dir) = &self.location else {
1056            return Err(LedgerError::Io {
1057                path: self.active_path.display().to_string(),
1058                err: "internal error: roll_over called on a non-segmented ledger".into(),
1059            });
1060        };
1061        let dir = dir.clone();
1062        let state = self.rollover.as_ref().ok_or_else(|| LedgerError::Io {
1063            path: dir.display().to_string(),
1064            err: "internal error: roll_over called with no rollover state".into(),
1065        })?;
1066        let (new_manifest, new_path) =
1067            segment::roll_over(&dir, &state.manifest, self.next_seq, next_ts_ms)?;
1068        self.file = OpenOptions::new()
1069            .append(true)
1070            .open(&new_path)
1071            .map_err(|e| io_err(&new_path, e))?;
1072        self.active_path = new_path;
1073        if let Some(state) = self.rollover.as_mut() {
1074            state.manifest = new_manifest;
1075        }
1076        Ok(())
1077    }
1078
1079    /// The current head hash — export and anchor this externally.
1080    pub fn root(&self) -> &str {
1081        &self.last_hash
1082    }
1083
1084    /// Number of entries appended so far (the next sequence number).
1085    pub fn count(&self) -> u64 {
1086        self.next_seq
1087    }
1088
1089    /// Re-read and verify this ledger's own file against its whole keyring (every
1090    /// retired key plus the current one), so a rotated log verifies end-to-end. Used
1091    /// by the admin summary; O(entries), so not for the request hot path.
1092    pub fn self_verify(&self) -> Result<VerifyReport, LedgerError> {
1093        verify_inner(&self.location, &self.verifiers, None)
1094    }
1095
1096    /// Hex of the Ed25519 public key that signs this ledger's entries — the
1097    /// fingerprint an auditor pins.
1098    pub fn pubkey_hex(&self) -> String {
1099        hex::encode(self.key.verifying_key().to_bytes())
1100    }
1101
1102    /// Every stored line, verbatim and unparsed — for a caller who must hold this
1103    /// ledger's lock as briefly as possible. The audit projection copies the bytes out
1104    /// under the lock and does every parse, match and proof after releasing it; what
1105    /// stays under the lock is one sequential read, not three parsing passes.
1106    pub fn raw_records(&self) -> Result<Vec<String>, LedgerError> {
1107        let mut out = Vec::new();
1108        for line in self.location.lines()? {
1109            let line = line?;
1110            if line.trim().is_empty() {
1111                continue;
1112            }
1113            out.push(line);
1114        }
1115        Ok(out)
1116    }
1117
1118    /// A window of records for the admin ledger browser: skip `offset`, take up
1119    /// to `limit`, each as its stored JSON object. Reads the file, so it's an
1120    /// admin/audit path, never the decision hot path.
1121    pub fn read_records(
1122        &self,
1123        offset: usize,
1124        limit: usize,
1125    ) -> Result<Vec<serde_json::Value>, LedgerError> {
1126        // `skip(offset)` and the `limit` break apply to ONE chained iterator
1127        // across every segment (single-file mode: trivially one file) — a
1128        // GLOBAL record index, never a per-segment one. Wrapping this loop
1129        // per-segment instead would re-apply both per file, which is wrong on
1130        // both counts for a window that straddles a segment boundary.
1131        let mut out = Vec::new();
1132        for line in self.location.lines()?.skip(offset) {
1133            if out.len() >= limit {
1134                break;
1135            }
1136            let line = line?;
1137            if line.trim().is_empty() {
1138                continue;
1139            }
1140            let v = serde_json::from_str(&line).map_err(|e| LedgerError::Serde(e.to_string()))?;
1141            out.push(v);
1142        }
1143        Ok(out)
1144    }
1145
1146    /// A window of records as their VERBATIM stored bytes — the exact line each
1147    /// record was written as, preserved byte-for-byte (`RawValue`, no reparse).
1148    /// This is what an EXTERNALLY-VERIFIABLE evidence bundle must ship: the hash
1149    /// chain commits to the entry's stored bytes (`chain_hash(entry_bytes, prev)`),
1150    /// so a third party can only reproduce a record's hash from those exact bytes.
1151    /// `read_records` (which parses to `Value`) would re-serialize and reorder keys,
1152    /// breaking the hash — use this whenever the bytes are the proof, not the data.
1153    pub fn read_raw_records(
1154        &self,
1155        offset: usize,
1156        limit: usize,
1157    ) -> Result<Vec<Box<serde_json::value::RawValue>>, LedgerError> {
1158        // Same global-index discipline as `read_records` — see its comment.
1159        // This is the primitive an evidence bundle's span is built from, so a
1160        // span crossing a segment boundary must come out byte-identical to
1161        // requesting the same span from an equivalent single-file log.
1162        let mut out = Vec::new();
1163        for line in self.location.lines()?.skip(offset) {
1164            if out.len() >= limit {
1165                break;
1166            }
1167            let line = line?;
1168            if line.trim().is_empty() {
1169                continue;
1170            }
1171            let v: Box<serde_json::value::RawValue> =
1172                serde_json::from_str(&line).map_err(|e| LedgerError::Serde(e.to_string()))?;
1173            out.push(v);
1174        }
1175        Ok(out)
1176    }
1177
1178    /// Sign the current head into a [`Checkpoint`] for external anchoring — the
1179    /// operator-independent half of the audit story. Hand it to a notary / SCITT
1180    /// transparency service / another party; they can later prove the log was not
1181    /// rewritten below this point without trusting the operator.
1182    pub fn checkpoint(&self, ts_ms: u64) -> Checkpoint {
1183        let root = self.last_hash.clone();
1184        let count = self.next_seq;
1185        let sig = self.key.sign(&checkpoint_bytes(&root, count, ts_ms));
1186        Checkpoint {
1187            root,
1188            count,
1189            ts_ms,
1190            pubkey_hex: self.pubkey_hex(),
1191            sig_b64: B64.encode(sig.to_bytes()),
1192        }
1193    }
1194
1195    /// Read every record's chain hash, in order, as the Merkle LEAF DATA (the 32 raw bytes
1196    /// each record's `hash` hex encodes). The RFC 9162 tree is built over these, so a leaf
1197    /// is exactly what the chain already commits to — a verifier who checks the chain has
1198    /// already derived every leaf. Audit/export path only (scans the whole file). Fails
1199    /// closed on a record with a missing or non-hex `hash`.
1200    fn merkle_leaves(&self) -> Result<Vec<Vec<u8>>, LedgerError> {
1201        let count = self.next_seq as usize;
1202        leaves_from_records(&self.read_records(0, count)?)
1203    }
1204
1205    /// Sign the current MERKLE tree head — the RFC 9162 root over all record hashes,
1206    /// externally anchorable like [`checkpoint`](Ledger::checkpoint) but enabling COMPACT
1207    /// third-party inclusion/consistency proofs. `tree_size == count`. Signs through the
1208    /// ledger key (a keyless hash committed by a key, same as a checkpoint).
1209    pub fn tree_head(&self, ts_ms: u64) -> Result<TreeHead, LedgerError> {
1210        let leaves = self.merkle_leaves()?;
1211        let root_hex = hex::encode(merkle::tree_hash(&leaves));
1212        Ok(self.sign_tree_head(root_hex, leaves.len() as u64, ts_ms))
1213    }
1214
1215    /// Sign a tree head over an already-computed root — the signing half of
1216    /// [`tree_head`](Ledger::tree_head), for a caller who derived the leaves outside the
1217    /// lock. Signs exactly what it is given: a root computed from a prefix that has since
1218    /// been appended past is still a consistent commitment to that prefix, the same answer
1219    /// the caller would have gotten before the append.
1220    pub fn sign_tree_head(&self, merkle_root: String, tree_size: u64, ts_ms: u64) -> TreeHead {
1221        let sig = self
1222            .key
1223            .sign(&tree_head_bytes(&merkle_root, tree_size, ts_ms));
1224        TreeHead {
1225            merkle_root,
1226            tree_size,
1227            ts_ms,
1228            pubkey_hex: self.pubkey_hex(),
1229            sig_b64: B64.encode(sig.to_bytes()),
1230        }
1231    }
1232
1233    /// A compact RFC 9162 inclusion proof that the record at `seq` (0-based) is committed
1234    /// by the current tree head's root. `Err` if `seq` is past the end of the log.
1235    pub fn inclusion_proof(&self, seq: u64) -> Result<InclusionProof, LedgerError> {
1236        let leaves = self.merkle_leaves()?;
1237        let idx = seq as usize;
1238        let path = merkle::inclusion_proof(&leaves, idx).ok_or_else(|| LedgerError::Tamper {
1239            seq,
1240            why: "inclusion index past the end of the log".into(),
1241        })?;
1242        Ok(InclusionProof {
1243            leaf_index: seq,
1244            tree_size: leaves.len() as u64,
1245            leaf_data: hex::encode(&leaves[idx]),
1246            audit_path: path.iter().map(hex::encode).collect(),
1247        })
1248    }
1249
1250    /// Inclusion proofs for several records, over one pass of the log.
1251    ///
1252    /// [`inclusion_proof`](Ledger::inclusion_proof) derives every leaf in the log to prove
1253    /// one record is in it, which is the right shape for one proof and the wrong shape for
1254    /// a page of them: asking for `m` proofs that way reads and parses the whole log `m`
1255    /// times, and does it holding the lock an append needs. This derives the leaves once.
1256    ///
1257    /// Returns proofs in the order the sequences were given. A sequence past the end of the
1258    /// log fails the whole call rather than being skipped — a page of proofs with a hole in
1259    /// it, where the hole is silent, is worse than no page.
1260    pub fn inclusion_proofs(&self, seqs: &[u64]) -> Result<Vec<InclusionProof>, LedgerError> {
1261        inclusion_proofs_over(&self.merkle_leaves()?, seqs)
1262    }
1263
1264    /// A compact RFC 9162 consistency proof that the log of the first `first_size` records
1265    /// is an exact prefix of the current log — the operator-independent equivocation /
1266    /// truncation check against an EARLIER anchored tree head. `1 <= first_size <= count`.
1267    pub fn consistency_proof(&self, first_size: u64) -> Result<ConsistencyProof, LedgerError> {
1268        let leaves = self.merkle_leaves()?;
1269        let path = merkle::consistency_proof(&leaves, first_size as usize).ok_or_else(|| {
1270            LedgerError::Tamper {
1271                seq: first_size,
1272                why: "consistency first_size out of range (need 1..=count)".into(),
1273            }
1274        })?;
1275        Ok(ConsistencyProof {
1276            first_size,
1277            second_size: leaves.len() as u64,
1278            proof: path.iter().map(hex::encode).collect(),
1279        })
1280    }
1281
1282    /// A single-snapshot read for an evidence bundle: checkpoint, tree_head, and raw records
1283    /// are ALL derived from the SAME in-memory (self.last_hash, self.next_seq) state captured
1284    /// at one moment — unlike calling `checkpoint()`/`tree_head()`/`read_raw_records()` separately
1285    /// (which lets an `append()` land between calls and make the three mutually inconsistent:
1286    /// `checkpoint.count != tree_head.tree_size` or `checkpoint.root` computed over different
1287    /// records than `tree_head`).
1288    ///
1289    /// This is the single-file analog of [`ShardedLedger::evidence_snapshot`](crate::sharded::ShardedLedger::evidence_snapshot).
1290    /// The snapshot captures the log's state at call time; a concurrent `append()` does not
1291    /// change the returned values. Returns `(count, raw_records, checkpoint, tree_head)`.
1292    pub fn snapshot_for_bundle(&self, ts_ms: u64) -> Result<EvidenceSnapshot, LedgerError> {
1293        // Capture the head state once — this is the "snapshot" that all derived values build from.
1294        let count = self.next_seq;
1295        let root = self.last_hash.clone();
1296
1297        // All three outputs are now derived from this same (root, count) pair, so they are
1298        // mutually consistent even if an append happens after this point.
1299        let raw_records = self.read_raw_records(0, count as usize)?;
1300
1301        let cp_sig = self.key.sign(&checkpoint_bytes(&root, count, ts_ms));
1302        let checkpoint = Checkpoint {
1303            root,
1304            count,
1305            ts_ms,
1306            pubkey_hex: self.pubkey_hex(),
1307            sig_b64: B64.encode(cp_sig.to_bytes()),
1308        };
1309
1310        let leaves = leaves_from_records(&self.read_records(0, count as usize)?)?;
1311        let merkle_root = hex::encode(merkle::tree_hash(&leaves));
1312        let tree_size = leaves.len() as u64;
1313        let th_sig = self
1314            .key
1315            .sign(&tree_head_bytes(&merkle_root, tree_size, ts_ms));
1316        let tree_head = TreeHead {
1317            merkle_root,
1318            tree_size,
1319            ts_ms,
1320            pubkey_hex: self.pubkey_hex(),
1321            sig_b64: B64.encode(th_sig.to_bytes()),
1322        };
1323
1324        Ok((count, raw_records, checkpoint, tree_head))
1325    }
1326
1327    /// Seal the current head into the persisted ANCHOR file — the last committed
1328    /// height, durably recorded on THIS node (not only handed to an external notary).
1329    /// On the next [`open_anchored`](Ledger::open_anchored) /
1330    /// [`verify_against_anchor`](Ledger::verify_against_anchor) the log must still
1331    /// extend it, which is what makes a tail-truncation across a restart detectable
1332    /// — a plain reopen accepts any internally-consistent shorter chain and cannot.
1333    pub fn seal_anchor(&self, anchor_path: &Path, ts_ms: u64) -> Result<Checkpoint, LedgerError> {
1334        let cp = self.checkpoint(ts_ms);
1335        save_anchor(anchor_path, &cp)?;
1336        Ok(cp)
1337    }
1338
1339    /// Fail-closed truncation/rewrite check against the persisted anchor. Call it
1340    /// right after opening (or use [`open_anchored`](Ledger::open_anchored)): if an
1341    /// anchor exists it must (a) be signed by a key in this ledger's keyring — a
1342    /// forged anchor cannot be used to downgrade the committed height — and (b) still
1343    /// be extended by the log (at least `count` records that re-derive `root`). Any
1344    /// failure is `Tamper`: the log was truncated below, or rewritten at/below, its
1345    /// last committed height. No anchor file ⇒ `Ok` (nothing committed yet).
1346    pub fn verify_against_anchor(&self, anchor_path: &Path) -> Result<(), LedgerError> {
1347        let Some(cp) = load_anchor(anchor_path)? else {
1348            return Ok(());
1349        };
1350        // (a) The anchor must be vouched by a trusted ledger key (current or retired),
1351        // else an attacker could drop a self-signed anchor at a lower count to mask a
1352        // truncation.
1353        if !self.verifiers.iter().any(|k| verify_checkpoint_sig(&cp, k)) {
1354            return Err(LedgerError::Tamper {
1355                seq: cp.count,
1356                why:
1357                    "anchor signature is not from a trusted ledger key (forged or wrong-key anchor)"
1358                        .into(),
1359            });
1360        }
1361        // (b) The log must still extend the anchor's committed height.
1362        if !ledger_extends_checkpoint_at(&self.location, &cp)? {
1363            return Err(LedgerError::Tamper {
1364                seq: cp.count,
1365                why: format!(
1366                    "ledger no longer extends its anchor at count {} — truncated or rewritten \
1367                     below the last committed height",
1368                    cp.count
1369                ),
1370            });
1371        }
1372        Ok(())
1373    }
1374}
1375
1376/// A signed, externally-anchorable commitment to the ledger's state at a moment:
1377/// the head `root` over the first `count` entries, timestamped and signed by the
1378/// ledger key. It leaks no entry content — only a hash, a count, and a signature —
1379/// so it is safe to publish, hand to a notary, or submit to a SCITT transparency
1380/// service. Because the log is append-only, the root over the first `count` entries
1381/// is fixed forever; an external party holding a checkpoint can re-derive that root
1382/// from the file and, if it disagrees, prove the operator rewrote history — the
1383/// operator-INDEPENDENT verification a log inside the operator's own stack lacks.
1384#[derive(Debug, Clone, Serialize, Deserialize)]
1385pub struct Checkpoint {
1386    pub root: String,
1387    pub count: u64,
1388    pub ts_ms: u64,
1389    /// Hex of the Ed25519 ledger key that signed both the entries and this commitment.
1390    pub pubkey_hex: String,
1391    /// Ed25519 signature over the canonical commitment bytes.
1392    pub sig_b64: String,
1393}
1394
1395/// A signed, externally-anchorable commitment to the ledger's MERKLE state: the RFC 9162
1396/// tree root over the first `tree_size` record hashes, timestamped and signed by the ledger
1397/// key. Parallel to [`Checkpoint`] (the linear-chain head): a `TreeHead` enables COMPACT
1398/// third-party proofs — an inclusion proof shows one record is in the log without shipping
1399/// the whole tail, and a consistency proof between an anchored earlier `TreeHead` and a
1400/// later one proves nothing below the earlier size was rewritten or dropped (closing
1401/// equivocation). Leaks no entry content — only a root, a size, and a signature.
1402#[derive(Debug, Clone, Serialize, Deserialize)]
1403pub struct TreeHead {
1404    /// Hex of the RFC 9162 Merkle Tree Hash over the first `tree_size` record hashes.
1405    pub merkle_root: String,
1406    pub tree_size: u64,
1407    pub ts_ms: u64,
1408    /// Hex of the Ed25519 ledger key that signed this commitment.
1409    pub pubkey_hex: String,
1410    /// Ed25519 signature over the `decern-ledger-tree-head` domain-separated bytes.
1411    pub sig_b64: String,
1412}
1413
1414/// A single-snapshot evidence bundle: record count, raw bytes, and signed commitments
1415/// (checkpoint and merkle tree head) all derived from the same log state at one moment.
1416/// This is the return type of [`Ledger::snapshot_for_bundle`] and
1417/// [`ShardedLedger::evidence_snapshot`](sharded::ShardedLedger::evidence_snapshot).
1418pub type EvidenceSnapshot = (
1419    u64,                                   // record count
1420    Vec<Box<serde_json::value::RawValue>>, // raw record bytes
1421    Checkpoint,                            // signed linear-chain commitment
1422    TreeHead,                              // signed merkle-tree commitment
1423);
1424
1425/// A compact RFC 9162 inclusion proof (hex-encoded): the record at `leaf_index` in a tree
1426/// of `tree_size` leaves is committed by a [`TreeHead`]'s root. `leaf_data` is the record's
1427/// chain hash (the Merkle leaf data — a verifier hashes it with the `0x00` leaf prefix);
1428/// `audit_path` is the sibling hashes bottom-up.
1429#[derive(Debug, Clone, Serialize, Deserialize)]
1430pub struct InclusionProof {
1431    pub leaf_index: u64,
1432    pub tree_size: u64,
1433    pub leaf_data: String,
1434    pub audit_path: Vec<String>,
1435}
1436
1437/// A compact RFC 9162 consistency proof (hex-encoded) that the tree of the first
1438/// `first_size` leaves is an exact prefix of the tree of `second_size` leaves.
1439#[derive(Debug, Clone, Serialize, Deserialize)]
1440pub struct ConsistencyProof {
1441    pub first_size: u64,
1442    pub second_size: u64,
1443    pub proof: Vec<String>,
1444}
1445
1446/// Domain-separated commitment bytes: a fixed `tag` plus a hex field and two decimal
1447/// integers joined by a unit-separator byte (0x1F) that appears in neither hex nor a
1448/// decimal, so no two distinct field tuples collide AND no two tags cross-verify (a
1449/// checkpoint signature can never be replayed as a tree-head signature, or vice versa).
1450/// Shared by [`checkpoint_bytes`] and [`tree_head_bytes`] so the signing convention lives
1451/// in one place.
1452fn commitment_bytes(tag: &str, hex_field: &str, a: u64, b: u64) -> Vec<u8> {
1453    format!("{tag}\x1f{hex_field}\x1f{a}\x1f{b}").into_bytes()
1454}
1455
1456/// The chain-head commitment (linear hash-chain root over `count` entries).
1457fn checkpoint_bytes(root: &str, count: u64, ts_ms: u64) -> Vec<u8> {
1458    commitment_bytes("decern-ledger-checkpoint", root, count, ts_ms)
1459}
1460
1461/// The Merkle-tree-head commitment (RFC 9162 root over `tree_size` record hashes).
1462fn tree_head_bytes(merkle_root: &str, tree_size: u64, ts_ms: u64) -> Vec<u8> {
1463    commitment_bytes("decern-ledger-tree-head", merkle_root, tree_size, ts_ms)
1464}
1465
1466#[derive(Debug)]
1467pub struct VerifyReport {
1468    pub entries: u64,
1469    pub root: Option<String>,
1470    pub signatures_checked: bool,
1471}
1472
1473/// Hex of an Ed25519 public key — the `kid` fingerprint stamped on each record and
1474/// the id an auditor pins.
1475fn key_fingerprint(vk: &VerifyingKey) -> String {
1476    hex::encode(vk.to_bytes())
1477}
1478
1479/// Verify a ledger file: the hash chain always; every entry signature when a key is
1480/// supplied. Single-key convenience over [`verify_with_keys`] — for a key-ROTATED
1481/// log (entries under more than one key) use that with the full keyring.
1482#[must_use = "ledger verification failure must be checked"]
1483pub fn verify(path: &Path, pubkey: Option<&VerifyingKey>) -> Result<VerifyReport, LedgerError> {
1484    let loc = Location::detect(path);
1485    match pubkey {
1486        None => verify_inner(&loc, &[], None),
1487        Some(k) => verify_inner(&loc, std::slice::from_ref(k), None),
1488    }
1489}
1490
1491/// Verify the whole chain (fail-closed on any tamper) AND return a window of the parsed
1492/// records — the OFFLINE auditor read: an auditor holds the ledger file and, out of band,
1493/// the public key, but not the private signing key `Ledger::open` demands. With `pubkey`
1494/// each record's signature is checked too; without it, only the hash chain (still
1495/// fail-closed). The whole log is scanned to verify integrity; only records in
1496/// `[offset, offset+limit)` are materialized (memory stays bounded to the window). The
1497/// records are the exact stored JSON (as `read_records` returns them), NOT verbatim bytes.
1498pub fn read_verified(
1499    path: &Path,
1500    pubkey: Option<&VerifyingKey>,
1501    offset: usize,
1502    limit: usize,
1503) -> Result<(VerifyReport, Vec<serde_json::Value>), LedgerError> {
1504    let loc = Location::detect(path);
1505    let mut window = ReadWindow {
1506        offset,
1507        end: offset.saturating_add(limit),
1508        records: Vec::new(),
1509    };
1510    let report = match pubkey {
1511        None => verify_inner(&loc, &[], Some(&mut window)),
1512        Some(k) => verify_inner(&loc, std::slice::from_ref(k), Some(&mut window)),
1513    }?;
1514    Ok((report, window.records))
1515}
1516
1517/// A bounded collection window for [`read_verified`]: while the whole chain is scanned
1518/// for integrity, only records whose index falls in `[offset, end)` are materialized.
1519struct ReadWindow {
1520    offset: usize,
1521    end: usize,
1522    records: Vec<serde_json::Value>,
1523}
1524
1525/// Verify a ledger file against a KEYRING — the rotation-aware form. Each record is
1526/// checked against the key its `kid` names; a legacy record with no `kid` (written
1527/// before rotation support) is accepted against any key in the ring. A record whose
1528/// `kid` names a key NOT in the ring is tamper (fail-closed: an unknown signer is
1529/// never trusted). An empty ring means "signatures not checked" (chain only), same
1530/// as [`verify`] with `None`.
1531#[must_use = "ledger verification failure must be checked"]
1532pub fn verify_with_keys(path: &Path, keys: &[VerifyingKey]) -> Result<VerifyReport, LedgerError> {
1533    verify_inner(&Location::detect(path), keys, None)
1534}
1535
1536fn verify_inner(
1537    location: &Location,
1538    keys: &[VerifyingKey],
1539    sink: Option<&mut ReadWindow>,
1540) -> Result<VerifyReport, LedgerError> {
1541    let (lines, torn) = location.prefix_lines()?;
1542    // Verify the PREFIX (torn fragment already excluded). A failure here is a
1543    // fault in fully-terminated, acked history → genuine Tamper, propagated
1544    // as-is whether or not a torn fragment also exists.
1545    let report = verify_lines(lines, keys, sink)?;
1546    match torn {
1547        None => Ok(report),
1548        // Prefix is clean AND a torn fragment trails it → report it distinctly so
1549        // callers can tell a benign crash-mid-append from an attack. The open
1550        // path catches this and heals; read-only verifiers surface it.
1551        Some(t) => Err(LedgerError::TornTail {
1552            healed_entries: report.entries,
1553            healed_root: report.root,
1554            torn_path: t.path.display().to_string(),
1555            torn_from_offset: t.offset,
1556        }),
1557    }
1558}
1559
1560/// The shared per-record verify core: hash-chain always, signatures when `keys` is
1561/// non-empty. `lines` yields each stored record's raw JSON text in seq order (an
1562/// `Err` propagates a read failure as-is) — the SAME logic verifies a File-backed
1563/// [`Ledger`]'s lines (via [`verify_inner`]) and a [`sharded::ShardedLedger`] shard's
1564/// stored records (via [`verify_stored_records`]), so the two can never diverge on
1565/// what counts as tamper.
1566fn verify_lines(
1567    lines: impl Iterator<Item = Result<String, LedgerError>>,
1568    keys: &[VerifyingKey],
1569    mut sink: Option<&mut ReadWindow>,
1570) -> Result<VerifyReport, LedgerError> {
1571    let check_sigs = !keys.is_empty();
1572
1573    let mut prev = GENESIS.to_owned();
1574    let mut count: u64 = 0;
1575
1576    for (i, line) in lines.enumerate() {
1577        let line = line?;
1578        if line.trim().is_empty() {
1579            continue;
1580        }
1581        let record: RecordIn = serde_json::from_str(&line).map_err(|e| LedgerError::Tamper {
1582            seq: i as u64,
1583            why: format!("unparseable record: {e}"),
1584        })?;
1585
1586        // Hash the entry bytes EXACTLY as stored — no re-serialization.
1587        let entry_bytes = record.entry.get().as_bytes();
1588        let entry: Entry =
1589            serde_json::from_str(record.entry.get()).map_err(|e| LedgerError::Tamper {
1590                seq: count,
1591                why: format!("unparseable entry: {e}"),
1592            })?;
1593
1594        if entry.seq != count {
1595            return Err(LedgerError::Tamper {
1596                seq: count,
1597                why: format!("sequence break (found seq {})", entry.seq),
1598            });
1599        }
1600        if record.prev != prev {
1601            return Err(LedgerError::Tamper {
1602                seq: count,
1603                why: "broken chain link (prev mismatch — record edited, moved or removed)".into(),
1604            });
1605        }
1606
1607        let hash = chain_hash(entry_bytes, &record.prev);
1608        if hex::encode(hash) != record.hash {
1609            return Err(LedgerError::Tamper {
1610                seq: count,
1611                why: "entry altered (hash mismatch)".into(),
1612            });
1613        }
1614
1615        if check_sigs {
1616            let sig_bytes: [u8; 64] = B64
1617                .decode(&record.sig_b64)
1618                .map_err(|_| LedgerError::Tamper {
1619                    seq: count,
1620                    why: "unparseable signature".into(),
1621                })?
1622                .try_into()
1623                .map_err(|_| LedgerError::Tamper {
1624                    seq: count,
1625                    why: "signature length".into(),
1626                })?;
1627            let sig = decern_crypto::Signature::from_bytes(&sig_bytes);
1628
1629            // Pick the verifying key: the one this record's `kid` names, or — for a
1630            // legacy record with no `kid` — any key in the ring (a pre-rotation log
1631            // was signed by a single key that is in the ring).
1632            let verified = match &record.kid {
1633                Some(kid) => match keys.iter().find(|k| key_fingerprint(k) == *kid) {
1634                    Some(k) => k.verify_strict(&hash, &sig).is_ok(),
1635                    None => {
1636                        return Err(LedgerError::Tamper {
1637                            seq: count,
1638                            why: format!(
1639                                "record signed by key {kid}, which is not in the trusted keyring"
1640                            ),
1641                        });
1642                    }
1643                },
1644                None => keys.iter().any(|k| k.verify_strict(&hash, &sig).is_ok()),
1645            };
1646            if !verified {
1647                return Err(LedgerError::Tamper {
1648                    seq: count,
1649                    why: "signature invalid (chain rewritten with a different key?)".into(),
1650                });
1651            }
1652        }
1653
1654        // Materialize into the read window only when in range — the whole chain is still
1655        // scanned for integrity, but memory stays bounded to `[offset, end)`. Push the
1656        // WHOLE stored line (same shape `read_records` returns: `entry` + envelope), so a
1657        // read_verified consumer sees exactly what the admin projection would, but only
1658        // after the chain (and, with a key, the signatures) checked out.
1659        if let Some(w) = sink.as_deref_mut() {
1660            let idx = count as usize;
1661            if idx >= w.offset && idx < w.end {
1662                let value: serde_json::Value =
1663                    serde_json::from_str(&line).map_err(|e| LedgerError::Serde(e.to_string()))?;
1664                w.records.push(value);
1665            }
1666        }
1667
1668        prev = record.hash;
1669        count += 1;
1670    }
1671
1672    Ok(VerifyReport {
1673        entries: count,
1674        root: if count > 0 { Some(prev) } else { None },
1675        signatures_checked: check_sigs,
1676    })
1677}
1678
1679/// Verify one [`sharded::ShardedLedger`] shard's stored records — the hash-chain +
1680/// signature check [`sharded::ShardedLedger::self_verify`] runs, over the exact
1681/// byte-stable lines a [`decern_store::LedgerHeadStore`] persisted (never a
1682/// re-serialization — same byte-stability discipline as the File path). `records`
1683/// must already be in seq order (what `LedgerHeadStore::with_shard` hands back).
1684pub(crate) fn verify_stored_records(
1685    records: &[decern_store::StoredRecord],
1686    keys: &[VerifyingKey],
1687) -> Result<VerifyReport, LedgerError> {
1688    verify_lines(
1689        records.iter().map(|r| Ok(r.record_json.clone())),
1690        keys,
1691        None,
1692    )
1693}
1694
1695/// Verify a checkpoint's own signature against a pinned key — does the ledger key
1696/// that signs entries also vouch for this commitment? Does not read the ledger.
1697/// The Merkle leaf data of each record, in order: the 32 raw bytes its `hash` hex encodes.
1698/// One definition, shared by the signing side and the read-only verifying side — a leaf that
1699/// meant two different things in two places would produce proofs that verify nowhere.
1700/// The Merkle leaf data of each stored line, in order — the record-form derivation, for a
1701/// caller holding raw lines from [`Ledger::raw_records`]. Only the `hash` field is
1702/// deserialized, and a line without a valid one fails closed exactly as the record
1703/// form does.
1704pub fn leaves_from_lines(lines: &[String]) -> Result<Vec<Vec<u8>>, LedgerError> {
1705    #[derive(serde::Deserialize)]
1706    struct HashOnly {
1707        hash: String,
1708    }
1709    let mut leaves = Vec::with_capacity(lines.len());
1710    for (i, line) in lines.iter().enumerate() {
1711        let h: HashOnly = serde_json::from_str(line).map_err(|_| LedgerError::Tamper {
1712            seq: i as u64,
1713            why: "record missing hash field".into(),
1714        })?;
1715        let bytes = hex::decode(&h.hash).map_err(|_| LedgerError::Tamper {
1716            seq: i as u64,
1717            why: "record hash is not valid hex".into(),
1718        })?;
1719        leaves.push(bytes);
1720    }
1721    Ok(leaves)
1722}
1723
1724/// Inclusion proofs over already-derived leaves — the proving half of
1725/// [`Ledger::inclusion_proofs`], for a caller who took the leaves out from under the
1726/// lock. Returns proofs in the order the sequences were given; a sequence past the end
1727/// fails the whole call rather than being skipped.
1728pub fn inclusion_proofs_over(
1729    leaves: &[Vec<u8>],
1730    seqs: &[u64],
1731) -> Result<Vec<InclusionProof>, LedgerError> {
1732    let tree_size = leaves.len() as u64;
1733    seqs.iter()
1734        .map(|&seq| {
1735            let idx = seq as usize;
1736            let path = merkle::inclusion_proof(leaves, idx).ok_or_else(|| LedgerError::Tamper {
1737                seq,
1738                why: "inclusion index past the end of the log".into(),
1739            })?;
1740            Ok(InclusionProof {
1741                leaf_index: seq,
1742                tree_size,
1743                leaf_data: hex::encode(&leaves[idx]),
1744                audit_path: path.iter().map(hex::encode).collect(),
1745            })
1746        })
1747        .collect()
1748}
1749
1750fn leaves_from_records(recs: &[serde_json::Value]) -> Result<Vec<Vec<u8>>, LedgerError> {
1751    let mut leaves = Vec::with_capacity(recs.len());
1752    for (i, r) in recs.iter().enumerate() {
1753        let hash_hex = r
1754            .get("hash")
1755            .and_then(serde_json::Value::as_str)
1756            .ok_or_else(|| LedgerError::Tamper {
1757                seq: i as u64,
1758                why: "record missing hash field".into(),
1759            })?;
1760        let bytes = hex::decode(hash_hex).map_err(|_| LedgerError::Tamper {
1761            seq: i as u64,
1762            why: "record hash is not valid hex".into(),
1763        })?;
1764        leaves.push(bytes);
1765    }
1766    Ok(leaves)
1767}
1768
1769/// Merkle leaves of the ledger at `path`, read-only — no signing key required.
1770///
1771/// Producing a tree head needs the ledger key, because a commitment nobody signed commits
1772/// nobody. CHECKING one does not: an auditor holds the log and a public key, never the key
1773/// that wrote it. This is the path that makes an anchored commitment verifiable by someone
1774/// other than its author, which is the only thing that makes anchoring worth doing.
1775///
1776/// Verifies the whole chain on the way through, so leaves are never derived from records
1777/// that do not hold together.
1778pub fn merkle_leaves_at(
1779    path: &Path,
1780    pubkey: Option<&VerifyingKey>,
1781) -> Result<Vec<Vec<u8>>, LedgerError> {
1782    let (_report, records) = read_verified(path, pubkey, 0, usize::MAX)?;
1783    leaves_from_records(&records)
1784}
1785
1786#[must_use = "signature verification result must be checked"]
1787pub fn verify_checkpoint_sig(cp: &Checkpoint, pubkey: &VerifyingKey) -> bool {
1788    let Ok(bytes) = B64.decode(&cp.sig_b64) else {
1789        return false;
1790    };
1791    let Ok(sig_arr): Result<[u8; 64], _> = bytes.try_into() else {
1792        return false;
1793    };
1794    let sig = decern_crypto::Signature::from_bytes(&sig_arr);
1795    pubkey
1796        .verify_strict(&checkpoint_bytes(&cp.root, cp.count, cp.ts_ms), &sig)
1797        .is_ok()
1798}
1799
1800/// Verify a tree head's own signature against a pinned key — the Merkle counterpart of
1801/// [`verify_checkpoint_sig`]. The domain-separated `decern-ledger-tree-head` tag means this
1802/// never cross-verifies a checkpoint signature. Does not read the ledger.
1803#[must_use = "signature verification result must be checked"]
1804pub fn verify_tree_head_sig(th: &TreeHead, pubkey: &VerifyingKey) -> bool {
1805    let Ok(bytes) = B64.decode(&th.sig_b64) else {
1806        return false;
1807    };
1808    let Ok(sig_arr): Result<[u8; 64], _> = bytes.try_into() else {
1809        return false;
1810    };
1811    let sig = decern_crypto::Signature::from_bytes(&sig_arr);
1812    pubkey
1813        .verify_strict(
1814            &tree_head_bytes(&th.merkle_root, th.tree_size, th.ts_ms),
1815            &sig,
1816        )
1817        .is_ok()
1818}
1819
1820/// The per-check result of verifying an exported evidence bundle offline. `accepted` is
1821/// the AND of every APPLICABLE check (an `Option` check that is `None` did not apply and
1822/// does not gate acceptance). Serializable so a CLI can emit it as `--json` verbatim.
1823#[derive(Debug, Clone, Serialize)]
1824pub struct BundleVerdict {
1825    pub accepted: bool,
1826    pub format: String,
1827    pub records: usize,
1828    pub from: u64,
1829    /// True when the bundle is a full tail from genesis (`from == 0`) — only then can a
1830    /// verifier recompute the Merkle root and a consistency proof from the records alone.
1831    pub full_tail: bool,
1832    pub chain_ok: bool,
1833    pub record_sigs_ok: bool,
1834    pub checkpoint_sig_ok: bool,
1835    pub tree_head_present: bool,
1836    pub tree_head_sig_ok: bool,
1837    /// `Some(ok)` when recomputed from a full tail; `None` for a partial tail.
1838    pub merkle_root_ok: Option<bool>,
1839    pub anchor_ok: bool,
1840    /// `Some(ok)` when an `--against` earlier tree head was supplied AND checkable.
1841    pub consistency_ok: Option<bool>,
1842    pub errors: Vec<String>,
1843}
1844
1845#[derive(Deserialize)]
1846struct BundleIn {
1847    #[serde(default)]
1848    format: String,
1849    #[serde(default)]
1850    span: SpanIn,
1851    checkpoint: Checkpoint,
1852    #[serde(default)]
1853    tree_head: Option<TreeHead>,
1854    #[serde(default)]
1855    records: Vec<RecordIn>,
1856}
1857
1858#[derive(Deserialize, Default)]
1859struct SpanIn {
1860    #[serde(default)]
1861    from: u64,
1862}
1863
1864/// Does any key in `keys` verify `sig_b64` over `msg`? (Keyring-aware: a rotated log's
1865/// records are signed by different keys; a verifier holds the current + retired public
1866/// keys.) Fail-closed on a malformed signature.
1867fn any_key_verifies(msg: &[u8], sig_b64: &str, keys: &[VerifyingKey]) -> bool {
1868    let Ok(bytes) = B64.decode(sig_b64) else {
1869        return false;
1870    };
1871    let Ok(arr): Result<[u8; 64], _> = bytes.try_into() else {
1872        return false;
1873    };
1874    let sig = decern_crypto::Signature::from_bytes(&arr);
1875    keys.iter().any(|k| k.verify_strict(msg, &sig).is_ok())
1876}
1877
1878/// Verify an exported `decern-evidence-bundle` OFFLINE against a PINNED keyring — the standalone
1879/// third-party check with no call back to the server. `bundle_json` is the RAW bundle file
1880/// text (NOT a re-serialized `Value`): each record's `entry` is captured as verbatim bytes,
1881/// because the chain commits to those exact bytes. `keys` is the pinned current + retired
1882/// public keys (obtained OUT OF BAND — never from the bundle). `against`, if given, is an
1883/// independently-anchored EARLIER tree head; a consistency check then proves the bundle did
1884/// not rewrite or drop anything below that earlier size (equivocation/truncation).
1885///
1886/// Checks (all fail-closed): every record hash re-derives from its verbatim bytes; the chain
1887/// links (genesis when `from == 0`); every record signature; the checkpoint signature; the
1888/// tree-head signature; the anchor (last hash == checkpoint root, positions match count ==
1889/// tree_size); and — for a full tail — the recomputed Merkle root equals the signed tree
1890/// head, plus any requested consistency proof against the anchored earlier head.
1891pub fn verify_evidence_bundle(
1892    bundle_json: &str,
1893    keys: &[VerifyingKey],
1894    against: Option<&TreeHead>,
1895) -> BundleVerdict {
1896    let mut errors: Vec<String> = Vec::new();
1897    let b: BundleIn = match serde_json::from_str(bundle_json) {
1898        Ok(b) => b,
1899        Err(e) => {
1900            return BundleVerdict {
1901                accepted: false,
1902                format: String::new(),
1903                records: 0,
1904                from: 0,
1905                full_tail: false,
1906                chain_ok: false,
1907                record_sigs_ok: false,
1908                checkpoint_sig_ok: false,
1909                tree_head_present: false,
1910                tree_head_sig_ok: false,
1911                merkle_root_ok: None,
1912                anchor_ok: false,
1913                consistency_ok: None,
1914                errors: vec![format!("bundle does not parse: {e}")],
1915            };
1916        }
1917    };
1918
1919    let from = b.span.from;
1920    let full_tail = from == 0;
1921    let n = b.records.len();
1922
1923    // 1) Per-record hash re-derivation + 2) chain links.
1924    let mut chain_ok = true;
1925    let mut leaves: Vec<Vec<u8>> = Vec::with_capacity(n);
1926    for (i, r) in b.records.iter().enumerate() {
1927        let want = hex::encode(chain_hash(r.entry.get().as_bytes(), &r.prev));
1928        if want != r.hash {
1929            chain_ok = false;
1930            errors.push(format!(
1931                "record {i}: hash does not re-derive from its bytes"
1932            ));
1933        }
1934        let expected_prev = if i == 0 {
1935            if full_tail {
1936                GENESIS.to_owned()
1937            } else {
1938                r.prev.clone() // no genesis anchor for a partial tail; link-only below
1939            }
1940        } else {
1941            b.records[i - 1].hash.clone()
1942        };
1943        if r.prev != expected_prev {
1944            chain_ok = false;
1945            errors.push(format!(
1946                "record {i}: prev does not link to the previous record"
1947            ));
1948        }
1949        match hex::decode(&r.hash) {
1950            Ok(bytes) => leaves.push(bytes),
1951            Err(_) => {
1952                chain_ok = false;
1953                errors.push(format!("record {i}: hash is not valid hex"));
1954            }
1955        }
1956    }
1957
1958    // 3) Every record signature against the pinned keyring.
1959    let mut record_sigs_ok = true;
1960    for (i, r) in b.records.iter().enumerate() {
1961        let Ok(msg) = hex::decode(&r.hash) else {
1962            record_sigs_ok = false;
1963            continue;
1964        };
1965        if !any_key_verifies(&msg, &r.sig_b64, keys) {
1966            record_sigs_ok = false;
1967            errors.push(format!(
1968                "record {i}: signature not verified by any pinned key"
1969            ));
1970        }
1971    }
1972
1973    // 4) Checkpoint + 5) tree-head signatures.
1974    let checkpoint_sig_ok = keys.iter().any(|k| verify_checkpoint_sig(&b.checkpoint, k));
1975    if !checkpoint_sig_ok {
1976        errors.push("checkpoint signature not verified by any pinned key".into());
1977    }
1978    let tree_head_present = b.tree_head.is_some();
1979    let tree_head_sig_ok = match &b.tree_head {
1980        Some(th) => {
1981            let ok = keys.iter().any(|k| verify_tree_head_sig(th, k));
1982            if !ok {
1983                errors.push("tree-head signature not verified by any pinned key".into());
1984            }
1985            ok
1986        }
1987        None => {
1988            // Fail-closed: this verifier attests the UPGRADED bundle (the server always
1989            // emits a signed Merkle tree head). A bundle without one gets no Merkle
1990            // commitment, so it is rejected with a legible reason rather than a silent
1991            // acceptance that overstates what was checked.
1992            errors.push(
1993                "bundle has no tree_head (Merkle commitment); this verifier requires the \
1994                 upgraded decern-evidence-bundle shape"
1995                    .into(),
1996            );
1997            false
1998        }
1999    };
2000
2001    // 6) Anchor: the tail terminates at the signed head and positions line up.
2002    let mut anchor_ok = true;
2003    match b.records.last() {
2004        Some(last) if last.hash == b.checkpoint.root => {}
2005        Some(_) => {
2006            anchor_ok = false;
2007            errors.push("last record hash != checkpoint root".into());
2008        }
2009        None => {
2010            // Empty tail: the checkpoint alone attests the head; only valid when from==count.
2011            if from != b.checkpoint.count {
2012                anchor_ok = false;
2013                errors.push("empty tail but from != checkpoint count".into());
2014            }
2015        }
2016    }
2017    if from + n as u64 != b.checkpoint.count {
2018        anchor_ok = false;
2019        errors.push("from + record count != checkpoint count".into());
2020    }
2021    if let Some(th) = &b.tree_head
2022        && th.tree_size != b.checkpoint.count
2023    {
2024        anchor_ok = false;
2025        errors.push("tree_head size != checkpoint count".into());
2026    }
2027
2028    // 7) Full-tail Merkle root recomputation.
2029    let merkle_root_ok = match (&b.tree_head, full_tail && chain_ok) {
2030        (Some(th), true) => {
2031            let recomputed = hex::encode(merkle::tree_hash(&leaves));
2032            let ok = recomputed == th.merkle_root;
2033            if !ok {
2034                errors.push("recomputed Merkle root != signed tree head".into());
2035            }
2036            Some(ok)
2037        }
2038        _ => None,
2039    };
2040
2041    // 8) Optional consistency against an anchored earlier tree head (full tail only).
2042    let consistency_ok = match (against, &b.tree_head, full_tail && chain_ok) {
2043        (Some(earlier), Some(current), true) => {
2044            // The earlier head must itself be pinned-key-signed, else it anchors nothing.
2045            let earlier_sig = keys.iter().any(|k| verify_tree_head_sig(earlier, k));
2046            let first = earlier.tree_size as usize;
2047            let ok = earlier_sig
2048                && first <= leaves.len()
2049                && merkle::consistency_proof(&leaves, first).is_some_and(|path| {
2050                    match (
2051                        hex_to_32(&earlier.merkle_root),
2052                        hex_to_32(&current.merkle_root),
2053                    ) {
2054                        (Some(fr), Some(sr)) => merkle::verify_consistency(
2055                            earlier.tree_size,
2056                            current.tree_size,
2057                            &fr,
2058                            &sr,
2059                            &path,
2060                        ),
2061                        _ => false,
2062                    }
2063                });
2064            if !ok {
2065                errors.push(
2066                    "consistency proof against the earlier anchored tree head FAILED \
2067                     (possible equivocation/truncation, or the earlier head is unsigned)"
2068                        .into(),
2069                );
2070            }
2071            Some(ok)
2072        }
2073        (Some(_), _, false) => {
2074            errors
2075                .push("consistency check needs a full tail (from==0) to recompute; skipped".into());
2076            Some(false)
2077        }
2078        _ => None,
2079    };
2080
2081    let accepted = chain_ok
2082        && record_sigs_ok
2083        && checkpoint_sig_ok
2084        && tree_head_sig_ok
2085        && anchor_ok
2086        && merkle_root_ok.unwrap_or(true)
2087        && consistency_ok.unwrap_or(true);
2088
2089    BundleVerdict {
2090        accepted,
2091        format: b.format,
2092        records: n,
2093        from,
2094        full_tail,
2095        chain_ok,
2096        record_sigs_ok,
2097        checkpoint_sig_ok,
2098        tree_head_present,
2099        tree_head_sig_ok,
2100        merkle_root_ok,
2101        anchor_ok,
2102        consistency_ok,
2103        errors,
2104    }
2105}
2106
2107/// Decode a 64-hex-char string into 32 bytes, or `None`.
2108fn hex_to_32(s: &str) -> Option<[u8; 32]> {
2109    hex::decode(s).ok()?.try_into().ok()
2110}
2111
2112/// The operator-independent tamper check: does the ledger at `path` still extend a
2113/// previously issued `cp`? Re-derives the head over the first `cp.count` records
2114/// from the stored bytes and confirms it equals `cp.root`. If the operator edited,
2115/// reordered, or truncated any entry at or before `count`, the re-derived root
2116/// diverges and this returns `Ok(false)` — caught by anyone holding the old
2117/// checkpoint, without trusting the operator. A well-behaved append-only log always
2118/// extends its past checkpoints.
2119pub fn ledger_extends_checkpoint(path: &Path, cp: &Checkpoint) -> Result<bool, LedgerError> {
2120    ledger_extends_checkpoint_at(&Location::detect(path), cp)
2121}
2122
2123fn ledger_extends_checkpoint_at(location: &Location, cp: &Checkpoint) -> Result<bool, LedgerError> {
2124    Ok(root_at_count(location, cp.count)?.as_deref() == Some(cp.root.as_str()))
2125}
2126
2127/// Persist a checkpoint as the ledger's anchor file, atomically (temp + fsync +
2128/// rename + parent-dir fsync) so a crash cannot leave a half-written or non-durable
2129/// anchor — a lost anchor write would silently lower the height truncation is checked
2130/// against. See [`Ledger::seal_anchor`].
2131pub fn save_anchor(anchor_path: &Path, cp: &Checkpoint) -> Result<(), LedgerError> {
2132    let bytes = serde_json::to_vec_pretty(cp).map_err(|e| LedgerError::Serde(e.to_string()))?;
2133    let tmp = anchor_path.with_extension("anchor-tmp");
2134    {
2135        let mut f = File::create(&tmp).map_err(|e| io_err(&tmp, e))?;
2136        f.write_all(&bytes).map_err(|e| io_err(&tmp, e))?;
2137        f.sync_all().map_err(|e| io_err(&tmp, e))?;
2138    }
2139    std::fs::rename(&tmp, anchor_path).map_err(|e| io_err(anchor_path, e))?;
2140    // fsync the parent dir so the rename (the anchor's new directory entry) is durable.
2141    if let Some(parent) = anchor_path.parent().filter(|p| !p.as_os_str().is_empty())
2142        && let Ok(dir) = File::open(parent)
2143    {
2144        let _ = dir.sync_all();
2145    }
2146    Ok(())
2147}
2148
2149/// Load the persisted anchor, or `None` if no anchor file exists yet. A present but
2150/// unparseable anchor is a hard error (fail-closed — a corrupt anchor is never
2151/// silently treated as "no committed height").
2152pub fn load_anchor(anchor_path: &Path) -> Result<Option<Checkpoint>, LedgerError> {
2153    match std::fs::read(anchor_path) {
2154        Ok(bytes) => Ok(Some(
2155            serde_json::from_slice(&bytes).map_err(|e| LedgerError::Serde(e.to_string()))?,
2156        )),
2157        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
2158        Err(e) => Err(io_err(anchor_path, e)),
2159    }
2160}
2161
2162/// Re-derive the hash-chain head after exactly the first `count` records. Mirrors
2163/// [`verify`]'s per-record hashing (stored bytes + prev), independently, so any
2164/// rewrite below `count` changes the result. `None` if the file holds fewer than
2165/// `count` records (truncated below the checkpoint).
2166fn root_at_count(location: &Location, count: u64) -> Result<Option<String>, LedgerError> {
2167    if count == 0 {
2168        return Ok(Some(GENESIS.to_owned()));
2169    }
2170    let mut prev = GENESIS.to_owned();
2171    let mut seen: u64 = 0;
2172    // Consume the PREFIX (any crash-torn trailing fragment excluded), so the
2173    // anchor/truncation check derives the head over ACKED history only and a
2174    // half-written tail can neither spoof a match nor spuriously error. A prefix
2175    // shorter than `count` still yields `None` — the truncation-below-anchor
2176    // signal, which is exactly how a ragged attacker truncation stays Tamper.
2177    let (lines, _torn) = location.prefix_lines()?;
2178    for line in lines {
2179        let line = line?;
2180        if line.trim().is_empty() {
2181            continue;
2182        }
2183        let record: RecordIn = serde_json::from_str(&line).map_err(|e| LedgerError::Tamper {
2184            seq: seen,
2185            why: format!("unparseable record: {e}"),
2186        })?;
2187        if record.prev != prev {
2188            return Err(LedgerError::Tamper {
2189                seq: seen,
2190                why: "broken chain link (prev mismatch)".into(),
2191            });
2192        }
2193        let hash = hex::encode(chain_hash(record.entry.get().as_bytes(), &record.prev));
2194        if hash != record.hash {
2195            return Err(LedgerError::Tamper {
2196                seq: seen,
2197                why: "entry altered (hash mismatch)".into(),
2198            });
2199        }
2200        prev = record.hash;
2201        seen += 1;
2202        if seen == count {
2203            return Ok(Some(prev));
2204        }
2205    }
2206    Ok(None)
2207}
2208
2209#[cfg(test)]
2210mod tests {
2211    use super::*;
2212    use decern_crypto::Verifier;
2213    use serde_json::json;
2214
2215    fn entry(action: &str, decision: bool) -> Entry {
2216        Entry {
2217            seq: 0, // assigned by append
2218            ts_ms: 1234,
2219            subject_type: "Principal".into(),
2220            subject_id: "agent1".into(),
2221            action: action.into(),
2222            resource_type: "Resource".into(),
2223            resource_id: "claim1".into(),
2224            context: json!({"now": 100}),
2225            decision,
2226            reasons: vec![],
2227            ..Default::default()
2228        }
2229    }
2230
2231    fn tmp(name: &str) -> PathBuf {
2232        let dir = std::env::temp_dir().join(format!("decern-ledger-test-{}", std::process::id()));
2233        std::fs::create_dir_all(&dir).unwrap();
2234        dir.join(name)
2235    }
2236
2237    fn h32(hex_str: &str) -> [u8; 32] {
2238        <[u8; 32]>::try_from(hex::decode(hex_str).unwrap()).unwrap()
2239    }
2240
2241    /// The unlocked path is the locked path: raw lines yield the same leaves, the same
2242    /// proofs, and a head that verifies — one definition of a leaf, reachable two ways.
2243
2244    #[test]
2245    fn the_out_of_lock_projection_path_equals_the_in_lock_one() {
2246        let key = decern_crypto::generate().unwrap();
2247        let vk = key.verifying_key();
2248        let path = tmp("raw-lines.ledger");
2249        let _ = std::fs::remove_file(&path);
2250        let mut l = Ledger::open(&path, key).unwrap();
2251        for i in 0..5 {
2252            l.append(entry(&format!("a{i}"), i % 2 == 0)).unwrap();
2253        }
2254
2255        let lines = l.raw_records().unwrap();
2256        assert_eq!(lines.len(), 5);
2257        let from_lines = leaves_from_lines(&lines).unwrap();
2258        let from_records = l.merkle_leaves().unwrap();
2259        assert_eq!(from_lines, from_records);
2260
2261        let seqs = [0u64, 3, 4];
2262        let over = inclusion_proofs_over(&from_lines, &seqs).unwrap();
2263        let method = l.inclusion_proofs(&seqs).unwrap();
2264        for (a, b) in over.iter().zip(&method) {
2265            assert_eq!(a.leaf_index, b.leaf_index);
2266            assert_eq!(a.tree_size, b.tree_size);
2267            assert_eq!(a.leaf_data, b.leaf_data);
2268            assert_eq!(a.audit_path, b.audit_path);
2269        }
2270
2271        let root_hex = hex::encode(merkle::tree_hash(&from_lines));
2272        let signed = l.sign_tree_head(root_hex, from_lines.len() as u64, 2_000);
2273        let direct = l.tree_head(2_000).unwrap();
2274        assert_eq!(signed.merkle_root, direct.merkle_root);
2275        assert_eq!(signed.tree_size, direct.tree_size);
2276        assert!(verify_tree_head_sig(&signed, &vk));
2277    }
2278
2279    /// A line without a decodable hash fails the whole derivation, exactly as the
2280    /// record form does — a leaf set with a silent hole would prove the wrong tree.
2281    #[test]
2282    fn a_line_without_a_hash_fails_leaf_derivation_closed() {
2283        let lines = vec![r#"{"entry":{}}"#.to_owned()];
2284        assert!(leaves_from_lines(&lines).is_err());
2285        let lines = vec![r#"{"hash":"zz"}"#.to_owned()];
2286        assert!(leaves_from_lines(&lines).is_err());
2287    }
2288
2289    #[test]
2290    fn merkle_tree_head_and_proofs_verify_against_a_real_ledger() {
2291        let key = decern_crypto::generate().unwrap();
2292        let vk = key.verifying_key();
2293        let path = tmp("merkle-th.ledger");
2294        let _ = std::fs::remove_file(&path);
2295        let mut l = Ledger::open(&path, key.clone()).unwrap();
2296
2297        // Grow to 4 records and anchor an EARLIER tree head.
2298        for i in 0..4 {
2299            l.append(entry(&format!("a{i}"), true)).unwrap();
2300        }
2301        let th4 = l.tree_head(1_000).unwrap();
2302        assert_eq!(th4.tree_size, 4);
2303        assert!(verify_tree_head_sig(&th4, &vk));
2304
2305        // Grow to 7 records; a NEW tree head over the whole log.
2306        for i in 0..3 {
2307            l.append(entry(&format!("b{i}"), false)).unwrap();
2308        }
2309        let th7 = l.tree_head(2_000).unwrap();
2310        assert_eq!(th7.tree_size, 7);
2311        assert!(verify_tree_head_sig(&th7, &vk));
2312
2313        // The tree-head signature is pinned to the ledger key AND domain-separated: a
2314        // wrong key or a tampered root does not verify.
2315        let other = decern_crypto::generate().unwrap().verifying_key();
2316        assert!(
2317            !verify_tree_head_sig(&th7, &other),
2318            "wrong key must not verify"
2319        );
2320        let mut tampered = th7.clone();
2321        tampered.merkle_root = "00".repeat(32);
2322        assert!(
2323            !verify_tree_head_sig(&tampered, &vk),
2324            "tampered root rejected"
2325        );
2326        // A checkpoint signature must NOT cross-verify as a tree head (distinct domain tag).
2327        let cp = l.checkpoint(2_000);
2328        let cross = TreeHead {
2329            merkle_root: cp.root.clone(),
2330            tree_size: cp.count,
2331            ts_ms: cp.ts_ms,
2332            pubkey_hex: cp.pubkey_hex.clone(),
2333            sig_b64: cp.sig_b64.clone(),
2334        };
2335        assert!(
2336            !verify_tree_head_sig(&cross, &vk),
2337            "a checkpoint sig must not verify as a tree head"
2338        );
2339
2340        // Inclusion: every record is provably in the size-7 tree under th7's root.
2341        let root7 = h32(&th7.merkle_root);
2342        for seq in 0..7u64 {
2343            let ip = l.inclusion_proof(seq).unwrap();
2344            assert_eq!(ip.tree_size, 7);
2345            let leaf_hash = merkle::hash_leaf(&hex::decode(&ip.leaf_data).unwrap());
2346            let audit: Vec<[u8; 32]> = ip.audit_path.iter().map(|h| h32(h)).collect();
2347            assert!(
2348                merkle::verify_inclusion(seq, 7, &leaf_hash, &root7, &audit),
2349                "record {seq} must prove included"
2350            );
2351        }
2352        assert!(
2353            l.inclusion_proof(7).is_err(),
2354            "out-of-range inclusion rejected"
2355        );
2356
2357        // Consistency: the anchored size-4 tree is an exact prefix of the size-7 head —
2358        // the operator cannot have rewritten or dropped anything below seq 4.
2359        let root4 = h32(&th4.merkle_root);
2360        let consist = l.consistency_proof(4).unwrap();
2361        assert_eq!((consist.first_size, consist.second_size), (4, 7));
2362        let cpath: Vec<[u8; 32]> = consist.proof.iter().map(|h| h32(h)).collect();
2363        assert!(
2364            merkle::verify_consistency(4, 7, &root4, &root7, &cpath),
2365            "size-4 prefix must reconcile with the size-7 head"
2366        );
2367        // A forged earlier root (equivocation) does NOT reconcile.
2368        let mut forged = root4;
2369        forged[0] ^= 0xFF;
2370        assert!(!merkle::verify_consistency(4, 7, &forged, &root7, &cpath));
2371
2372        let _ = std::fs::remove_file(&path);
2373    }
2374
2375    #[test]
2376    fn evidence_bundle_verifies_offline_and_catches_tamper() {
2377        let key = decern_crypto::generate().unwrap();
2378        let vk = key.verifying_key();
2379        let path = tmp("bundle-verify.ledger");
2380        let _ = std::fs::remove_file(&path);
2381        let mut l = Ledger::open(&path, key.clone()).unwrap();
2382        for i in 0..5 {
2383            l.append(entry(&format!("e{i}"), true)).unwrap();
2384        }
2385        let earlier = l.tree_head(500).unwrap(); // size 5 — an externally-anchored head
2386        for i in 0..3 {
2387            l.append(entry(&format!("f{i}"), false)).unwrap();
2388        }
2389        let count = l.count() as usize; // 8
2390
2391        // Assemble the bundle from VERBATIM record bytes (`RawValue::get()`), exactly as
2392        // the server ships them — routing records through json!/Value would reorder the
2393        // entry keys (no preserve_order here) and break the very hashes we verify.
2394        let make_bundle = |from: usize| -> String {
2395            let recs = l.read_raw_records(from, count - from).unwrap();
2396            let records_arr = format!(
2397                "[{}]",
2398                recs.iter().map(|r| r.get()).collect::<Vec<_>>().join(",")
2399            );
2400            format!(
2401                "{{\"format\":\"decern-evidence-bundle/1\",\"span\":{{\"from\":{from}}},\
2402                 \"checkpoint\":{cp},\"tree_head\":{th},\"records\":{records_arr}}}",
2403                cp = serde_json::to_string(&l.checkpoint(900)).unwrap(),
2404                th = serde_json::to_string(&l.tree_head(900).unwrap()).unwrap(),
2405            )
2406        };
2407
2408        // Full tail: everything verifies, the Merkle root recomputes, and the size-5
2409        // anchored head is a proven prefix of the size-8 head.
2410        let full = make_bundle(0);
2411        let v = verify_evidence_bundle(&full, &[vk], Some(&earlier));
2412        assert!(v.accepted, "full bundle must verify: {:?}", v.errors);
2413        assert_eq!(v.merkle_root_ok, Some(true));
2414        assert_eq!(v.consistency_ok, Some(true));
2415
2416        // Wrong pinned key → rejected (record + checkpoint + tree-head sigs all fail).
2417        let other = decern_crypto::generate().unwrap().verifying_key();
2418        assert!(!verify_evidence_bundle(&full, &[other], None).accepted);
2419
2420        // Tampered record entry → its hash no longer re-derives → rejected.
2421        let tampered = full.replacen("\"e0\"", "\"e0X\"", 1);
2422        let vt = verify_evidence_bundle(&tampered, &[vk], None);
2423        assert!(
2424            !vt.accepted && !vt.chain_ok,
2425            "tamper caught: {:?}",
2426            vt.errors
2427        );
2428
2429        // Partial tail (from=3): the Merkle root can't be recomputed (None) but every other
2430        // check still passes, so the bundle is accepted.
2431        let partial = make_bundle(3);
2432        let vp = verify_evidence_bundle(&partial, &[vk], None);
2433        assert!(vp.accepted, "partial tail verifies: {:?}", vp.errors);
2434        assert_eq!(vp.merkle_root_ok, None);
2435
2436        // Equivocation: an earlier head with a forged (unsigned) root fails consistency.
2437        let mut forged = earlier.clone();
2438        forged.merkle_root = "11".repeat(32);
2439        let vf = verify_evidence_bundle(&full, &[vk], Some(&forged));
2440        assert_eq!(vf.consistency_ok, Some(false));
2441        assert!(!vf.accepted);
2442
2443        let _ = std::fs::remove_file(&path);
2444    }
2445
2446    #[test]
2447    fn edge_type_omitted_when_attenuate_recorded_when_mint() {
2448        // Attenuate (the default) is skipped → existing records are byte-identical,
2449        // so their hashes and signatures are unaffected.
2450        let att = serde_json::to_string(&entry("issue_token", true)).unwrap();
2451        assert!(
2452            !att.contains("\"edge\""),
2453            "attenuate edge must be omitted: {att}"
2454        );
2455
2456        // A mint (trusted-issuer crossing) is recorded explicitly.
2457        let mut e = entry("issue_token", true);
2458        e.edge = EdgeType::Mint;
2459        let mint = serde_json::to_string(&e).unwrap();
2460        assert!(
2461            mint.contains("\"edge\":\"Mint\""),
2462            "mint edge recorded: {mint}"
2463        );
2464
2465        // Both round-trip; a record with no `edge` deserializes as Attenuate.
2466        assert_eq!(
2467            serde_json::from_str::<Entry>(&mint).unwrap().edge,
2468            EdgeType::Mint
2469        );
2470        assert_eq!(
2471            serde_json::from_str::<Entry>(&att).unwrap().edge,
2472            EdgeType::Attenuate
2473        );
2474    }
2475
2476    #[test]
2477    fn append_verify_resume() {
2478        let path = tmp("ok.ledger");
2479        std::fs::remove_file(&path).ok();
2480        let key = decern_crypto::generate().unwrap();
2481
2482        let mut l = Ledger::open(&path, key.clone()).unwrap();
2483        l.append(entry("Read", true)).unwrap();
2484        l.append(entry("MoveMoney", false)).unwrap();
2485        drop(l);
2486
2487        // resume with the same key: chain verifies, seq continues
2488        let mut l = Ledger::open(&path, key.clone()).unwrap();
2489        let rec = l.append(entry("Read", true)).unwrap();
2490        assert_eq!(rec.entry.seq, 2);
2491
2492        let report = verify(&path, Some(&key.verifying_key())).unwrap();
2493        assert_eq!(report.entries, 3);
2494        assert!(report.root.is_some());
2495    }
2496
2497    #[test]
2498    fn sync_enabled_ledger_appends_and_verifies() {
2499        // fsync-per-append must be transparent to correctness: same chain, same verify.
2500        let path = tmp("synced.ledger");
2501        std::fs::remove_file(&path).ok();
2502        let key = decern_crypto::generate().unwrap();
2503        let mut l = Ledger::open(&path, key.clone()).unwrap();
2504        l.set_sync(true);
2505        l.append(entry("Read", true)).unwrap();
2506        l.append(entry("MoveMoney", false)).unwrap();
2507        drop(l);
2508        let report = verify(&path, Some(&key.verifying_key())).unwrap();
2509        assert_eq!(report.entries, 2);
2510        assert!(report.signatures_checked);
2511    }
2512
2513    #[test]
2514    fn read_raw_records_bytes_reproduce_the_stored_hash() {
2515        // The evidence-bundle contract: the VERBATIM bytes read back must let an
2516        // external party recompute each record's hash. `read_records` (parse→Value)
2517        // would reorder keys and break this; `read_raw_records` must not.
2518        let path = tmp("raw.ledger");
2519        std::fs::remove_file(&path).ok();
2520        let key = decern_crypto::generate().unwrap();
2521        let mut l = Ledger::open(&path, key.clone()).unwrap();
2522        // A context with several keys — reserialization would reorder them.
2523        let mut e = entry("Pay", true);
2524        e.context = json!({"z": 1, "a": 2, "m": 3, "amount_minor": 500});
2525        l.append(e).unwrap();
2526        l.append(entry("Read", true)).unwrap();
2527
2528        let raw = l.read_raw_records(0, 100).unwrap();
2529        assert_eq!(raw.len(), 2);
2530
2531        // Recompute the chain exactly as an external verifier would.
2532        #[derive(serde::Deserialize)]
2533        struct Rec {
2534            entry: Box<serde_json::value::RawValue>,
2535            prev: String,
2536            hash: String,
2537        }
2538        let mut prev = GENESIS.to_owned();
2539        for line in &raw {
2540            let r: Rec = serde_json::from_str(line.get()).unwrap();
2541            let got = hex::encode(chain_hash(r.entry.get().as_bytes(), &prev));
2542            assert_eq!(got, r.hash, "verbatim bytes reproduce the stored hash");
2543            assert_eq!(r.prev, prev, "chain link continuous");
2544            prev = r.hash;
2545        }
2546        assert_eq!(prev, *l.root(), "final recomputed hash == head");
2547    }
2548
2549    #[test]
2550    fn read_verified_returns_a_verified_window_and_fails_closed_on_tamper() {
2551        let path = tmp("readv.ledger");
2552        std::fs::remove_file(&path).ok();
2553        let key = decern_crypto::generate().unwrap();
2554        let mut l = Ledger::open(&path, key.clone()).unwrap();
2555        l.append(entry("Read", true)).unwrap();
2556        l.append(entry("MoveMoney", false)).unwrap();
2557        l.append(entry("Read", true)).unwrap();
2558        drop(l);
2559
2560        // Full read with the public key: chain + signatures verified, all 3 records back,
2561        // each carrying its `entry` (with the seq inside), same shape read_records returns.
2562        let (report, recs) = read_verified(&path, Some(&key.verifying_key()), 0, 100).unwrap();
2563        assert_eq!(report.entries, 3);
2564        assert!(report.signatures_checked);
2565        assert_eq!(recs.len(), 3);
2566        assert_eq!(recs[0]["entry"]["seq"], json!(0));
2567        assert_eq!(recs[2]["entry"]["action"], json!("Read"));
2568
2569        // Windowed (offset 1, limit 1): whole chain still verified, only the 2nd record
2570        // materialized.
2571        let (report, recs) = read_verified(&path, None, 1, 1).unwrap();
2572        assert_eq!(report.entries, 3, "whole chain scanned for integrity");
2573        assert!(!report.signatures_checked, "no key → chain-only");
2574        assert_eq!(recs.len(), 1);
2575        assert_eq!(recs[0]["entry"]["seq"], json!(1));
2576        assert_eq!(recs[0]["entry"]["action"], json!("MoveMoney"));
2577
2578        // Tamper: flip the deny to allow without re-chaining → a READ must refuse, never
2579        // hand back the altered record as if it were sound.
2580        let text = std::fs::read_to_string(&path).unwrap();
2581        let mut lines: Vec<String> = text.lines().map(str::to_owned).collect();
2582        let mut rec: Record = serde_json::from_str(&lines[1]).unwrap();
2583        rec.entry.decision = true;
2584        lines[1] = serde_json::to_string(&rec).unwrap();
2585        std::fs::write(&path, lines.join("\n") + "\n").unwrap();
2586        let err = read_verified(&path, Some(&key.verifying_key()), 0, 100).unwrap_err();
2587        assert!(matches!(err, LedgerError::Tamper { seq: 1, .. }), "{err}");
2588    }
2589
2590    #[test]
2591    fn flipped_decision_detected() {
2592        let path = tmp("tamper.ledger");
2593        std::fs::remove_file(&path).ok();
2594        let key = decern_crypto::generate().unwrap();
2595        let mut l = Ledger::open(&path, key.clone()).unwrap();
2596        l.append(entry("Read", true)).unwrap();
2597        l.append(entry("MoveMoney", false)).unwrap();
2598        l.append(entry("Read", true)).unwrap();
2599        drop(l);
2600
2601        // flip the deny to an allow without re-chaining
2602        let text = std::fs::read_to_string(&path).unwrap();
2603        let mut lines: Vec<String> = text.lines().map(str::to_owned).collect();
2604        let mut rec: Record = serde_json::from_str(&lines[1]).unwrap();
2605        rec.entry.decision = true;
2606        lines[1] = serde_json::to_string(&rec).unwrap();
2607        std::fs::write(&path, lines.join("\n") + "\n").unwrap();
2608
2609        let err = verify(&path, Some(&key.verifying_key())).unwrap_err();
2610        assert!(matches!(err, LedgerError::Tamper { seq: 1, .. }), "{err}");
2611    }
2612
2613    #[test]
2614    fn rewrite_with_other_key_detected() {
2615        let path = tmp("rewrite.ledger");
2616        std::fs::remove_file(&path).ok();
2617        let honest = decern_crypto::generate().unwrap();
2618        let insider = decern_crypto::generate().unwrap();
2619
2620        // insider fabricates a perfectly self-consistent chain with their own key
2621        let mut l = Ledger::open(&path, insider).unwrap();
2622        l.append(entry("MoveMoney", true)).unwrap();
2623        drop(l);
2624
2625        // chain alone verifies...
2626        assert!(verify(&path, None).is_ok());
2627        // ...but not against the honest ledger key
2628        let err = verify(&path, Some(&honest.verifying_key())).unwrap_err();
2629        assert!(matches!(err, LedgerError::Tamper { .. }));
2630    }
2631
2632    #[test]
2633    fn hostile_float_context_cannot_false_tamper() {
2634        // Regression: serde_json float round-trips are NOT byte-stable
2635        // without the float_roundtrip feature. These exact values used to
2636        // brick an honest ledger. The hash must cover stored bytes, so
2637        // append -> verify -> reopen must all succeed.
2638        let path = tmp("floats.ledger");
2639        std::fs::remove_file(&path).ok();
2640        let key = decern_crypto::generate().unwrap();
2641        let mut l = Ledger::open(&path, key.clone()).unwrap();
2642
2643        for ctx in [
2644            json!({"now": 100, "z": 1.0715660391465826e-75}),
2645            json!({"v": 2.291712365432881e-9}),
2646            json!({"now": 100, "a": 0.1, "b": 1e308, "c": -5.5e-324}),
2647        ] {
2648            let mut e = entry("Read", false);
2649            e.context = ctx;
2650            l.append(e).unwrap();
2651        }
2652        drop(l);
2653
2654        let report = verify(&path, Some(&key.verifying_key())).expect("honest ledger must verify");
2655        assert_eq!(report.entries, 3);
2656        // and it must reopen for new writes
2657        let mut l = Ledger::open(&path, key).expect("honest ledger must reopen");
2658        l.append(entry("Read", true)).unwrap();
2659    }
2660
2661    #[test]
2662    fn corrupt_ledger_refuses_new_writes() {
2663        let path = tmp("refuse.ledger");
2664        std::fs::remove_file(&path).ok();
2665        let key = decern_crypto::generate().unwrap();
2666        let mut l = Ledger::open(&path, key.clone()).unwrap();
2667        l.append(entry("Read", true)).unwrap();
2668        drop(l);
2669
2670        // corrupt it
2671        let text = std::fs::read_to_string(&path).unwrap();
2672        std::fs::write(&path, text.replace("Read", "Raid")).unwrap();
2673
2674        assert!(Ledger::open(&path, key).is_err());
2675    }
2676
2677    #[test]
2678    fn checkpoint_signs_and_verifies() {
2679        let path = tmp("cp-sig.ledger");
2680        std::fs::remove_file(&path).ok();
2681        let key = decern_crypto::generate().unwrap();
2682        let other = decern_crypto::generate().unwrap();
2683        let mut l = Ledger::open(&path, key.clone()).unwrap();
2684        l.append(entry("Read", true)).unwrap();
2685        l.append(entry("MoveMoney", false)).unwrap();
2686
2687        let cp = l.checkpoint(9_999);
2688        assert_eq!(cp.count, 2);
2689        assert_eq!(cp.root, l.root());
2690        assert_eq!(cp.pubkey_hex, l.pubkey_hex());
2691        // the ledger key vouches for the commitment; a different key does not
2692        assert!(verify_checkpoint_sig(&cp, &key.verifying_key()));
2693        assert!(!verify_checkpoint_sig(&cp, &other.verifying_key()));
2694        // and a forged field is rejected (signature covers root+count+ts)
2695        let mut forged = cp.clone();
2696        forged.count = 3;
2697        assert!(!verify_checkpoint_sig(&forged, &key.verifying_key()));
2698    }
2699
2700    #[test]
2701    fn append_only_log_extends_its_own_checkpoint() {
2702        let path = tmp("cp-extend.ledger");
2703        std::fs::remove_file(&path).ok();
2704        let key = decern_crypto::generate().unwrap();
2705        let mut l = Ledger::open(&path, key).unwrap();
2706        l.append(entry("Read", true)).unwrap();
2707        l.append(entry("MoveMoney", false)).unwrap();
2708        let cp = l.checkpoint(1); // an external party holds this
2709        // more activity happens afterwards
2710        l.append(entry("Read", true)).unwrap();
2711        drop(l);
2712        // the honest, append-only log still extends the held checkpoint
2713        assert!(ledger_extends_checkpoint(&path, &cp).unwrap());
2714    }
2715
2716    #[test]
2717    fn rewrite_below_a_held_checkpoint_is_caught() {
2718        let path = tmp("cp-rewrite.ledger");
2719        std::fs::remove_file(&path).ok();
2720        let key = decern_crypto::generate().unwrap();
2721        let mut l = Ledger::open(&path, key).unwrap();
2722        l.append(entry("Read", true)).unwrap();
2723        l.append(entry("MoveMoney", false)).unwrap();
2724        l.append(entry("Read", true)).unwrap();
2725        let cp = l.checkpoint(1);
2726        drop(l);
2727
2728        // operator flips the recorded deny to an allow (without re-chaining)
2729        let text = std::fs::read_to_string(&path).unwrap();
2730        let mut lines: Vec<String> = text.lines().map(str::to_owned).collect();
2731        let mut rec: Record = serde_json::from_str(&lines[1]).unwrap();
2732        rec.entry.decision = true;
2733        lines[1] = serde_json::to_string(&rec).unwrap();
2734        std::fs::write(&path, lines.join("\n") + "\n").unwrap();
2735
2736        // the external holder's re-derivation refuses to confirm the checkpoint
2737        let r = ledger_extends_checkpoint(&path, &cp);
2738        assert!(
2739            matches!(r, Err(LedgerError::Tamper { .. })) || matches!(r, Ok(false)),
2740            "rewrite below a checkpoint must break extension: {r:?}"
2741        );
2742    }
2743
2744    #[test]
2745    fn truncation_below_a_held_checkpoint_is_caught() {
2746        let path = tmp("cp-truncate.ledger");
2747        std::fs::remove_file(&path).ok();
2748        let key = decern_crypto::generate().unwrap();
2749        let mut l = Ledger::open(&path, key).unwrap();
2750        l.append(entry("Read", true)).unwrap();
2751        l.append(entry("MoveMoney", false)).unwrap();
2752        l.append(entry("Read", true)).unwrap();
2753        let cp = l.checkpoint(1); // commits to 3 entries
2754        drop(l);
2755
2756        // operator drops the last recorded entry
2757        let text = std::fs::read_to_string(&path).unwrap();
2758        let kept: Vec<&str> = text.lines().take(2).collect();
2759        std::fs::write(&path, kept.join("\n") + "\n").unwrap();
2760
2761        // fewer records than the checkpoint committed to → does not extend
2762        assert!(!ledger_extends_checkpoint(&path, &cp).unwrap());
2763    }
2764
2765    #[test]
2766    fn decision_entry_serialization_is_stable() {
2767        // Golden serialization + chain hash for a plain Decision entry. The chain
2768        // commits to these exact bytes, so if field order or naming drifts, every
2769        // existing ledger stops verifying.
2770        let js = serde_json::to_string(&entry("Read", true)).unwrap();
2771        assert_eq!(
2772            js,
2773            r#"{"seq":0,"ts_ms":1234,"subject_type":"Principal","subject_id":"agent1","action":"Read","resource_type":"Resource","resource_id":"claim1","context":{"now":100},"decision":true,"reasons":[]}"#,
2774            "Decision entry serialization drifted from the golden value"
2775        );
2776        assert_eq!(
2777            hex::encode(chain_hash(js.as_bytes(), GENESIS)),
2778            "7b53ca2b3294bd92166dc254d1b56ee12a2a95b76807c08867147729405f8194",
2779            "Decision entry chain hash drifted from the golden value"
2780        );
2781    }
2782
2783    // ------------------------------- key rotation -------------------------------
2784
2785    #[test]
2786    fn rotation_keeps_the_whole_chain_verifiable() {
2787        // The core: rotating the signing key must NOT brick a long-lived log.
2788        let path = tmp("rotate.ledger");
2789        std::fs::remove_file(&path).ok();
2790        let old = decern_crypto::generate().unwrap();
2791        let new = decern_crypto::generate().unwrap();
2792
2793        let mut l = Ledger::open(&path, old.clone()).unwrap();
2794        l.append(entry("Read", true)).unwrap(); // signed by old
2795        l.append(entry("Write", true)).unwrap(); // signed by old
2796        l.rotate(new.clone());
2797        l.append(entry("MoveMoney", false)).unwrap(); // signed by new
2798        drop(l);
2799
2800        // The whole chain verifies under the keyring (old + new)...
2801        let report = verify_with_keys(&path, &[old.verifying_key(), new.verifying_key()]).unwrap();
2802        assert_eq!(report.entries, 3);
2803        assert!(report.signatures_checked);
2804
2805        // ...but NOT under either key alone: the pre-rotation records need `old`, the
2806        // post-rotation record needs `new`.
2807        assert!(
2808            verify(&path, Some(&old.verifying_key())).is_err(),
2809            "old key alone cannot verify the post-rotation tail"
2810        );
2811        assert!(
2812            verify(&path, Some(&new.verifying_key())).is_err(),
2813            "new key alone cannot verify the pre-rotation head"
2814        );
2815    }
2816
2817    #[test]
2818    fn rotated_ledger_reopens_with_retired_verifiers() {
2819        let path = tmp("rotate-reopen.ledger");
2820        std::fs::remove_file(&path).ok();
2821        let old = decern_crypto::generate().unwrap();
2822        let new = decern_crypto::generate().unwrap();
2823
2824        let mut l = Ledger::open(&path, old.clone()).unwrap();
2825        l.append(entry("Read", true)).unwrap();
2826        l.rotate(new.clone());
2827        l.append(entry("Write", true)).unwrap();
2828        drop(l);
2829
2830        // Reopening with the CURRENT key alone fails (the head was signed by `old`)...
2831        assert!(
2832            Ledger::open(&path, new.clone()).is_err(),
2833            "reopen without the retired key must fail on the old-signed head"
2834        );
2835        // ...but succeeds when the retired public key is supplied, and can append more.
2836        let mut l =
2837            Ledger::open_with_verifiers(&path, new.clone(), vec![old.verifying_key()]).unwrap();
2838        assert_eq!(l.count(), 2);
2839        l.append(entry("MoveMoney", true)).unwrap(); // signed by new, seq 2
2840        assert!(l.self_verify().is_ok());
2841        // the keyring an auditor pins spans both keys
2842        let fps = l.verifier_fingerprints();
2843        assert!(fps.contains(&key_fingerprint(&old.verifying_key())));
2844        assert!(fps.contains(&key_fingerprint(&new.verifying_key())));
2845    }
2846
2847    #[test]
2848    fn a_record_signed_by_an_untrusted_key_is_tamper() {
2849        // A record whose kid names a key NOT in the ring must be rejected — an
2850        // auditor cannot be fooled into trusting a signer of the attacker's choosing.
2851        let path = tmp("rotate-untrusted.ledger");
2852        std::fs::remove_file(&path).ok();
2853        let honest = decern_crypto::generate().unwrap();
2854        let attacker = decern_crypto::generate().unwrap();
2855
2856        let mut l = Ledger::open(&path, honest.clone()).unwrap();
2857        l.append(entry("Read", true)).unwrap(); // kid = honest fingerprint
2858        drop(l);
2859
2860        // A ring that does NOT contain the record's signing key: its kid names a key
2861        // not in the ring → tamper (fail-closed on an unknown signer), NOT skipped.
2862        let err = verify_with_keys(&path, &[attacker.verifying_key()]).unwrap_err();
2863        assert!(
2864            matches!(err, LedgerError::Tamper { .. }),
2865            "a record whose kid is not in the ring must be tamper: {err}"
2866        );
2867        // The honest ring verifies fine.
2868        assert!(verify_with_keys(&path, &[honest.verifying_key()]).is_ok());
2869    }
2870
2871    // ------------------------------ persisted anchor ------------------------------
2872
2873    #[test]
2874    fn anchor_catches_truncation_across_a_reopen() {
2875        // The core: a plain reopen accepts a truncated (still internally
2876        // consistent) log; the persisted anchor makes the truncation fail-closed.
2877        let path = tmp("anchor-truncate.ledger");
2878        let anchor = tmp("anchor-truncate.anchor");
2879        std::fs::remove_file(&path).ok();
2880        std::fs::remove_file(&anchor).ok();
2881        let key = decern_crypto::generate().unwrap();
2882
2883        let mut l = Ledger::open(&path, key.clone()).unwrap();
2884        l.append(entry("Read", true)).unwrap();
2885        l.append(entry("Write", true)).unwrap();
2886        l.append(entry("MoveMoney", false)).unwrap();
2887        l.seal_anchor(&anchor, 1).unwrap(); // committed height = 3
2888        drop(l);
2889
2890        // an insider truncates the last recorded decision
2891        let text = std::fs::read_to_string(&path).unwrap();
2892        let kept: Vec<&str> = text.lines().take(2).collect();
2893        std::fs::write(&path, kept.join("\n") + "\n").unwrap();
2894
2895        // a plain reopen is fooled — the 2-record log is internally consistent...
2896        assert!(
2897            Ledger::open(&path, key.clone()).is_ok(),
2898            "plain reopen cannot see the truncation"
2899        );
2900        // ...but open_anchored refuses: the log no longer extends its committed height.
2901        let err = match Ledger::open_anchored(&path, key.clone(), Vec::new(), &anchor) {
2902            Ok(_) => panic!("open_anchored must catch the truncation"),
2903            Err(e) => e,
2904        };
2905        assert!(
2906            matches!(err, LedgerError::Tamper { .. }),
2907            "anchor must catch the truncation: {err}"
2908        );
2909    }
2910
2911    #[test]
2912    fn anchor_accepts_a_legit_append_only_extension() {
2913        let path = tmp("anchor-extend.ledger");
2914        let anchor = tmp("anchor-extend.anchor");
2915        std::fs::remove_file(&path).ok();
2916        std::fs::remove_file(&anchor).ok();
2917        let key = decern_crypto::generate().unwrap();
2918
2919        let mut l = Ledger::open(&path, key.clone()).unwrap();
2920        l.append(entry("Read", true)).unwrap();
2921        l.seal_anchor(&anchor, 1).unwrap(); // committed height = 1
2922        l.append(entry("Write", true)).unwrap(); // honest append-only growth
2923        drop(l);
2924
2925        // reopening against the anchor succeeds — the log still extends height 1
2926        let l = Ledger::open_anchored(&path, key, Vec::new(), &anchor).unwrap();
2927        assert_eq!(l.count(), 2);
2928        // no anchor file at all is also fine (nothing committed yet)
2929        let missing = tmp("anchor-missing.anchor");
2930        std::fs::remove_file(&missing).ok();
2931        assert!(l.verify_against_anchor(&missing).is_ok());
2932    }
2933
2934    /// The Ed25519 identity point is a valid encoding of a key of order 1. Under the
2935    /// cofactorless verification equation, the signature `R = identity, S = 0` satisfies
2936    /// it for EVERY message — so an operator who hands an auditor this public key can
2937    /// hand them any log at all and have every record "verify". RFC 8032 §5.1.7 permits
2938    /// the cofactorless check; rejecting a small-order key is what makes verification
2939    /// mean something to a party who did not write the log.
2940    fn small_order_key_and_universal_forgery() -> (VerifyingKey, decern_crypto::Signature) {
2941        let mut identity = [0u8; 32];
2942        identity[0] = 1;
2943        let mut sig_bytes = [0u8; 64];
2944        sig_bytes[0] = 1; // R = identity, S = 0
2945        (
2946            VerifyingKey::from_bytes(&identity).expect("identity is a valid encoding"),
2947            decern_crypto::Signature::from_bytes(&sig_bytes),
2948        )
2949    }
2950
2951    #[test]
2952    fn a_small_order_key_cannot_verify_a_signature_it_never_made() {
2953        let (key, forgery) = small_order_key_and_universal_forgery();
2954        // The premise: this pair really does satisfy the permissive equation.
2955        assert!(
2956            key.verify(b"any message at all", &forgery).is_ok(),
2957            "the forgery must pass the cofactorless check, or this test proves nothing"
2958        );
2959        for msg in [&b"any message at all"[..], b"a fabricated record"] {
2960            assert!(
2961                key.verify_strict(msg, &forgery).is_err(),
2962                "a small-order key must not verify {}",
2963                String::from_utf8_lossy(msg)
2964            );
2965        }
2966    }
2967
2968    #[test]
2969    fn a_fabricated_anchor_under_a_small_order_key_is_refused() {
2970        let (key, forgery) = small_order_key_and_universal_forgery();
2971        let cp = Checkpoint {
2972            root: "00".repeat(32),
2973            count: 9999,
2974            ts_ms: 1,
2975            pubkey_hex: hex::encode(key.to_bytes()),
2976            sig_b64: B64.encode(forgery.to_bytes()),
2977        };
2978        assert!(
2979            !verify_checkpoint_sig(&cp, &key),
2980            "an anchor over a height that was never reached must not verify"
2981        );
2982        let th = TreeHead {
2983            merkle_root: "00".repeat(32),
2984            tree_size: 9999,
2985            ts_ms: 1,
2986            pubkey_hex: hex::encode(key.to_bytes()),
2987            sig_b64: B64.encode(forgery.to_bytes()),
2988        };
2989        assert!(!verify_tree_head_sig(&th, &key));
2990    }
2991
2992    #[test]
2993    fn a_forged_anchor_from_an_untrusted_key_is_refused() {
2994        // An attacker who truncates the log and drops a self-signed anchor at the
2995        // lower height must not be able to mask the truncation — the anchor's own
2996        // signature must come from a trusted ledger key.
2997        let path = tmp("anchor-forged.ledger");
2998        let anchor = tmp("anchor-forged.anchor");
2999        std::fs::remove_file(&path).ok();
3000        std::fs::remove_file(&anchor).ok();
3001        let key = decern_crypto::generate().unwrap();
3002        let attacker = decern_crypto::generate().unwrap();
3003
3004        let mut l = Ledger::open(&path, key.clone()).unwrap();
3005        l.append(entry("Read", true)).unwrap();
3006        l.append(entry("Write", true)).unwrap();
3007        let honest = l.checkpoint(9); // correct (root, count) for the current head
3008        drop(l);
3009
3010        // The attacker fabricates an anchor over the SAME (root, count) — so it still
3011        // "extends" the log — but signs it with their OWN key (the honest ledger's
3012        // records are kid-bound to `key`, so the attacker can't even open it).
3013        let sig = attacker.sign(&checkpoint_bytes(&honest.root, honest.count, honest.ts_ms));
3014        let forged = Checkpoint {
3015            root: honest.root,
3016            count: honest.count,
3017            ts_ms: honest.ts_ms,
3018            pubkey_hex: hex::encode(attacker.verifying_key().to_bytes()),
3019            sig_b64: B64.encode(sig.to_bytes()),
3020        };
3021        save_anchor(&anchor, &forged).unwrap();
3022
3023        // the honest ledger refuses the anchor: its signer is not in the keyring
3024        let l = Ledger::open(&path, key).unwrap();
3025        let err = l.verify_against_anchor(&anchor).unwrap_err();
3026        assert!(
3027            matches!(err, LedgerError::Tamper { .. }),
3028            "a forged anchor must be refused: {err}"
3029        );
3030    }
3031
3032    #[test]
3033    fn legacy_kidless_records_verify_against_the_ring() {
3034        // A record written before rotation support has no `kid`. It must still verify
3035        // against the trusted key (the try-each-key fallback), so upgrading the code
3036        // does not brick pre-existing logs.
3037        let path = tmp("legacy-kidless.ledger");
3038        std::fs::remove_file(&path).ok();
3039        let key = decern_crypto::generate().unwrap();
3040
3041        // Append normally, then strip the `kid` field to simulate a legacy line.
3042        // Do it by byte surgery on the trailing `,"kid":"<hex>"` — parsing to Value
3043        // and re-serializing would reorder the `entry` bytes and break the hash
3044        // (which is exactly why the stored bytes, not a reparse, are the proof).
3045        let mut l = Ledger::open(&path, key.clone()).unwrap();
3046        l.append(entry("Read", true)).unwrap();
3047        drop(l);
3048        let kid = key_fingerprint(&key.verifying_key());
3049        let text = std::fs::read_to_string(&path).unwrap();
3050        let stripped = text.trim_end().replace(&format!(",\"kid\":\"{kid}\""), "");
3051        assert!(!stripped.contains("kid"), "kid removed: {stripped}");
3052        std::fs::write(&path, stripped + "\n").unwrap();
3053
3054        // No kid on the record → verified against any trusted key in the ring.
3055        assert!(verify(&path, Some(&key.verifying_key())).is_ok());
3056        assert!(verify_with_keys(&path, &[key.verifying_key()]).is_ok());
3057    }
3058
3059    // ===================== per-epoch/size segmentation =====================
3060
3061    fn tmp_dir(name: &str) -> PathBuf {
3062        let d = tmp(name);
3063        std::fs::remove_dir_all(&d).ok();
3064        d
3065    }
3066
3067    #[test]
3068    fn open_segmented_creates_a_directory_with_one_active_segment_and_a_manifest() {
3069        let dir = tmp_dir("seg-init");
3070        let key = decern_crypto::generate().unwrap();
3071        let _l = Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::default()).unwrap();
3072        assert!(dir.join("manifest.json").exists());
3073        assert!(dir.join("00000001.jsonl").exists());
3074    }
3075
3076    #[test]
3077    fn segmented_append_and_reopen_preserves_root_and_count() {
3078        let dir = tmp_dir("seg-reopen");
3079        let key = decern_crypto::generate().unwrap();
3080        {
3081            let mut l =
3082                Ledger::open_segmented(&dir, key.clone(), Vec::new(), RolloverPolicy::default())
3083                    .unwrap();
3084            for i in 0..5 {
3085                l.append(entry(&format!("act{i}"), true)).unwrap();
3086            }
3087        }
3088        let l2 = Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::default()).unwrap();
3089        assert_eq!(l2.count(), 5);
3090        let recs = l2.read_records(0, 10).unwrap();
3091        assert_eq!(recs.len(), 5);
3092    }
3093
3094    #[test]
3095    fn segmented_size_rollover_creates_a_second_segment_and_chain_still_verifies() {
3096        let dir = tmp_dir("seg-size-rollover");
3097        let key = decern_crypto::generate().unwrap();
3098        let vk = key.verifying_key();
3099        let mut l = Ledger::open_segmented(
3100            &dir,
3101            key,
3102            Vec::new(),
3103            RolloverPolicy::max_bytes(200), // small enough that a few entries force rollover
3104        )
3105        .unwrap();
3106        for i in 0..8 {
3107            l.append(entry(&format!("action-{i}"), true)).unwrap();
3108        }
3109        drop(l);
3110
3111        let segs: Vec<_> = std::fs::read_dir(&dir)
3112            .unwrap()
3113            .filter_map(|e| e.ok())
3114            .filter(|e| e.file_name().to_string_lossy().ends_with(".jsonl"))
3115            .collect();
3116        assert!(
3117            segs.len() >= 2,
3118            "expected rollover to produce multiple segments, got {}",
3119            segs.len()
3120        );
3121
3122        // The whole segmented directory verifies end-to-end via the SAME free
3123        // function a single file would use — auto-detected via is_dir().
3124        let report = verify_with_keys(&dir, &[vk]).unwrap();
3125        assert_eq!(report.entries, 8);
3126
3127        // Every seq is present exactly once, in order, across the boundary.
3128        let recs = read_verified(&dir, None, 0, 100).unwrap().1;
3129        let seqs: Vec<u64> = recs
3130            .iter()
3131            .map(|r| r["entry"]["seq"].as_u64().unwrap())
3132            .collect();
3133        assert_eq!(seqs, (0..8).collect::<Vec<_>>());
3134    }
3135
3136    #[test]
3137    fn segmented_epoch_rollover_triggers_on_a_bucket_change() {
3138        let dir = tmp_dir("seg-epoch-rollover");
3139        let key = decern_crypto::generate().unwrap();
3140        let mut l =
3141            Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::epoch_ms(1000)).unwrap();
3142        let mut e0 = entry("a", true);
3143        e0.ts_ms = 500; // bucket 0
3144        l.append(e0).unwrap();
3145        let mut e1 = entry("b", true);
3146        e1.ts_ms = 1500; // bucket 1 — crosses the boundary, must roll over
3147        l.append(e1).unwrap();
3148        drop(l);
3149
3150        let segs: Vec<_> = std::fs::read_dir(&dir)
3151            .unwrap()
3152            .filter_map(|e| e.ok())
3153            .filter(|e| e.file_name().to_string_lossy().ends_with(".jsonl"))
3154            .collect();
3155        assert_eq!(segs.len(), 2, "epoch bucket change must trigger a rollover");
3156    }
3157
3158    #[cfg(unix)]
3159    #[test]
3160    fn segmented_sealed_segment_is_chmod_read_only() {
3161        use std::os::unix::fs::PermissionsExt;
3162        let dir = tmp_dir("seg-chmod");
3163        let key = decern_crypto::generate().unwrap();
3164        let mut l =
3165            Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::max_bytes(150)).unwrap();
3166        for i in 0..8 {
3167            l.append(entry(&format!("action-{i}"), true)).unwrap();
3168        }
3169        drop(l);
3170        let sealed = dir.join("00000001.jsonl");
3171        let mode = std::fs::metadata(&sealed).unwrap().permissions().mode() & 0o777;
3172        assert_eq!(
3173            mode, 0o400,
3174            "a sealed segment must be read-only AND not readable by group or other"
3175        );
3176    }
3177
3178    #[test]
3179    fn segmented_read_records_offset_limit_spans_a_segment_boundary_and_matches_single_file() {
3180        // offset/limit must be a GLOBAL record index across segments, not
3181        // re-applied per file. Build the SAME 5 entries
3182        // into a single-file ledger and a 2-segment ledger (rollover forced
3183        // after the 2nd entry) and assert every windowed read matches exactly.
3184        let single_path = tmp("seg-cmp-single.log");
3185        std::fs::remove_file(&single_path).ok();
3186        let seg_dir = tmp_dir("seg-cmp-segmented");
3187        let key = decern_crypto::generate().unwrap();
3188
3189        let mut single = Ledger::open(&single_path, key.clone()).unwrap();
3190        let mut segmented =
3191            Ledger::open_segmented(&seg_dir, key, Vec::new(), RolloverPolicy::max_bytes(1))
3192                .unwrap(); // 1 byte: rolls over before EVERY append after the first
3193
3194        for i in 0..5 {
3195            let e = entry(&format!("act{i}"), true);
3196            single.append(e.clone()).unwrap();
3197            segmented.append(e).unwrap();
3198        }
3199
3200        // Confirm the rollover actually produced multiple segments (else this
3201        // test would trivially pass without exercising the boundary at all).
3202        let segs = std::fs::read_dir(&seg_dir)
3203            .unwrap()
3204            .filter(|e| {
3205                e.as_ref()
3206                    .unwrap()
3207                    .file_name()
3208                    .to_string_lossy()
3209                    .ends_with(".jsonl")
3210            })
3211            .count();
3212        assert!(segs >= 3, "expected several segments, got {segs}");
3213
3214        for (offset, limit) in [(0usize, 5usize), (1, 3), (2, 2), (3, 100), (4, 1), (0, 1)] {
3215            let a = single.read_records(offset, limit).unwrap();
3216            let b = segmented.read_records(offset, limit).unwrap();
3217            assert_eq!(a, b, "read_records({offset}, {limit}) mismatch");
3218
3219            let ar = single.read_raw_records(offset, limit).unwrap();
3220            let br = segmented.read_raw_records(offset, limit).unwrap();
3221            let a_strs: Vec<&str> = ar.iter().map(|v| v.get()).collect();
3222            let b_strs: Vec<&str> = br.iter().map(|v| v.get()).collect();
3223            assert_eq!(
3224                a_strs, b_strs,
3225                "read_raw_records({offset}, {limit}) verbatim-byte mismatch"
3226            );
3227        }
3228    }
3229
3230    #[test]
3231    fn segmented_verify_fails_closed_when_a_manifest_listed_segment_is_missing() {
3232        let dir = tmp_dir("seg-missing-segment");
3233        let key = decern_crypto::generate().unwrap();
3234        let mut l = Ledger::open_segmented(
3235            &dir,
3236            key.clone(),
3237            Vec::new(),
3238            RolloverPolicy::max_bytes(150),
3239        )
3240        .unwrap();
3241        for i in 0..8 {
3242            l.append(entry(&format!("action-{i}"), true)).unwrap();
3243        }
3244        drop(l);
3245
3246        // Delete a SEALED segment's bytes but leave the manifest still naming
3247        // it — the mid-log analogue of a tail truncation. The manifest is
3248        // untrusted metadata; only the actual bytes are load-bearing.
3249        std::fs::remove_file(dir.join("00000001.jsonl")).unwrap();
3250
3251        let err = match Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::default()) {
3252            Err(e) => e,
3253            Ok(_) => panic!("expected open_segmented to fail on a missing segment"),
3254        };
3255        assert!(
3256            matches!(err, LedgerError::Tamper { .. }),
3257            "expected Tamper, got {err:?}"
3258        );
3259    }
3260
3261    #[test]
3262    fn segmented_anchor_catches_a_truncation_that_deletes_a_whole_sealed_segment() {
3263        // Parity with the single-file case: the chain walk ALONE cannot
3264        // distinguish "the log always had 3 records" from "the log had 5 and
3265        // lost the last 2 (plus their segment + manifest entry)" — that is
3266        // exactly what an externally-persisted anchor exists to catch.
3267        let dir = tmp_dir("seg-anchor-truncation");
3268        let anchor_path = tmp("seg-anchor-truncation.anchor");
3269        std::fs::remove_file(&anchor_path).ok();
3270        let key = decern_crypto::generate().unwrap();
3271
3272        let mut l = Ledger::open_segmented(
3273            &dir,
3274            key.clone(),
3275            Vec::new(),
3276            RolloverPolicy::max_bytes(150),
3277        )
3278        .unwrap();
3279        for i in 0..8 {
3280            l.append(entry(&format!("action-{i}"), true)).unwrap();
3281        }
3282        l.seal_anchor(&anchor_path, 999).unwrap();
3283        drop(l);
3284
3285        // Attacker deletes the newest (active) segment's file AND its
3286        // manifest entry, then re-marks the new tail as active so the
3287        // manifest stays SHAPE-VALID (exactly one active segment) — the full,
3288        // sophisticated attacker capability, not just "delete a file and
3289        // leave a dangling reference" (that simpler case is covered by
3290        // `segmented_verify_fails_closed_when_a_manifest_listed_segment_is_missing`
3291        // and is caught earlier, by `segment_paths` itself).
3292        let mut manifest = segment::load_manifest(&dir).unwrap().unwrap();
3293        let last = manifest.segments.pop().unwrap();
3294        if let Some(new_last) = manifest.segments.last_mut() {
3295            new_last.end_seq = None;
3296        }
3297        segment::save_manifest(&dir, &manifest).unwrap();
3298        std::fs::remove_file(dir.join(&last.file)).unwrap();
3299
3300        let err = match Ledger::open_segmented_anchored(
3301            &dir,
3302            key,
3303            Vec::new(),
3304            RolloverPolicy::default(),
3305            &anchor_path,
3306        ) {
3307            Err(e) => e,
3308            Ok(_) => panic!("expected open_segmented_anchored to fail: anchor no longer extended"),
3309        };
3310        assert!(
3311            matches!(err, LedgerError::Tamper { .. }),
3312            "expected Tamper (anchor no longer extended), got {err:?}"
3313        );
3314    }
3315
3316    #[test]
3317    fn open_on_a_segmented_directory_returns_a_clear_error_not_a_raw_os_error() {
3318        let dir = tmp_dir("seg-vs-open");
3319        let key = decern_crypto::generate().unwrap();
3320        let _l = Ledger::open_segmented(&dir, key.clone(), Vec::new(), RolloverPolicy::default())
3321            .unwrap();
3322        let err = match Ledger::open(&dir, key) {
3323            Err(e) => e,
3324            Ok(_) => panic!("expected Ledger::open to refuse a segmented directory"),
3325        };
3326        match err {
3327            LedgerError::Io { err, .. } => assert!(
3328                err.contains("open_segmented"),
3329                "expected a clear pointer to open_segmented, got: {err}"
3330            ),
3331            other => panic!("expected LedgerError::Io, got {other:?}"),
3332        }
3333    }
3334
3335    #[test]
3336    fn open_segmented_ignores_an_orphan_segment_left_by_a_crashed_rollover() {
3337        let dir = tmp_dir("seg-orphan");
3338        let key = decern_crypto::generate().unwrap();
3339        {
3340            let mut l = Ledger::open_segmented(
3341                &dir,
3342                key.clone(),
3343                Vec::new(),
3344                RolloverPolicy::default(), // never auto-rolls; we simulate the crash by hand
3345            )
3346            .unwrap();
3347            l.append(entry("a", true)).unwrap();
3348        }
3349        // Simulate a rollover that created the new segment file but crashed
3350        // before the manifest commit: an extra, higher-numbered file the
3351        // manifest does not (yet) know about.
3352        std::fs::write(dir.join("00000002.jsonl"), b"").unwrap();
3353
3354        let mut l =
3355            Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::max_bytes(1)).unwrap();
3356        assert_eq!(l.count(), 1, "the orphan must not be silently adopted");
3357        // The very next append forces a rollover (max_bytes=1); it must pick
3358        // an index that does NOT collide with the orphan.
3359        l.append(entry("b", true)).unwrap();
3360        assert!(
3361            dir.join("00000003.jsonl").exists(),
3362            "rollover must skip past the orphan's index, not overwrite it"
3363        );
3364        let orphan_still_empty = std::fs::metadata(dir.join("00000002.jsonl")).unwrap().len();
3365        assert_eq!(
3366            orphan_still_empty, 0,
3367            "orphan must never be silently reused/overwritten"
3368        );
3369    }
3370
3371    // ===== segmented-ledger hardening: rollover filenames =====
3372
3373    #[test]
3374    fn roll_over_ignores_a_planted_out_of_range_filename_instead_of_overflowing() {
3375        // A file named exactly u32::MAX (10 digits) would overflow the naive
3376        // `max_index + 1` in roll_over if it were adopted as a real segment
3377        // index. validate_segment_filename's 8-digit shape requirement makes
3378        // max_index's directory scan treat it as "not a segment file" at
3379        // all (the same as it already treats manifest.json itself) — so
3380        // rollover proceeds completely unaffected by the decoy, rather than
3381        // erroring OR overflowing.
3382        let dir = tmp_dir("seg-overflow-guard");
3383        let key = decern_crypto::generate().unwrap();
3384        let mut l =
3385            Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::max_bytes(1)).unwrap();
3386        l.append(entry("a", true)).unwrap();
3387        std::fs::write(dir.join("4294967295.jsonl"), b"").unwrap();
3388
3389        l.append(entry("b", true))
3390            .expect("the decoy filename must not block a normal rollover");
3391        assert!(
3392            dir.join("00000002.jsonl").exists(),
3393            "rollover must proceed to the next real index, unaffected by the decoy"
3394        );
3395        assert!(
3396            !dir.join("00000000.jsonl").exists(),
3397            "must never silently wrap to and create index 0"
3398        );
3399    }
3400
3401    #[test]
3402    fn open_segmented_rejects_a_manifest_that_names_an_out_of_range_segment_filename() {
3403        // Unlike a decoy file merely sitting in the directory (ignored, see
3404        // the sibling test above), a MANIFEST entry naming an out-of-range
3405        // filename must be rejected outright — load_manifest validates every
3406        // segment's `file` field unconditionally, the moment the manifest is
3407        // read, before anything downstream (segment_paths, the active-segment
3408        // pick) can act on it.
3409        let dir = tmp_dir("seg-overflow-manifest");
3410        let key = decern_crypto::generate().unwrap();
3411        {
3412            let mut l =
3413                Ledger::open_segmented(&dir, key.clone(), Vec::new(), RolloverPolicy::default())
3414                    .unwrap();
3415            l.append(entry("a", true)).unwrap();
3416        }
3417        let mut manifest = segment::load_manifest(&dir).unwrap().unwrap();
3418        manifest.segments[0].file = "4294967295.jsonl".into();
3419        // Bypass save_manifest's own (correct) behavior of writing whatever
3420        // it's given — write the tampered manifest directly so this test
3421        // exercises `load_manifest`'s READ-time validation, not a write-path
3422        // check that doesn't exist and shouldn't need to.
3423        std::fs::write(
3424            dir.join("manifest.json"),
3425            serde_json::to_vec_pretty(&manifest).unwrap(),
3426        )
3427        .unwrap();
3428
3429        let err = match Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::default()) {
3430            Err(e) => e,
3431            Ok(_) => panic!("expected an out-of-range manifest filename to be rejected"),
3432        };
3433        assert!(
3434            matches!(err, LedgerError::Tamper { .. }),
3435            "expected Tamper (invalid segment filename), got {err:?}"
3436        );
3437    }
3438
3439    #[test]
3440    fn open_segmented_rejects_a_manifest_with_two_active_segments() {
3441        let dir = tmp_dir("seg-dual-active");
3442        let key = decern_crypto::generate().unwrap();
3443        {
3444            let mut l =
3445                Ledger::open_segmented(&dir, key.clone(), Vec::new(), RolloverPolicy::max_bytes(1))
3446                    .unwrap();
3447            for i in 0..3 {
3448                l.append(entry(&format!("a{i}"), true)).unwrap();
3449            }
3450        }
3451        // Clear end_seq on an already-sealed, non-tail segment — a manifest
3452        // edit alone, no signing key, no byte-level tamper — producing a
3453        // shape-invalid "dual active" manifest.
3454        let mut manifest = segment::load_manifest(&dir).unwrap().unwrap();
3455        manifest.segments[0].end_seq = None;
3456        segment::save_manifest(&dir, &manifest).unwrap();
3457
3458        let err = match Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::default()) {
3459            Err(e) => e,
3460            Ok(_) => panic!("expected a dual-active manifest to be rejected"),
3461        };
3462        assert!(
3463            matches!(err, LedgerError::Tamper { .. }),
3464            "expected Tamper (dual active segment), got {err:?}"
3465        );
3466    }
3467
3468    #[test]
3469    fn open_segmented_rejects_a_manifest_whose_active_segment_is_not_the_last_entry() {
3470        let dir = tmp_dir("seg-active-not-tail");
3471        let key = decern_crypto::generate().unwrap();
3472        {
3473            let mut l =
3474                Ledger::open_segmented(&dir, key.clone(), Vec::new(), RolloverPolicy::max_bytes(1))
3475                    .unwrap();
3476            for i in 0..3 {
3477                l.append(entry(&format!("a{i}"), true)).unwrap();
3478            }
3479        }
3480        // Swap which segment is "active" WITHOUT changing which one is last —
3481        // mark the true tail sealed and an earlier one active, still exactly
3482        // one active segment overall (so the dual-active check alone would
3483        // not catch this), but not the last entry.
3484        let mut manifest = segment::load_manifest(&dir).unwrap().unwrap();
3485        let last_end = manifest.segments.last().unwrap().end_seq;
3486        manifest.segments[0].end_seq = None;
3487        manifest.segments.last_mut().unwrap().end_seq = last_end.or(Some(3));
3488        segment::save_manifest(&dir, &manifest).unwrap();
3489
3490        let err = match Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::default()) {
3491            Err(e) => e,
3492            Ok(_) => panic!("expected a non-tail-active manifest to be rejected"),
3493        };
3494        assert!(
3495            matches!(err, LedgerError::Tamper { .. }),
3496            "expected Tamper (active segment not last), got {err:?}"
3497        );
3498    }
3499
3500    #[test]
3501    fn segment_paths_rejects_a_path_traversal_filename_in_the_manifest() {
3502        // A manifest entry's `file` field must be validated BEFORE it is ever
3503        // joined onto the segment directory — otherwise an unverified read
3504        // (read_records/read_raw_records, which the admin ledger browser and
3505        // the evidence-bundle endpoint both call) could be redirected to
3506        // return the content of an arbitrary file outside the ledger dir.
3507        let dir = tmp_dir("seg-traversal");
3508        let outside = tmp("seg-traversal-secret.jsonl");
3509        std::fs::write(&outside, b"{\"leaked\":true}\n").unwrap();
3510        let key = decern_crypto::generate().unwrap();
3511        {
3512            let mut l =
3513                Ledger::open_segmented(&dir, key.clone(), Vec::new(), RolloverPolicy::default())
3514                    .unwrap();
3515            l.append(entry("a", true)).unwrap();
3516        }
3517        let mut manifest = segment::load_manifest(&dir).unwrap().unwrap();
3518        manifest.segments[0].file = "../seg-traversal-secret.jsonl".into();
3519        segment::save_manifest(&dir, &manifest).unwrap();
3520
3521        let err = match Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::default()) {
3522            Err(e) => e,
3523            Ok(_) => panic!("expected a path-traversal filename to be rejected"),
3524        };
3525        assert!(
3526            matches!(err, LedgerError::Tamper { .. }),
3527            "expected Tamper (invalid segment filename), got {err:?}"
3528        );
3529    }
3530
3531    #[test]
3532    fn fresh_epoch_only_ledger_does_not_waste_an_empty_first_segment() {
3533        // segment::initialize hardcodes the first segment's opened_ms to 0;
3534        // without the "active segment is still empty" guard in
3535        // should_roll_over, the FIRST real append (a realistic ts_ms, order
3536        // 10^12) would roll over before writing anything, since its epoch
3537        // bucket almost never matches bucket 0.
3538        let dir = tmp_dir("seg-epoch-fresh-no-waste");
3539        let key = decern_crypto::generate().unwrap();
3540        let mut l =
3541            Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::epoch_ms(86_400_000))
3542                .unwrap();
3543        let mut e = entry("first", true);
3544        e.ts_ms = 1_780_000_000_000; // a realistic, far-from-zero epoch ms
3545        l.append(e).unwrap();
3546        drop(l);
3547
3548        assert!(
3549            dir.join("00000001.jsonl").exists(),
3550            "the first entry must land in segment 1"
3551        );
3552        assert!(
3553            !dir.join("00000002.jsonl").exists(),
3554            "a still-empty first segment must never be rolled over"
3555        );
3556        let bytes = std::fs::metadata(dir.join("00000001.jsonl")).unwrap().len();
3557        assert!(bytes > 0, "segment 1 must actually hold the record");
3558    }
3559
3560    #[test]
3561    fn segmented_deleting_a_middle_segment_with_manifest_reconciled_is_still_caught() {
3562        // Dropping a middle segment (and editing the manifest to skip it) now
3563        // breaks start/end contiguity between the two neighbors, so
3564        // validate_manifest_shape's contiguity check rejects it at
3565        // load_manifest time — before verify_lines' unconditional per-record
3566        // seq/prev checks would ever get a chance to catch it independently.
3567        // Locks in defense-in-depth: two layers now catch this, not one.
3568        let dir = tmp_dir("seg-middle-gap");
3569        let key = decern_crypto::generate().unwrap();
3570        {
3571            let mut l =
3572                Ledger::open_segmented(&dir, key.clone(), Vec::new(), RolloverPolicy::max_bytes(1))
3573                    .unwrap();
3574            for i in 0..4 {
3575                l.append(entry(&format!("a{i}"), true)).unwrap();
3576            }
3577        }
3578        let mut manifest = segment::load_manifest(&dir).unwrap().unwrap();
3579        assert!(manifest.segments.len() >= 3, "need a real middle segment");
3580        let middle = manifest.segments.remove(1);
3581        segment::save_manifest(&dir, &manifest).unwrap();
3582        std::fs::remove_file(dir.join(&middle.file)).unwrap();
3583
3584        let err = match Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::default()) {
3585            Err(e) => e,
3586            Ok(_) => panic!("expected a reconciled middle-segment gap to still be caught"),
3587        };
3588        assert!(
3589            matches!(err, LedgerError::Tamper { .. }),
3590            "expected Tamper (sequence break), got {err:?}"
3591        );
3592    }
3593
3594    #[test]
3595    fn segmented_read_verified_offset_window_matches_read_records_across_a_boundary() {
3596        // read_verified windows by a per-record `count` incremented inside
3597        // verify_lines; read_records/read_raw_records window by Iterator::skip
3598        // over raw lines. These are two independently-implemented mechanisms
3599        // that only agree because no writer path ever emits a blank line —
3600        // this pins that agreement down for a segmented ledger with a
3601        // non-zero offset spanning a boundary, so a future change that
3602        // decouples the two counting schemes would be caught here.
3603        let dir = tmp_dir("seg-read-verified-parity");
3604        let key = decern_crypto::generate().unwrap();
3605        let vk = key.verifying_key();
3606        let mut l =
3607            Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::max_bytes(1)).unwrap();
3608        for i in 0..6 {
3609            l.append(entry(&format!("a{i}"), true)).unwrap();
3610        }
3611
3612        for (offset, limit) in [(0usize, 100usize), (2, 3), (1, 1), (5, 10)] {
3613            let direct = l.read_records(offset, limit).unwrap();
3614            let (_report, verified) = read_verified(&dir, Some(&vk), offset, limit).unwrap();
3615            assert_eq!(
3616                direct, verified,
3617                "read_verified({offset}, {limit}) must match read_records exactly"
3618            );
3619        }
3620    }
3621
3622    // ===== segmented-ledger hardening: filename ceiling =====
3623
3624    #[test]
3625    fn roll_over_refuses_to_create_a_segment_past_the_8_digit_filename_ceiling() {
3626        // Fabricate a manifest whose sole active segment is already at the
3627        // maximum representable 8-digit index (99,999,999) — no need to
3628        // actually perform 100 million real rollovers to reach the
3629        // boundary. `segment_filename`'s `{:08}` is a MINIMUM width, not a
3630        // cap, so the next policy-triggered rollover would otherwise
3631        // silently create a 9-digit filename that `validate_segment_filename`
3632        // (the very check that closed the original planted-decoy overflow
3633        // bug) then rejects as Tamper on the NEXT read or reopen. The
3634        // rollover must instead refuse cleanly, at append() time.
3635        let dir = tmp_dir("seg-8digit-ceiling");
3636        std::fs::create_dir_all(&dir).unwrap();
3637        let seg_file = segment::segment_filename(99_999_999);
3638        std::fs::write(dir.join(&seg_file), b"").unwrap();
3639        let manifest = segment::Manifest {
3640            version: 1,
3641            segments: vec![segment::SegmentMeta {
3642                file: seg_file,
3643                start_seq: 0,
3644                end_seq: None,
3645                opened_ms: 0,
3646            }],
3647        };
3648        segment::save_manifest(&dir, &manifest).unwrap();
3649
3650        let key = decern_crypto::generate().unwrap();
3651        let mut l =
3652            Ledger::open_segmented(&dir, key.clone(), Vec::new(), RolloverPolicy::max_bytes(1))
3653                .unwrap();
3654        l.append(entry("a", true)).unwrap(); // lands in the pre-seeded segment (active_is_empty)
3655        let err = match l.append(entry("b", true)) {
3656            Err(e) => e,
3657            Ok(_) => panic!("expected rollover past the 8-digit ceiling to be refused"),
3658        };
3659        assert!(
3660            matches!(err, LedgerError::Io { .. }),
3661            "expected a clean Io error at rollover time, got {err:?}"
3662        );
3663        drop(l);
3664
3665        // Crucially: the refused rollover must not have half-committed
3666        // anything — the ledger must still open and verify cleanly
3667        // afterward, holding exactly the one entry that succeeded.
3668        let reopened =
3669            Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::max_bytes(1)).unwrap();
3670        assert_eq!(
3671            reopened.count(),
3672            1,
3673            "only the first append should have landed"
3674        );
3675    }
3676
3677    #[test]
3678    fn segmented_epoch_policy_keeps_two_same_bucket_entries_in_one_segment() {
3679        // Regression for the opened_ms-never-rebased gap: segment 1's
3680        // opened_ms is hardcoded to 0 by segment::initialize (no entry
3681        // exists yet to read a real timestamp from). Without rebasing it to
3682        // the first real entry's ts_ms once that entry lands, EVERY append
3683        // after the first would compare a real ts_ms (~10^12) against the
3684        // stale 0 and roll over one record after the first no matter how
3685        // close in time the two really are — silently defeating epoch-based
3686        // grouping for the entirety of a fresh ledger's first bucket.
3687        let dir = tmp_dir("seg-epoch-rebase");
3688        let key = decern_crypto::generate().unwrap();
3689        let mut l =
3690            Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::epoch_ms(86_400_000))
3691                .unwrap();
3692        let mut e0 = entry("a", true);
3693        e0.ts_ms = 1_780_000_000_000; // some real Unix-ms timestamp
3694        l.append(e0).unwrap();
3695        let mut e1 = entry("b", true);
3696        e1.ts_ms = 1_780_000_000_500; // 500ms later, same epoch bucket
3697        l.append(e1).unwrap();
3698        drop(l);
3699
3700        let segs: Vec<_> = std::fs::read_dir(&dir)
3701            .unwrap()
3702            .filter_map(|e| e.ok())
3703            .filter(|e| e.file_name().to_string_lossy().ends_with(".jsonl"))
3704            .collect();
3705        assert_eq!(
3706            segs.len(),
3707            1,
3708            "two entries in the same real epoch bucket must stay in one segment"
3709        );
3710    }
3711
3712    #[test]
3713    fn open_segmented_rejects_a_manifest_with_reordered_sealed_segments() {
3714        // Swapping the array order of two already-sealed segments (no
3715        // deletion, no active-segment tampering — both existing checks stay
3716        // silent) still breaks start/end contiguity between neighbors and
3717        // must be rejected, since segment_paths reads files strictly in
3718        // manifest array order.
3719        let dir = tmp_dir("seg-reordered");
3720        let key = decern_crypto::generate().unwrap();
3721        {
3722            let mut l =
3723                Ledger::open_segmented(&dir, key.clone(), Vec::new(), RolloverPolicy::max_bytes(1))
3724                    .unwrap();
3725            for i in 0..4 {
3726                l.append(entry(&format!("a{i}"), true)).unwrap();
3727            }
3728        }
3729        let mut manifest = segment::load_manifest(&dir).unwrap().unwrap();
3730        assert!(
3731            manifest.segments.len() >= 3,
3732            "need at least two sealed segments to swap"
3733        );
3734        manifest.segments.swap(0, 1);
3735        segment::save_manifest(&dir, &manifest).unwrap();
3736
3737        let err = match Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::default()) {
3738            Err(e) => e,
3739            Ok(_) => panic!("expected a reordered manifest to be rejected"),
3740        };
3741        assert!(
3742            matches!(err, LedgerError::Tamper { .. }),
3743            "expected Tamper (non-contiguous segments), got {err:?}"
3744        );
3745    }
3746
3747    #[test]
3748    fn open_segmented_rejects_a_manifest_with_zero_segments() {
3749        // segment::initialize never produces an empty segments list, so this
3750        // is a shape no legitimate code path can reach — rejecting it is
3751        // zero-false-positive-risk and closes a gap where the free,
3752        // path-only read functions (verify/read_verified, which never call
3753        // open_segmented's separate zero-active check) would otherwise
3754        // silently report a clean, empty ledger while real segment files
3755        // with real records sit untouched on disk.
3756        let dir = tmp_dir("seg-zero-segments");
3757        let key = decern_crypto::generate().unwrap();
3758        {
3759            let mut l =
3760                Ledger::open_segmented(&dir, key.clone(), Vec::new(), RolloverPolicy::default())
3761                    .unwrap();
3762            l.append(entry("a", true)).unwrap();
3763        }
3764        let empty = segment::Manifest {
3765            version: 1,
3766            segments: vec![],
3767        };
3768        segment::save_manifest(&dir, &empty).unwrap();
3769
3770        let err = match Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::default()) {
3771            Err(e) => e,
3772            Ok(_) => panic!("expected a zero-segment manifest to be rejected"),
3773        };
3774        assert!(
3775            matches!(err, LedgerError::Tamper { .. }),
3776            "expected Tamper (zero segments), got {err:?}"
3777        );
3778    }
3779
3780    // ===== segmented-ledger hardening: manifest integrity =====
3781
3782    #[test]
3783    fn segmented_manifest_missing_its_head_segment_is_rejected_including_on_an_already_open_handle()
3784    {
3785        // The contiguity check alone only verifies ADJACENT pairs — dropping
3786        // the earliest segment leaves every remaining pair still mutually
3787        // contiguous, so it needed its own explicit anchor-to-seq-0 check.
3788        // Exercise BOTH the reopen path AND the path this gap actually
3789        // mattered for: read_records on an ALREADY-OPEN handle, which
3790        // re-reads the manifest fresh on every call but never re-runs the
3791        // chain walk that would otherwise have caught this independently.
3792        let dir = tmp_dir("seg-head-drop");
3793        let key = decern_crypto::generate().unwrap();
3794        let mut l =
3795            Ledger::open_segmented(&dir, key.clone(), Vec::new(), RolloverPolicy::max_bytes(1))
3796                .unwrap();
3797        for i in 0..4 {
3798            l.append(entry(&format!("a{i}"), true)).unwrap();
3799        }
3800        let mut manifest = segment::load_manifest(&dir).unwrap().unwrap();
3801        assert!(
3802            manifest.segments.len() >= 3,
3803            "need a real head segment to drop"
3804        );
3805        manifest.segments.remove(0);
3806        segment::save_manifest(&dir, &manifest).unwrap();
3807
3808        let err = l
3809            .read_records(0, 100)
3810            .expect_err("head-dropped manifest must be rejected, not silently shifted");
3811        assert!(
3812            matches!(err, LedgerError::Tamper { .. }),
3813            "expected Tamper (missing chain head), got {err:?}"
3814        );
3815        drop(l);
3816
3817        let err = match Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::default()) {
3818            Err(e) => e,
3819            Ok(_) => panic!("expected a head-dropped manifest to be rejected on reopen"),
3820        };
3821        assert!(
3822            matches!(err, LedgerError::Tamper { .. }),
3823            "expected Tamper (missing chain head), got {err:?}"
3824        );
3825    }
3826
3827    #[cfg(unix)]
3828    #[test]
3829    fn opened_ms_rebase_rolls_back_in_memory_on_a_failed_persist_so_a_retry_still_persists_it() {
3830        use std::os::unix::fs::PermissionsExt;
3831        // If the rebase mutated self.rollover.manifest BEFORE the fallible
3832        // save_manifest call (with no rollback on failure), a retry with the
3833        // identical timestamp would find opened_ms already "correct" in
3834        // memory and silently skip persisting it to disk forever. Force the
3835        // first attempt's persist to fail, then confirm a retry with the
3836        // SAME entry still lands the correction on disk.
3837        let dir = tmp_dir("seg-rebase-rollback");
3838        let key = decern_crypto::generate().unwrap();
3839        let mut l =
3840            Ledger::open_segmented(&dir, key, Vec::new(), RolloverPolicy::epoch_ms(86_400_000))
3841                .unwrap();
3842
3843        let mut e0 = entry("a", true);
3844        e0.ts_ms = 1_780_000_000_000;
3845        let mut perms = std::fs::metadata(&dir).unwrap().permissions();
3846        perms.set_mode(0o500);
3847        std::fs::set_permissions(&dir, perms.clone()).unwrap();
3848        l.append(e0.clone())
3849            .expect_err("save_manifest should fail while the dir is read-only");
3850        perms.set_mode(0o700);
3851        std::fs::set_permissions(&dir, perms).unwrap();
3852
3853        l.append(e0).unwrap();
3854
3855        let manifest = segment::load_manifest(&dir).unwrap().unwrap();
3856        assert_eq!(
3857            manifest.segments[0].opened_ms, 1_780_000_000_000,
3858            "the retry must have persisted the rebased opened_ms to disk"
3859        );
3860    }
3861
3862    // ===================== torn-tail vs tamper (crash recovery) =====================
3863
3864    /// A ledger written before a field existed must still verify, byte for byte,
3865    /// after that field is added. Every optional column is `skip_serializing_if`
3866    /// for exactly this reason, and the chain hashes the bytes on disk — so the
3867    /// guarantee is only real if a record lacking the newest fields still checks
3868    /// out. Written as literal on-disk lines rather than by re-serializing, so
3869    /// this fails if a future field ever starts emitting a default.
3870    #[test]
3871    fn a_record_written_before_the_newest_columns_still_verifies() {
3872        let key = decern_crypto::generate().unwrap();
3873        let vk = key.verifying_key();
3874        let path = tmp("pre-columns-compat.ledger");
3875        std::fs::remove_file(&path).ok();
3876
3877        // Append through the current code, then confirm the bytes carry none of
3878        // the default-valued columns: their absence is what an older writer
3879        // produced, so today's output IS the old shape when nothing set them.
3880        let mut l = Ledger::open(&path, key.clone()).unwrap();
3881        l.append(entry("act0", true)).unwrap();
3882        drop(l);
3883
3884        let line = std::fs::read_to_string(&path).unwrap();
3885        assert!(
3886            !line.contains("decision_subject_source"),
3887            "a default source must not be written; old records would not have it: {line}"
3888        );
3889        assert!(
3890            !line.contains("sponsor_source"),
3891            "a default sponsor source must not be written either: {line}"
3892        );
3893
3894        // The chain covers the exact bytes above, so this is the real check: an
3895        // entry with none of the newer columns verifies as authentic.
3896        let report = verify(&path, Some(&vk)).unwrap();
3897        assert_eq!(report.entries, 1);
3898        assert!(report.signatures_checked);
3899
3900        // And it round-trips: reading it back yields the defaults rather than
3901        // failing to parse, which is what lets an old ledger be read at all.
3902        let (_r, records) = read_verified(&path, Some(&vk), 0, 1).unwrap();
3903        let rec = records.first().expect("one record");
3904        assert!(rec["entry"].get("decision_subject_source").is_none());
3905        std::fs::remove_file(&path).ok();
3906    }
3907
3908    /// Write `n` valid, chain+signature-valid records, then return the file's
3909    /// full lines so a test can rebuild a deliberately damaged tail.
3910    fn seed_lines(path: &Path, key: &SigningKey, n: usize) -> Vec<String> {
3911        std::fs::remove_file(path).ok();
3912        let mut l = Ledger::open(path, key.clone()).unwrap();
3913        for i in 0..n {
3914            l.append(entry(&format!("act{i}"), true)).unwrap();
3915        }
3916        drop(l);
3917        std::fs::read_to_string(path)
3918            .unwrap()
3919            .lines()
3920            .map(str::to_owned)
3921            .collect()
3922    }
3923
3924    #[test]
3925    fn crash_torn_tail_heals_as_torn_tail_not_tamper() {
3926        let key = decern_crypto::generate().unwrap();
3927        let vk = key.verifying_key();
3928        let path = tmp("torn-heals.ledger");
3929        let lines = seed_lines(&path, &key, 3);
3930
3931        // Simulate a crash mid-append of record 2: the first two records are
3932        // whole + newline-terminated; the third is a half-written fragment with
3933        // NO trailing newline.
3934        let torn = format!(
3935            "{}\n{}\n{}",
3936            lines[0],
3937            lines[1],
3938            &lines[2][..lines[2].len() / 2]
3939        );
3940        std::fs::write(&path, &torn).unwrap();
3941
3942        // The read-only verifier SURFACES this as TornTail, distinct from Tamper.
3943        let err = verify(&path, Some(&vk)).unwrap_err();
3944        match err {
3945            LedgerError::TornTail { healed_entries, .. } => assert_eq!(healed_entries, 2),
3946            other => panic!("expected TornTail, got {other:?}"),
3947        }
3948
3949        // Opening HEALS: the verified 2-record prefix is intact and appendable.
3950        let mut l = Ledger::open(&path, key.clone()).unwrap();
3951        l.append(entry("after-heal", true)).unwrap();
3952        drop(l);
3953
3954        // Reopen: clean, three records (2 healed + 1 new), no torn tail remains.
3955        let report = verify(&path, Some(&vk)).unwrap();
3956        assert_eq!(report.entries, 3);
3957    }
3958
3959    #[test]
3960    fn unterminated_but_complete_final_record_is_discarded_then_appends_cleanly() {
3961        // The strongest proof the rule is keyed on NEWLINE-TERMINATION, not on
3962        // parseability: the final record's bytes are entirely present and parse
3963        // fine — only its terminating '\n' never reached disk. It must still be
3964        // discarded, because keeping an unterminated line would fuse the next
3965        // append onto it as one physical line and corrupt the log.
3966        let key = decern_crypto::generate().unwrap();
3967        let vk = key.verifying_key();
3968        let path = tmp("torn-complete.ledger");
3969        let lines = seed_lines(&path, &key, 3);
3970
3971        // All three records complete; drop ONLY the trailing newline.
3972        std::fs::write(&path, format!("{}\n{}\n{}", lines[0], lines[1], lines[2])).unwrap();
3973
3974        // Surfaced as TornTail even though the final line parses cleanly.
3975        match verify(&path, Some(&vk)).unwrap_err() {
3976            LedgerError::TornTail { healed_entries, .. } => assert_eq!(healed_entries, 2),
3977            other => panic!("expected TornTail, got {other:?}"),
3978        }
3979
3980        let mut l = Ledger::open(&path, key.clone()).unwrap();
3981        l.append(entry("after-heal", true)).unwrap();
3982        drop(l);
3983
3984        // The append landed on its own line — not fused — and verifies.
3985        let text = std::fs::read_to_string(&path).unwrap();
3986        assert_eq!(
3987            text.lines().count(),
3988            3,
3989            "records must stay one-per-line: {text}"
3990        );
3991        assert!(
3992            text.ends_with('\n'),
3993            "healed log is newline-terminated again"
3994        );
3995        assert_eq!(verify(&path, Some(&vk)).unwrap().entries, 3);
3996    }
3997
3998    #[test]
3999    fn attacker_ragged_truncation_below_anchor_stays_tamper() {
4000        // The discriminating case the review demands: an attacker truncates
4001        // MID-record so the file ends WITHOUT a newline (entering the heal path),
4002        // but the healed prefix falls BELOW the committed anchor height. This is
4003        // deletion of acked history, not a crash tail → must stay Tamper, and the
4004        // file must NOT be mutated before that verdict.
4005        let key = decern_crypto::generate().unwrap();
4006        let path = tmp("torn-below-anchor.ledger");
4007        let anchor = tmp("torn-below-anchor.anchor");
4008        std::fs::remove_file(&path).ok();
4009        std::fs::remove_file(&anchor).ok();
4010
4011        let mut l = Ledger::open(&path, key.clone()).unwrap();
4012        for i in 0..3 {
4013            l.append(entry(&format!("act{i}"), true)).unwrap();
4014        }
4015        l.seal_anchor(&anchor, 1_000).unwrap(); // commits to 3 records
4016        drop(l);
4017
4018        let lines: Vec<String> = std::fs::read_to_string(&path)
4019            .unwrap()
4020            .lines()
4021            .map(str::to_owned)
4022            .collect();
4023        // Keep 1 whole record + a ragged (unterminated) fragment of record 1 →
4024        // healed prefix = 1 < anchored 3.
4025        let ragged = format!("{}\n{}", lines[0], &lines[1][..lines[1].len() / 2]);
4026        std::fs::write(&path, &ragged).unwrap();
4027        let len_before = std::fs::metadata(&path).unwrap().len();
4028
4029        let err = match Ledger::open_anchored(&path, key.clone(), Vec::new(), &anchor) {
4030            Ok(_) => panic!("ragged truncation below the anchor must fail to open"),
4031            Err(e) => e,
4032        };
4033        assert!(
4034            matches!(err, LedgerError::Tamper { .. }),
4035            "ragged truncation below the anchor must be Tamper, got {err:?}"
4036        );
4037        // The verdict was reached WITHOUT healing/truncating the file.
4038        assert_eq!(
4039            std::fs::metadata(&path).unwrap().len(),
4040            len_before,
4041            "the file must not be mutated when the torn tail is really a truncation attack"
4042        );
4043    }
4044
4045    #[test]
4046    fn terminated_final_record_with_broken_signature_stays_tamper() {
4047        // A fully newline-terminated final record that fails signature is NOT a
4048        // torn tail — a terminated line can't be a partial write. Stays Tamper.
4049        let key = decern_crypto::generate().unwrap();
4050        let vk = key.verifying_key();
4051        let path = tmp("terminated-bad-sig.ledger");
4052        let lines = seed_lines(&path, &key, 3);
4053
4054        // Corrupt one base64 char of the last record's signature, keep the '\n'.
4055        let mut last: serde_json::Value = serde_json::from_str(&lines[2]).unwrap();
4056        let sig = last["sig_b64"].as_str().unwrap().to_owned();
4057        let flipped = if sig.starts_with('A') { 'B' } else { 'A' };
4058        last["sig_b64"] = json!(format!("{flipped}{}", &sig[1..]));
4059        let corrupt = format!(
4060            "{}\n{}\n{}\n",
4061            lines[0],
4062            lines[1],
4063            serde_json::to_string(&last).unwrap()
4064        );
4065        std::fs::write(&path, &corrupt).unwrap();
4066
4067        match verify(&path, Some(&vk)).unwrap_err() {
4068            LedgerError::Tamper { .. } => {}
4069            other => panic!("terminated bad-signature record must be Tamper, got {other:?}"),
4070        }
4071    }
4072
4073    #[test]
4074    fn terminated_final_record_with_broken_chain_stays_tamper() {
4075        // Same guarantee for a broken hash-chain link on a terminated final line.
4076        let key = decern_crypto::generate().unwrap();
4077        let vk = key.verifying_key();
4078        let path = tmp("terminated-bad-chain.ledger");
4079        let lines = seed_lines(&path, &key, 3);
4080
4081        // Flip a byte inside the final record's stored entry: breaks its hash
4082        // (and thus the chain), while the line stays newline-terminated.
4083        let mut last: serde_json::Value = serde_json::from_str(&lines[2]).unwrap();
4084        last["hash"] = json!("00".repeat(32));
4085        let corrupt = format!(
4086            "{}\n{}\n{}\n",
4087            lines[0],
4088            lines[1],
4089            serde_json::to_string(&last).unwrap()
4090        );
4091        std::fs::write(&path, &corrupt).unwrap();
4092
4093        match verify(&path, Some(&vk)).unwrap_err() {
4094            LedgerError::Tamper { .. } => {}
4095            other => panic!("terminated broken-chain record must be Tamper, got {other:?}"),
4096        }
4097    }
4098
4099    #[test]
4100    fn healthy_log_ends_newline_terminated_and_reopens_clean() {
4101        // The single-write append terminates every record in the same write, so
4102        // a clean shutdown always leaves a newline-terminated file with no torn
4103        // tail — the round-trip the heal path must never disturb.
4104        let key = decern_crypto::generate().unwrap();
4105        let vk = key.verifying_key();
4106        let path = tmp("healthy-roundtrip.ledger");
4107        seed_lines(&path, &key, 4);
4108
4109        let bytes = std::fs::read(&path).unwrap();
4110        assert_eq!(*bytes.last().unwrap(), b'\n', "log is newline-terminated");
4111        assert_eq!(verify(&path, Some(&vk)).unwrap().entries, 4);
4112        // Reopen (no heal) and keep appending.
4113        let mut l = Ledger::open(&path, key.clone()).unwrap();
4114        l.append(entry("more", true)).unwrap();
4115        drop(l);
4116        assert_eq!(verify(&path, Some(&vk)).unwrap().entries, 5);
4117    }
4118
4119    #[test]
4120    fn anchored_crash_tail_above_the_anchor_heals_and_opens() {
4121        // The POSITIVE control mirroring `attacker_ragged_truncation_below_anchor`:
4122        // an honest crash left an unterminated tail whose verified prefix STILL
4123        // covers the committed height. `open_anchored` must walk the anchor
4124        // check, heal, AND pass the post-heal `verify_against_anchor` — i.e. an
4125        // anchored deployment recovers from a crash instead of bricking.
4126        let key = decern_crypto::generate().unwrap();
4127        let vk = key.verifying_key();
4128        let path = tmp("anchored-crash-tail.ledger");
4129        let anchor = tmp("anchored-crash-tail.anchor");
4130        std::fs::remove_file(&path).ok();
4131        std::fs::remove_file(&anchor).ok();
4132
4133        let mut l = Ledger::open(&path, key.clone()).unwrap();
4134        for i in 0..3 {
4135            l.append(entry(&format!("act{i}"), true)).unwrap();
4136        }
4137        l.seal_anchor(&anchor, 1_000).unwrap(); // commits to 3 records
4138        l.append(entry("act3", true)).unwrap(); // a 4th, un-acked record
4139        drop(l);
4140
4141        let lines: Vec<String> = std::fs::read_to_string(&path)
4142            .unwrap()
4143            .lines()
4144            .map(str::to_owned)
4145            .collect();
4146        // Crash mid-append of record 3: records 0..=2 whole + terminated (== the
4147        // anchored height), record 3 a ragged unterminated fragment.
4148        let torn = format!(
4149            "{}\n{}\n{}\n{}",
4150            lines[0],
4151            lines[1],
4152            lines[2],
4153            &lines[3][..lines[3].len() / 2]
4154        );
4155        std::fs::write(&path, &torn).unwrap();
4156
4157        // Heals to the 3 acked records AND satisfies the anchor.
4158        let mut l = Ledger::open_anchored(&path, key.clone(), Vec::new(), &anchor).unwrap();
4159        l.append(entry("act3-again", true)).unwrap();
4160        drop(l);
4161        assert_eq!(verify(&path, Some(&vk)).unwrap().entries, 4);
4162    }
4163
4164    #[test]
4165    fn segmented_torn_tail_in_active_segment_heals() {
4166        // The heal path routes through `open_segmented_inner` too, and
4167        // `prefix_lines` keys off the LAST (active) segment. A torn tail in the
4168        // active segment must heal, leaving the earlier segment's records intact
4169        // and the chain verifying across the boundary.
4170        let key = decern_crypto::generate().unwrap();
4171        let vk = key.verifying_key();
4172        let dir = tmp("segmented-torn");
4173        std::fs::remove_dir_all(&dir).ok();
4174
4175        // A tiny size policy forces a rollover, so records land in >1 segment.
4176        let policy = RolloverPolicy {
4177            max_bytes: Some(1),
4178            epoch_ms: None,
4179        };
4180        let mut l = Ledger::open_segmented(&dir, key.clone(), Vec::new(), policy).unwrap();
4181        for i in 0..4 {
4182            l.append(entry(&format!("s{i}"), true)).unwrap();
4183        }
4184        drop(l);
4185
4186        // Locate the active (last) segment file and ragged-truncate its final line.
4187        let paths = segment::segment_paths(&dir).unwrap();
4188        let active = paths.last().unwrap().clone();
4189        let alines: Vec<String> = std::fs::read_to_string(&active)
4190            .unwrap()
4191            .lines()
4192            .map(str::to_owned)
4193            .collect();
4194        assert!(!alines.is_empty(), "active segment should hold >=1 record");
4195        let kept = &alines[..alines.len() - 1];
4196        let mut body = kept.iter().map(|s| format!("{s}\n")).collect::<String>();
4197        // Append a ragged (unterminated) fragment of the last record.
4198        let torn = alines.last().unwrap();
4199        body.push_str(&torn[..torn.len() / 2]);
4200        std::fs::write(&active, &body).unwrap();
4201
4202        // Read-only verify surfaces TornTail; open heals and stays appendable.
4203        assert!(matches!(
4204            verify(&dir, Some(&vk)).unwrap_err(),
4205            LedgerError::TornTail { .. }
4206        ));
4207        let healed_before = match verify(&dir, Some(&vk)).unwrap_err() {
4208            LedgerError::TornTail { healed_entries, .. } => healed_entries,
4209            _ => unreachable!(),
4210        };
4211        let mut l = Ledger::open_segmented(&dir, key.clone(), Vec::new(), policy).unwrap();
4212        l.append(entry("post-heal", true)).unwrap();
4213        drop(l);
4214        assert_eq!(
4215            verify(&dir, Some(&vk)).unwrap().entries,
4216            healed_before + 1,
4217            "chain verifies across the segment boundary after healing the active tail"
4218        );
4219    }
4220    /// The ledger holds decision subjects and the pseudonymous handles the subject-side
4221    /// audit route is keyed by. It was created at the process umask — commonly 0644 —
4222    /// while the signing key and the mission registry beside it are 0600, which made the
4223    /// audit log the readable one of the three.
4224    #[cfg(unix)]
4225    #[test]
4226    fn a_ledger_file_is_not_readable_by_group_or_other() {
4227        use std::os::unix::fs::PermissionsExt;
4228        let path = tmp("perm-new.ledger");
4229        std::fs::remove_file(&path).ok();
4230        let key = decern_crypto::generate().unwrap();
4231        let mut led = Ledger::open(&path, key).unwrap();
4232        led.append(entry("Read", true)).unwrap();
4233        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
4234        assert_eq!(mode, 0o600, "ledger must be 0600, got {mode:o}");
4235    }
4236
4237    /// A ledger written before this change is tightened when it is next opened, rather
4238    /// than left readable forever. `.mode()` alone would not do it: it applies only on
4239    /// creation.
4240    #[cfg(unix)]
4241    #[test]
4242    fn reopening_an_existing_world_readable_ledger_tightens_it() {
4243        use std::os::unix::fs::PermissionsExt;
4244        let path = tmp("perm-tighten.ledger");
4245        std::fs::remove_file(&path).ok();
4246        let key = decern_crypto::generate().unwrap();
4247        {
4248            let mut led = Ledger::open(&path, key.clone()).unwrap();
4249            led.append(entry("Read", true)).unwrap();
4250        }
4251        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
4252        let _led = Ledger::open(&path, key).unwrap();
4253        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
4254        assert_eq!(mode, 0o600, "reopen must tighten, got {mode:o}");
4255    }
4256}