Skip to main content

mkit_core/
history.rs

1//! Append-only commit-history Merkle Mountain Range (MMR).
2//!
3//! Issue #157. Light-client inclusion proofs for the commit chain: a
4//! verifier with the MMR root for a branch tip can check "commit X was
5//! leaf N on this branch" with `O(log n)` hash work, without
6//! downloading the parent chain or any pack.
7//!
8//! # Status
9//!
10//! - **In-memory** (shipped, [`CommitHistory::open`]): `mem`-backed
11//!   MMR. Lost on process exit. Useful for tests and
12//!   short-lived computations where the proof is the only output.
13//! - **On-disk journaled** (this build, [`CommitHistory::open_at`]):
14//!   journaled MMR backed by
15//!   [`commonware_storage::merkle::mmr::full::Mmr`] pinned to
16//!   `=2026.5.0`. The on-disk layout is commonware's native two-store
17//!   shape — a fixed-item journal of node digests plus a metadata
18//!   sidecar for pruned pinned nodes — laid out under
19//!   `<mkit_dir>/history/<sanitized_branch>/`. See
20//!   `docs/specs/SPEC-HISTORY-PROOF.md` §4.
21//! - **Commit-field integration** (planned, v0.2): `Commit.history_root`
22//!   proto field, new signing-bytes layout. Out of scope here.
23//!
24//! # Hashing
25//!
26//! The underlying primitive is `commonware-storage`'s journaled MMR
27//! parameterised over BLAKE3 (from `commonware-cryptography`). Node
28//! digests are 32-byte BLAKE3 values — same primitive mkit already
29//! uses elsewhere ([`crate::hash::Hash`]). The schedule is **not** the
30//! same as [`crate::hash::hash`]: commonware injects each node's
31//! position into the parent/leaf digest. Treat the digests as opaque
32//! beyond comparison.
33//!
34//! # Sync / async bridge
35//!
36//! commonware's journaled MMR is async over a `commonware-runtime`
37//! `Context`. The mkit-core public surface ([`CommitHistory::append`],
38//! [`CommitHistory::root`], [`CommitHistory::prove`]) is synchronous
39//! by design — it is called from the synchronous `refs::update_ref`
40//! path and from CLI helpers that have no async-runtime context.
41//!
42//! The bridge is the [`crate::protocol::async_shim::Executor`] trait
43//! hoisted in PR #167. [`CommitHistory`] is generic over an executor
44//! `X: Executor`; every sync method drives its async counterpart via
45//! `executor.block_on(...)`. The executor is held by [`Arc`] for the
46//! lifetime of the [`CommitHistory`] value so multiple `append` /
47//! `prove` calls share a single runtime.
48//!
49//! # Executor / Context ownership
50//!
51//! [`CommitHistory::open_at`] takes an [`Arc`]-shared executor from
52//! the caller. The commonware `Context` needed to drive the
53//! journaled MMR is bootstrapped *internally* via a one-shot
54//! [`commonware_runtime::tokio::Runner::start`] on a fresh OS thread
55//! (the standard workaround documented in transport-enc):
56//! the runner returns a Context clone, the outer `Arc<Executor>` inside
57//! the Context keeps tokio's runtime alive, and the bootstrap thread
58//! joins immediately. Subsequent async ops are driven through the
59//! caller-supplied executor.
60//!
61//! This means production callers only need to construct an executor
62//! ([`crate::history::tokio_executor::TokioExecutor`] is provided when
63//! the `history-mmr` feature is on); mkit-core handles the
64//! commonware-side wiring. The trade-off: every [`CommitHistory`]
65//! owns one tokio runtime (via its executor) AND one commonware
66//! Context with its own inner tokio runtime. The two never need to
67//! interact — `tokio::fs` and `tokio::sync::Mutex` are runtime-agnostic
68//! and work whichever runtime is driving the poll.
69
70use std::path::{Path, PathBuf};
71use std::sync::Arc;
72
73use commonware_cryptography::{Blake3, Hasher as CHasher};
74use commonware_parallel::Sequential;
75use commonware_runtime::{Runner as _, Supervisor as _, buffer::paged::CacheRef};
76use commonware_storage::merkle::Bagging;
77use commonware_storage::merkle::mmr::{
78    Location as MmrLocation, Proof as MmrProof, StandardHasher,
79    full::{Config as JConfig, Mmr as JournaledMmr},
80    mem::Mmr as MemMmr,
81};
82use commonware_utils::{NZU16, NZU64, NZUsize};
83
84use crate::hash::{HASH_LEN, Hash};
85use crate::protocol::async_shim::Executor;
86use crate::refs::validate_ref_name;
87
88pub mod tokio_executor;
89
90/// Re-export of the bundled tokio-runtime executor.
91pub use tokio_executor::TokioExecutor;
92
93/// Peak-bagging policy for the commit-history MMR.
94///
95/// This is a **load-bearing cryptographic parameter**: the producer
96/// ([`CommitHistory::root`] / [`CommitHistory::prove`]) and the verifier
97/// ([`verify_inclusion`]) must fold MMR peaks into a root identically, or
98/// every inclusion proof silently fails to verify. Pinning it here — and
99/// consuming it only via [`history_hasher`] — makes that agreement
100/// un-desyncable across all call sites and both code paths.
101///
102/// `ForwardFold` is also a **back-compat** choice: it reproduces the root
103/// that the pre-2026.5 commonware default produced byte-for-byte, so MMR
104/// journals written by an older mkit build and roots already stored in the
105/// reflog stay valid across the commonware bump. Do **not** change this
106/// without an on-disk format-version bump and a migration.
107const HISTORY_BAGGING: Bagging = Bagging::ForwardFold;
108
109// Test-only instrumentation: counts calls to the journaled flavour's
110// `journal.sync()` (fsync), thread-local so parallel `cargo test`
111// threads never interfere with each other's count. Exists purely to
112// let tests assert "backfilling N commits does one fsync, not N"
113// without depending on wall-clock timing. No production overhead —
114// compiled out entirely on non-test builds.
115#[cfg(test)]
116thread_local! {
117    static SYNC_CALL_COUNT: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
118}
119
120#[cfg(test)]
121fn record_sync_call() {
122    SYNC_CALL_COUNT.with(|c| c.set(c.get() + 1));
123}
124
125/// Reset this thread's fsync counter to 0. Call before the operation
126/// under test.
127#[cfg(test)]
128pub(crate) fn reset_sync_call_count() {
129    SYNC_CALL_COUNT.with(|c| c.set(0));
130}
131
132/// Number of `journal.sync()` (fsync) calls on this thread since the
133/// last [`reset_sync_call_count`].
134#[cfg(test)]
135pub(crate) fn sync_call_count() -> u64 {
136    SYNC_CALL_COUNT.with(std::cell::Cell::get)
137}
138
139/// The canonical hasher for the commit-history MMR — the single source of
140/// truth for [`HISTORY_BAGGING`], so the producer and verifier sides can
141/// never drift on the bagging policy.
142fn history_hasher() -> StandardHasher<Blake3> {
143    StandardHasher::new(HISTORY_BAGGING)
144}
145
146// ---------------------------------------------------------------------
147// Public types
148// ---------------------------------------------------------------------
149
150/// 0-based index of a commit within its branch's MMR.
151///
152/// In commonware's vocabulary this is a `Location` — the leaf index in
153/// insertion order, not the MMR's internal node position. The first
154/// commit appended is `Position(0)`, the second is `Position(1)`, etc.
155/// Stable for the lifetime of the branch: positions never shift
156/// because the MMR is append-only.
157#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
158pub struct Position(pub u64);
159
160impl Position {
161    /// Raw `u64`.
162    #[must_use]
163    pub const fn as_u64(self) -> u64 {
164        self.0
165    }
166}
167
168/// Inclusion proof — re-export of commonware's MMR proof type bound to
169/// our BLAKE3 digest. Wire shape is normatively defined in
170/// `SPEC-HISTORY-PROOF.md` §2.
171pub type InclusionProof = MmrProof<<Blake3 as CHasher>::Digest>;
172
173/// Errors returned by [`CommitHistory`] and [`verify_inclusion`].
174#[derive(Debug, thiserror::Error)]
175pub enum HistoryError {
176    /// The underlying MMR rejected the operation (out-of-bounds proof,
177    /// invalid size, etc.). The wrapped string is commonware's own
178    /// `Display` impl — stable enough for logs, not parsed.
179    #[error("mmr error: {0}")]
180    Mmr(String),
181    /// The branch name failed [`crate::refs::validate_ref_name`].
182    #[error("invalid branch name for history journal: {0:?}")]
183    InvalidBranch(String),
184    /// On-disk journal could not be opened or recovered. commonware's
185    /// own `init` performs roll-forward recovery on a half-written
186    /// trailing leaf (see `commonware_storage::merkle::mmr::full`
187    /// docs); this variant surfaces failures it cannot recover from.
188    #[error("history journal is corrupt: {0}")]
189    Corrupted(String),
190    /// Failed to bootstrap the commonware tokio Context.
191    #[error("failed to bootstrap commonware runtime: {0}")]
192    RuntimeBootstrap(String),
193    /// Failed to set up the on-disk history directory.
194    #[error("history directory I/O: {0}")]
195    Io(#[from] std::io::Error),
196}
197
198// ---------------------------------------------------------------------
199// On-disk layout
200// ---------------------------------------------------------------------
201
202/// Subdirectory of `<mkit_dir>` that holds per-branch MMR journals.
203///
204/// commonware lays its journaled-MMR partitions out as a pair of
205/// suffix-discriminated directories below this root:
206///
207/// ```text
208/// <mkit_dir>/history/<sanitized_branch>__journal-blobs/     # node-digest journal blobs
209/// <mkit_dir>/history/<sanitized_branch>__journal-metadata/  # journal segment table
210/// <mkit_dir>/history/<sanitized_branch>__metadata/          # pruned-pinned-node sidecar
211/// ```
212///
213/// The `-blobs` / `-metadata` segment suffixes are appended by
214/// commonware-storage; mkit names the leading partition tokens
215/// (`<sanitized_branch>__journal` and `<sanitized_branch>__metadata`)
216/// and hands them to `journaled::Mmr::init` via [`JConfig`].
217///
218/// commonware partition names are restricted to `[A-Za-z0-9_-]+`, so
219/// branch names go through `sanitize_branch` — a `_xx`-hex encoding
220/// of every byte outside `[A-Za-z0-9-]`. Empty branches and invalid
221/// ref names are rejected up front by [`CommitHistory::open_at`].
222pub const HISTORY_DIR: &str = crate::layout::HISTORY_DIR_NAME;
223
224/// Suffix appended to the branch's partition name for the node-digest
225/// journal.
226pub const JOURNAL_PARTITION_SUFFIX: &str = "__journal";
227
228/// Suffix appended to the branch's partition name for the
229/// pruned-pinned-node metadata sidecar.
230pub const METADATA_PARTITION_SUFFIX: &str = "__metadata";
231
232// ---------------------------------------------------------------------
233// CommitHistory
234// ---------------------------------------------------------------------
235
236/// Append-only Merkle history of commit hashes for one branch.
237///
238/// Two construction modes:
239///
240/// 1. [`CommitHistory::open`] — purely in-memory. Useful for tests
241///    and for callers that don't want any disk side-effects. Lost on
242///    drop.
243/// 2. [`CommitHistory::open_at`] — on-disk journaled MMR under
244///    `<mkit_dir>/history/<branch>/`. Survives process exit.
245///
246/// Each call to a sync method (`append`, `root`, `prove`) on the
247/// journaled flavour drives an async operation on the underlying
248/// `commonware-storage::journaled::Mmr` via the caller-supplied
249/// [`Executor`].
250pub struct CommitHistory<X: Executor = TokioExecutor> {
251    backend: Backend<X>,
252    hasher: StandardHasher<Blake3>,
253}
254
255/// Internal backend selector. The mem variant is the in-memory shape;
256/// the journaled variant is the on-disk one.
257///
258/// `Journaled` is boxed because its inner state (commonware's
259/// `Journaled` plus a `Context` clone) is ~2.2 KiB and would otherwise
260/// pad every mem-flavour `CommitHistory` by the same amount.
261enum Backend<X: Executor> {
262    Mem {
263        mmr: MemMmr<<Blake3 as CHasher>::Digest>,
264    },
265    Journaled(Box<JournaledBackend<X>>),
266}
267
268struct JournaledBackend<X: Executor> {
269    // Order matters for Drop: `mmr` must drop before `ctx` so
270    // any pending async-resource shutdowns can still poll on the
271    // surviving tokio runtime. In practice commonware's
272    // `Journaled` only flushes synchronously in `sync`, but the
273    // ordering is cheap insurance.
274    mmr: JournaledMmr<commonware_runtime::tokio::Context, <Blake3 as CHasher>::Digest, Sequential>,
275    executor: Arc<X>,
276    // Held to keep the bootstrap tokio runtime (inside the Context's
277    // executor `Arc`) alive for the whole CommitHistory lifetime, AND
278    // read by `reopen` (issue #640) to derive a labelled child Context
279    // instead of paying for a second `bootstrap_commonware_context`.
280    ctx: commonware_runtime::tokio::Context,
281    // Held so update_ref_with_history can take a fresh RepoLock for
282    // every append. None for the mem-only flavour. This is the COMMON
283    // dir: history is shared state across worktrees (#493).
284    common_dir: PathBuf,
285    branch: String,
286}
287
288impl<X: Executor> core::fmt::Debug for CommitHistory<X> {
289    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
290        match &self.backend {
291            Backend::Mem { mmr } => f
292                .debug_struct("CommitHistory::Mem")
293                .field("leaves", &u64::from(mmr.leaves()))
294                .field("size", &u64::from(mmr.size()))
295                .finish_non_exhaustive(),
296            Backend::Journaled(b) => f
297                .debug_struct("CommitHistory::Journaled")
298                .field("branch", &b.branch)
299                .field("leaves", &u64::from(b.mmr.leaves()))
300                .field("size", &u64::from(b.mmr.size()))
301                .finish_non_exhaustive(),
302        }
303    }
304}
305
306impl CommitHistory<TokioExecutor> {
307    /// Open a fresh empty in-memory history (mem-backed shape).
308    ///
309    /// Lost on drop. Useful for unit tests and for callers that just
310    /// need a proof bundle without committing any state to disk.
311    #[must_use]
312    pub fn open() -> Self {
313        let hasher = history_hasher();
314        let mmr = MemMmr::new();
315        Self {
316            backend: Backend::Mem { mmr },
317            hasher,
318        }
319    }
320}
321
322impl<X: Executor + 'static> CommitHistory<X> {
323    /// Open a persisted history under `<common dir>/history/<branch>/`.
324    ///
325    /// The on-disk layout is commonware-storage's native journaled MMR
326    /// shape — see [`HISTORY_DIR`] and SPEC-HISTORY-PROOF §4. If the
327    /// underlying journal does not yet exist, it is created empty
328    /// (subsequent [`CommitHistory::append`] calls populate it).
329    ///
330    /// Crash recovery is delegated to commonware: its `Journaled::init`
331    /// detects a half-written trailing leaf, rewinds the journal to
332    /// the last valid size, and re-derives the in-memory state. See
333    /// SPEC-HISTORY-PROOF §4.4 for the contract surfaced to mkit
334    /// callers.
335    ///
336    /// # Errors
337    ///
338    /// - [`HistoryError::InvalidBranch`] — `branch` failed
339    ///   [`validate_ref_name`].
340    /// - [`HistoryError::RuntimeBootstrap`] — could not start the
341    ///   commonware tokio Runner used to construct the underlying
342    ///   storage Context.
343    /// - [`HistoryError::Corrupted`] — commonware refused to recover
344    ///   the journal (a deeper-than-trailing-leaf corruption).
345    /// - [`HistoryError::Io`] — failed to create the history dir.
346    pub fn open_at(
347        executor: Arc<X>,
348        layout: &crate::layout::RepoLayout,
349        branch: &str,
350    ) -> Result<Self, HistoryError> {
351        Self::open_at_common_dir(executor, layout.common_dir(), branch)
352    }
353
354    /// [`Self::open_at`] on a bare common dir — the internal seam
355    /// [`Self::reopen`] uses to reconstruct itself from the stored
356    /// `common_dir` without re-materializing a full layout.
357    fn open_at_common_dir(
358        executor: Arc<X>,
359        common_dir: &Path,
360        branch: &str,
361    ) -> Result<Self, HistoryError> {
362        if !validate_ref_name(branch) {
363            return Err(HistoryError::InvalidBranch(branch.to_string()));
364        }
365
366        let history_dir = common_dir.join(HISTORY_DIR);
367        std::fs::create_dir_all(&history_dir)?;
368
369        // Bootstrap a commonware tokio Context rooted at
370        // `<mkit_dir>/history`. The Context survives the bootstrap
371        // runner's drop because its inner `Arc<Executor>` (the
372        // commonware Executor, not ours) holds the tokio runtime alive.
373        //
374        // This is the ONLY place a fresh Context gets bootstrapped.
375        // [`Self::reopen`] re-derives state via [`Self::init_journaled`]
376        // against its already-live Context instead of calling this
377        // again — see issue #640.
378        let ctx = bootstrap_commonware_context(&history_dir)?;
379
380        Self::init_journaled(executor, ctx, common_dir, branch)
381    }
382
383    /// Open (or re-derive) the journaled backend against an
384    /// already-live commonware `Context`.
385    ///
386    /// This is the shared tail of [`Self::open_at_common_dir`] (which
387    /// bootstraps a fresh `Context` and hands it here) and
388    /// [`Self::reopen`] (which passes in the CURRENT handle's already
389    /// -bootstrapped `Context` instead of building a new one). Every
390    /// call re-runs `JournaledMmr::init`, so the returned handle always
391    /// reflects what's actually on disk right now — the expensive part
392    /// this function does NOT repeat is the OS-thread `Context`
393    /// bootstrap itself.
394    fn init_journaled(
395        executor: Arc<X>,
396        ctx: commonware_runtime::tokio::Context,
397        common_dir: &Path,
398        branch: &str,
399    ) -> Result<Self, HistoryError> {
400        let sanitized = sanitize_branch(branch);
401        let journal_partition = format!("{sanitized}{JOURNAL_PARTITION_SUFFIX}");
402        let metadata_partition = format!("{sanitized}{METADATA_PARTITION_SUFFIX}");
403        let cfg = JConfig {
404            journal_partition,
405            metadata_partition,
406            // 4096 items/blob → 128 KiB blobs (32 B digest × 4096). Big
407            // enough that small branches stay in one blob; small enough
408            // that pruning later doesn't carry stale data around.
409            items_per_blob: NZU64!(4096),
410            write_buffer: NZUsize!(4096),
411            strategy: Sequential,
412            page_cache: CacheRef::from_pooler(&ctx, NZU16!(4096), NZUsize!(8)),
413        };
414
415        let hasher = history_hasher();
416        let mmr = {
417            let hasher_inner = history_hasher();
418            // `child` returns an owned child Context, leaving `ctx` usable for
419            // the surviving CommitHistory (which keeps the bootstrap runtime
420            // alive via its inner executor Arc).
421            let ctx_for_init = ctx.child("mmr_init");
422            executor
423                .block_on(async move {
424                    JournaledMmr::<_, <Blake3 as CHasher>::Digest, Sequential>::init(
425                        ctx_for_init,
426                        &hasher_inner,
427                        cfg,
428                    )
429                    .await
430                })
431                .map_err(|e| HistoryError::Corrupted(e.to_string()))?
432        };
433
434        Ok(Self {
435            backend: Backend::Journaled(Box::new(JournaledBackend {
436                mmr,
437                executor,
438                ctx,
439                common_dir: common_dir.to_path_buf(),
440                branch: branch.to_string(),
441            })),
442            hasher,
443        })
444    }
445
446    /// Borrow the common dir this history was opened against. `None`
447    /// for the mem-only flavour. Used by
448    /// [`crate::refs::update_ref_with_history`] to take a `RepoLock`
449    /// around the ref-write + MMR-append critical section.
450    #[must_use]
451    pub fn common_dir(&self) -> Option<&Path> {
452        match &self.backend {
453            Backend::Mem { .. } => None,
454            Backend::Journaled(b) => Some(b.common_dir.as_path()),
455        }
456    }
457
458    /// Borrow the branch name this history was opened against. `None`
459    /// for the mem-only flavour.
460    #[must_use]
461    pub fn branch(&self) -> Option<&str> {
462        match &self.backend {
463            Backend::Mem { .. } => None,
464            Backend::Journaled(b) => Some(b.branch.as_str()),
465        }
466    }
467
468    /// Re-derive this handle's in-memory state from the current
469    /// on-disk journal. A no-op for the mem-only flavour.
470    ///
471    /// [`CommitHistory::open_at`] may run before the caller has taken
472    /// any cross-process lock (opening the journal is cheap and the
473    /// lock is only needed around the ref-write + append critical
474    /// section — see [`crate::refs::update_ref_with_history`]). If
475    /// another process appended to the same on-disk journal in the
476    /// window between that `open_at` and this handle's own locked
477    /// critical section, this handle's leaf count / root would be
478    /// stale, and appending against stale state risks writing a leaf
479    /// at a position the on-disk journal has already used. Callers
480    /// that hold a cross-process lock around their critical section
481    /// MUST call this once after acquiring it and before appending, so
482    /// the append is always against what is truly on disk.
483    ///
484    /// # Errors
485    ///
486    /// Same as [`CommitHistory::open_at`]: [`HistoryError::Corrupted`]
487    /// if commonware cannot recover the journal,
488    /// [`HistoryError::RuntimeBootstrap`] if the runtime bootstrap
489    /// fails, [`HistoryError::Io`] for filesystem failures.
490    ///
491    /// # Bootstrap cost (issue #640)
492    ///
493    /// This does NOT spawn a second commonware `Context` bootstrap.
494    /// [`CommitHistory::open_at`]'s bootstrap thread already built a
495    /// full tokio runtime, metrics task, and buffer pools for this
496    /// handle; re-running that per call would double the cost of
497    /// every history-tracked ref write for no benefit, since the
498    /// bootstrapped Context is reusable — only the MMR's in-memory
499    /// view of the on-disk journal needs re-deriving. `reopen` takes a
500    /// labelled child of the handle's own already-live Context and
501    /// re-runs `JournaledMmr::init` against it, which is what actually
502    /// picks up a concurrent writer's append.
503    pub fn reopen(&mut self) -> Result<(), HistoryError> {
504        let Backend::Journaled(b) = &self.backend else {
505            return Ok(());
506        };
507        // Reuse the already-bootstrapped Context — see the doc comment
508        // above and `bootstrap_commonware_context`'s doc comment for
509        // why a second bootstrap must not happen here.
510        let ctx = b.ctx.child("mmr_reopen");
511        let fresh = Self::init_journaled(b.executor.clone(), ctx, &b.common_dir, &b.branch)?;
512        *self = fresh;
513        Ok(())
514    }
515
516    /// Append a commit hash. Returns its leaf [`Position`].
517    ///
518    /// Positions are dense: the *n*-th append returns `Position(n)`.
519    /// For the journaled flavour, the underlying MMR is `sync`'d to
520    /// disk before returning — survives a `SIGKILL` immediately after.
521    ///
522    /// A thin wrapper over `Self::append_no_sync` + `Self::sync` —
523    /// see those for the split. Callers appending many leaves in one
524    /// critical section (e.g. [`rebuild_from_chain`]'s backfill) should
525    /// call the two halves directly instead, batching the fsync.
526    pub fn append(&mut self, commit_hash: &Hash) -> Result<Position, HistoryError> {
527        let pos = self.append_no_sync(commit_hash)?;
528        self.sync()?;
529        Ok(pos)
530    }
531
532    /// Append a commit hash WITHOUT flushing to disk.
533    ///
534    /// Identical to [`Self::append`] except the journaled flavour skips
535    /// the trailing `journal.sync()` (fsync). Unlike `append`, a leaf
536    /// written this way is only guaranteed durable once a subsequent
537    /// [`Self::sync`] call returns — a `SIGKILL` before that sync loses
538    /// it. Exists so a caller appending many leaves in one critical
539    /// section (e.g. [`rebuild_from_chain`]'s backfill) can defer the
540    /// fsync to once for the whole batch instead of once per leaf: at
541    /// roughly 1-10ms per fsync, one-per-commit made backfilling a
542    /// branch with tens of thousands of commits take minutes instead of
543    /// the single-digit milliseconds SPEC-HISTORY-PROOF promises.
544    ///
545    /// # Errors
546    ///
547    /// Same as [`Self::append`].
548    fn append_no_sync(&mut self, commit_hash: &Hash) -> Result<Position, HistoryError> {
549        let leaf = digest_from_hash(commit_hash);
550        match &mut self.backend {
551            Backend::Mem { mmr } => {
552                let leaf_loc = mmr.leaves();
553                let batch = mmr
554                    .new_batch()
555                    .add(&self.hasher, &leaf)
556                    .merkleize(mmr, &self.hasher);
557                mmr.apply_batch(&batch)
558                    .map_err(|e| HistoryError::Mmr(e.to_string()))?;
559                Ok(Position(u64::from(leaf_loc)))
560            }
561            Backend::Journaled(b) => {
562                let leaf_loc = b.mmr.leaves();
563                let batch = b.mmr.new_batch().add(&self.hasher, &leaf);
564                let batch = b.mmr.with_mem(|mem| batch.merkleize(mem, &self.hasher));
565                b.mmr
566                    .apply_batch(&batch)
567                    .map_err(|e| HistoryError::Mmr(e.to_string()))?;
568                Ok(Position(u64::from(leaf_loc)))
569            }
570        }
571    }
572
573    /// Flush any leaves appended via [`Self::append_no_sync`] to disk.
574    /// No-op for the mem-only flavour (nothing to flush).
575    ///
576    /// # Errors
577    ///
578    /// [`HistoryError::Mmr`] if the underlying journal sync fails.
579    fn sync(&mut self) -> Result<(), HistoryError> {
580        if let Backend::Journaled(b) = &mut self.backend {
581            #[cfg(test)]
582            record_sync_call();
583            // Flush to disk synchronously so a SIGKILL between this
584            // call and the caller's next ref-write does not lose any
585            // leaf appended (via `append_no_sync`) since the last sync.
586            let sync_fut = b.mmr.sync();
587            b.executor
588                .block_on(sync_fut)
589                .map_err(|e| HistoryError::Mmr(e.to_string()))?;
590        }
591        Ok(())
592    }
593
594    /// Current MMR root digest. 32-byte BLAKE3 (mkit `Hash` shape).
595    ///
596    /// Defined for an empty history — commonware returns a
597    /// deterministic empty-MMR root (see SPEC-HISTORY-PROOF §2.3).
598    ///
599    /// # Panics
600    ///
601    /// Never panics in practice. Internally calls commonware's
602    /// `root(&hasher, 0)`, which only returns an error for a non-zero
603    /// inactive-peak count; mkit always requests `0` inactive peaks
604    /// (its proofs are self-contained over the full leaf set), so the
605    /// `Result` is always `Ok`.
606    #[must_use]
607    pub fn root(&self) -> Hash {
608        // `root` takes the hasher + an `inactive_peaks` count (0 for a
609        // self-contained MMR over the full leaf set) and returns an owned
610        // `Digest`. Both backends are sync here (Journaled reads its
611        // in-memory cache); 0 inactive peaks is always a valid request,
612        // so the `Result` is always `Ok` — see the `# Panics` note above.
613        let digest = match &self.backend {
614            Backend::Mem { mmr } => mmr.root(&self.hasher, 0),
615            Backend::Journaled(b) => b.mmr.root(&self.hasher, 0),
616        }
617        .expect("0 inactive peaks is always a valid root request");
618        let mut out = [0u8; HASH_LEN];
619        out.copy_from_slice(digest.as_ref());
620        out
621    }
622
623    /// Number of leaves (commits) appended so far.
624    #[must_use]
625    pub fn len(&self) -> u64 {
626        match &self.backend {
627            Backend::Mem { mmr } => u64::from(mmr.leaves()),
628            Backend::Journaled(b) => u64::from(b.mmr.leaves()),
629        }
630    }
631
632    /// `true` if no commits have been appended.
633    #[must_use]
634    pub fn is_empty(&self) -> bool {
635        self.len() == 0
636    }
637
638    /// Build an inclusion proof for the commit at `position`.
639    pub fn prove(&self, position: Position) -> Result<InclusionProof, HistoryError> {
640        let loc = MmrLocation::new(position.0);
641        match &self.backend {
642            Backend::Mem { mmr } => mmr
643                .proof(&self.hasher, loc, 0)
644                .map_err(|e| HistoryError::Mmr(e.to_string())),
645            Backend::Journaled(b) => {
646                let hasher = self.hasher.clone();
647                let proof_fut = b.mmr.proof(&hasher, loc, 0);
648                // Drive the async proof builder via the executor. The
649                // future borrows `mmr` immutably for the duration of
650                // `block_on`; the borrow ends when `block_on` returns.
651                b.executor
652                    .block_on(proof_fut)
653                    .map_err(|e| HistoryError::Mmr(e.to_string()))
654            }
655        }
656    }
657
658    /// Permanently delete this history's on-disk journal + metadata
659    /// partitions (issue #648).
660    ///
661    /// Consumes `self`: there is no valid handle to keep using once the
662    /// backing storage is gone. A no-op for the mem-only flavour (there
663    /// is nothing on disk to remove).
664    ///
665    /// This is the primitive [`crate::refs::delete_ref_with_history`]
666    /// uses to fence a branch's journal on `branch -d` / `branch -m`:
667    /// without it, re-creating a branch with a previously-used name
668    /// would reopen the dead incarnation's non-empty journal (same
669    /// sanitized partition name) and resume appending on top of its old
670    /// leaves — see SPEC-HISTORY-PROOF and issue #648 for the full
671    /// write-up of the resulting stale-inclusion-proof bug.
672    ///
673    /// # Errors
674    ///
675    /// [`HistoryError::Mmr`] if commonware's underlying journal/metadata
676    /// `destroy()` fails (e.g. an I/O error removing the partition
677    /// files).
678    pub fn destroy(self) -> Result<(), HistoryError> {
679        match self.backend {
680            Backend::Mem { .. } => Ok(()),
681            Backend::Journaled(b) => {
682                // Destructure so `executor` and `ctx` (which keeps the
683                // bootstrap commonware runtime alive — see the
684                // `JournaledBackend` doc comment) both stay in scope for
685                // the duration of `block_on`, exactly like every other
686                // method on this type. `ctx` itself is unused here beyond
687                // that lifetime-extension role (see #640, which made the
688                // field readable elsewhere but `destroy` has no need to
689                // read it).
690                let JournaledBackend {
691                    mmr,
692                    executor,
693                    ctx: _ctx,
694                    ..
695                } = *b;
696                executor
697                    .block_on(mmr.destroy())
698                    .map_err(|e| HistoryError::Mmr(e.to_string()))
699            }
700        }
701    }
702}
703
704impl Default for CommitHistory<TokioExecutor> {
705    fn default() -> Self {
706        Self::open()
707    }
708}
709
710/// Verify that `commit_hash` was appended at `position` to a history
711/// whose current root is `root`. Returns `true` on a passing proof,
712/// `false` on any tamper / wrong-position / wrong-root case.
713///
714/// Pure function: no allocation beyond what commonware's verifier does
715/// internally; safe to call from a light-client without any of the
716/// preceding chain bytes.
717#[must_use]
718pub fn verify_inclusion(
719    commit_hash: &Hash,
720    position: Position,
721    proof: &InclusionProof,
722    root: &Hash,
723) -> bool {
724    let leaf = digest_from_hash(commit_hash);
725    let root_digest = digest_from_hash(root);
726    let loc = MmrLocation::new(position.0);
727
728    // Same bagging policy as the producer — see [`HISTORY_BAGGING`].
729    let hasher = history_hasher();
730    proof.verify_element_inclusion(&hasher, leaf.as_ref(), loc, &root_digest)
731}
732
733// ---------------------------------------------------------------------
734// v0.1.x → v0.2.x rebuild shim
735// ---------------------------------------------------------------------
736
737/// Rebuild a branch's history MMR from its on-disk parent chain.
738///
739/// For repos created against an older mkit (v0.1.x, before this
740/// module persisted anything to disk) the `<mkit_dir>/history/<branch>/`
741/// directory is empty on first open, but the branch's commit chain is
742/// already on disk in the object store. This helper walks the
743/// first-parent chain from `tip` to root, then re-appends each commit
744/// (in oldest-first order) into the supplied empty [`CommitHistory`].
745///
746/// The walker is supplied by the caller as `parent_of`: given a
747/// commit hash, it returns `Ok(Some(parent_hash))` if there is a
748/// parent, `Ok(None)` for the root commit, and `Err(_)` for any
749/// lookup failure. This keeps `mkit-core::history` free of an
750/// `ObjectStore` dependency — the rebuild shim is a tool the caller
751/// (typically `mkit-cli`) drives.
752///
753/// Batches all backfilled leaves into a single `journal.sync()` (fsync)
754/// for the whole chain, rather than one per commit — see
755/// `CommitHistory::append_no_sync`. Git adopted the same pattern
756/// (`core.fsyncMethod=batch`) for the same reason: fsync latency, not
757/// the MMR math, is what dominates a large backfill.
758///
759/// # Errors
760///
761/// - Propagates any error from `parent_of`.
762/// - Propagates any [`HistoryError`] from the underlying append/sync.
763pub fn rebuild_from_chain<X, F, E>(
764    history: &mut CommitHistory<X>,
765    tip: Hash,
766    mut parent_of: F,
767) -> Result<u64, RebuildError<E>>
768where
769    X: Executor + 'static,
770    F: FnMut(&Hash) -> Result<Option<Hash>, E>,
771    E: core::fmt::Display,
772{
773    let mut chain = Vec::new();
774    let mut cursor = Some(tip);
775    while let Some(h) = cursor {
776        chain.push(h);
777        cursor = parent_of(&h).map_err(RebuildError::Walker)?;
778    }
779    // Walker yielded tip..root; we need root..tip for the append order.
780    chain.reverse();
781    let count = chain.len() as u64;
782    for h in &chain {
783        history.append_no_sync(h).map_err(RebuildError::History)?;
784    }
785    // One fsync for the entire batch instead of one per commit.
786    if count > 0 {
787        history.sync().map_err(RebuildError::History)?;
788    }
789    Ok(count)
790}
791
792/// Errors returned by [`rebuild_from_chain`].
793#[derive(Debug, thiserror::Error)]
794pub enum RebuildError<E: core::fmt::Display> {
795    /// The caller-supplied parent-walker failed. The wrapped `E`
796    /// carries the walker's error verbatim, so callers that pass a
797    /// structured error type get it back unchanged.
798    #[error("parent-chain walker failed: {0}")]
799    Walker(E),
800    /// The underlying [`CommitHistory::append`] failed.
801    #[error(transparent)]
802    History(HistoryError),
803}
804
805// ---------------------------------------------------------------------
806// Internals
807// ---------------------------------------------------------------------
808
809/// Convert an mkit `Hash` (`[u8; 32]`) into commonware's `Blake3::Digest`.
810fn digest_from_hash(h: &Hash) -> <Blake3 as CHasher>::Digest {
811    <<Blake3 as CHasher>::Digest as From<[u8; HASH_LEN]>>::from(*h)
812}
813
814/// Hex-escape a mkit branch name into a commonware-partition-safe
815/// token — see [`crate::refs::sanitize_ref_name`] for the encoding
816/// (`main` → `main`, `feat/v1.0` → `feat_2fv1_2e0`, injective).
817///
818/// The canonical implementation lives in `refs.rs`, NOT here: this
819/// module is entirely `#[cfg(feature = "history-mmr")]`-gated (see
820/// `lib.rs`), but `cas_write`'s per-ref lock naming needs the same
821/// sanitizer in every build, including when this module doesn't
822/// exist. `refs.rs` already has the right dependency direction —
823/// `history.rs` already depends on it for [`crate::refs::validate_ref_name`]
824/// — so this is a thin re-export, not a second implementation, to
825/// avoid the two drifting (found during the epic-#634 code review).
826use crate::refs::sanitize_ref_name as sanitize_branch;
827
828/// Test-only instrumentation for observing how many times
829/// [`bootstrap_commonware_context`] actually spawns a bootstrap OS
830/// thread. Production builds pay zero cost for this (compiled out
831/// entirely under `#[cfg(test)]`).
832///
833/// The counter is installed per-thread rather than as one global
834/// static so that tests exercising this (like
835/// `reopen_reuses_the_bootstrapped_commonware_context_instead_of_rebootstrapping`)
836/// are not flaky under `cargo test`'s default multi-threaded runner —
837/// each test only observes bootstraps triggered by its own call
838/// chain. [`bootstrap_commonware_context`] reads the hook
839/// synchronously on the *calling* thread (before it spawns the
840/// bootstrap thread) and moves the resulting `Arc` into the spawned
841/// closure, so the count still lands correctly even though the
842/// bootstrap itself happens on a different OS thread.
843#[cfg(test)]
844mod bootstrap_probe {
845    use std::cell::RefCell;
846    use std::sync::Arc;
847    use std::sync::atomic::AtomicUsize;
848
849    thread_local! {
850        static COUNTER: RefCell<Option<Arc<AtomicUsize>>> = const { RefCell::new(None) };
851    }
852
853    struct Clear;
854    impl Drop for Clear {
855        fn drop(&mut self) {
856            COUNTER.with(|c| *c.borrow_mut() = None);
857        }
858    }
859
860    /// Install `counter` as the active probe for the current thread.
861    /// Returns a guard that clears the hook on drop.
862    #[must_use]
863    pub(super) fn track(counter: Arc<AtomicUsize>) -> impl Drop {
864        COUNTER.with(|c| *c.borrow_mut() = Some(counter));
865        Clear
866    }
867
868    pub(super) fn current() -> Option<Arc<AtomicUsize>> {
869        COUNTER.with(|c| c.borrow().clone())
870    }
871}
872
873/// Bootstrap a commonware tokio `Context` whose `storage_directory`
874/// is the supplied path.
875///
876/// The bootstrap is done on a fresh OS thread because tokio refuses to
877/// nest `runtime::Builder::build()` inside an already-active runtime
878/// (the `TokioExecutor`'s runtime is already alive in the caller's
879/// thread). The returned Context's inner `Arc<Executor>` keeps the
880/// bootstrap runtime alive after the bootstrap thread joins.
881///
882/// See `docs/specs/SPEC-HISTORY-PROOF.md` §4.1 for the design rationale and
883/// the trade-off with the alternative "share the caller's tokio
884/// runtime" approach.
885///
886/// Callers that already hold a live [`commonware_runtime::tokio::Context`]
887/// (e.g. [`CommitHistory::reopen`]) MUST NOT call this a second time —
888/// see [`CommitHistory::init_journaled`], which re-derives on-disk
889/// state against an existing Context instead.
890fn bootstrap_commonware_context(
891    storage_directory: &Path,
892) -> Result<commonware_runtime::tokio::Context, HistoryError> {
893    let dir = storage_directory.to_path_buf();
894    #[cfg(test)]
895    let probe = bootstrap_probe::current();
896    std::thread::spawn(move || {
897        #[cfg(test)]
898        if let Some(counter) = &probe {
899            counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
900        }
901        let cfg = commonware_runtime::tokio::Config::new().with_storage_directory(dir);
902        let runner = commonware_runtime::tokio::Runner::new(cfg);
903        // Return an owned, labelled child Context. `Context::clone` and
904        // `Metrics::with_label` were both removed in 2026.5.0;
905        // `Supervisor::child` returns an owned Context that clones the
906        // inner `executor: Arc<Executor>`, which keeps the tokio runtime
907        // alive after the bootstrap Runner is dropped at the end of
908        // `start`. The label is best-effort so any commonware metrics
909        // surfaced through this Context are easy to spot in a debugger.
910        runner
911            .start(|ctx| async move { commonware_runtime::Supervisor::child(&ctx, "mkit_history") })
912    })
913    .join()
914    .map_err(|_| HistoryError::RuntimeBootstrap("bootstrap thread panicked".to_string()))
915}
916
917// ---------------------------------------------------------------------
918// Tests
919// ---------------------------------------------------------------------
920
921#[cfg(test)]
922mod tests {
923    use super::*;
924    use tempfile::TempDir;
925
926    /// Deterministic distinct commit hash generator.
927    fn synth(i: u64) -> Hash {
928        crate::hash::hash(&i.to_be_bytes())
929    }
930
931    fn fresh_mkit_dir() -> (TempDir, crate::layout::RepoLayout) {
932        let tmp = TempDir::new().unwrap();
933        let mkit_dir = crate::layout::RepoLayout::single(tmp.path());
934        std::fs::create_dir_all(mkit_dir.common_dir()).unwrap();
935        (tmp, mkit_dir)
936    }
937
938    fn fresh_executor() -> Arc<TokioExecutor> {
939        Arc::new(TokioExecutor::new().expect("tokio runtime"))
940    }
941
942    // ---- mem-only API (unchanged from issue #157) -----------------
943
944    #[test]
945    fn mem_empty_history_root_is_well_defined() {
946        let h1 = CommitHistory::open();
947        let h2 = CommitHistory::open();
948        assert_eq!(h1.root(), h2.root(), "empty root must be deterministic");
949        assert!(h1.is_empty());
950        assert_eq!(h1.len(), 0);
951    }
952
953    #[test]
954    fn mem_append_returns_dense_positions() {
955        let mut h = CommitHistory::open();
956        for i in 0..16u64 {
957            let pos = h.append(&synth(i)).unwrap();
958            assert_eq!(pos, Position(i), "positions must be dense and 0-based");
959        }
960        assert_eq!(h.len(), 16);
961    }
962
963    #[test]
964    fn mem_prove_and_verify_position_712_of_1000() {
965        let mut h = CommitHistory::open();
966        let commits: Vec<Hash> = (0..1000u64).map(synth).collect();
967        for c in &commits {
968            h.append(c).unwrap();
969        }
970        assert_eq!(h.len(), 1000);
971
972        let target = Position(712);
973        let proof = h.prove(target).unwrap();
974        let root = h.root();
975
976        assert!(
977            verify_inclusion(&commits[712], target, &proof, &root),
978            "honest proof must verify"
979        );
980    }
981
982    #[test]
983    fn mem_tampered_proof_fails_verification() {
984        let mut h = CommitHistory::open();
985        for i in 0..256u64 {
986            h.append(&synth(i)).unwrap();
987        }
988        let target = Position(42);
989        let mut proof = h.prove(target).unwrap();
990        let root = h.root();
991        let commit = synth(42);
992
993        assert!(verify_inclusion(&commit, target, &proof, &root));
994
995        assert!(
996            !proof.digests.is_empty(),
997            "non-trivial proof must carry at least one sibling"
998        );
999        let mut bytes: [u8; HASH_LEN] = [0u8; HASH_LEN];
1000        bytes.copy_from_slice(proof.digests[0].as_ref());
1001        bytes[0] ^= 0x01;
1002        proof.digests[0] = <<Blake3 as CHasher>::Digest as From<[u8; HASH_LEN]>>::from(bytes);
1003
1004        assert!(
1005            !verify_inclusion(&commit, target, &proof, &root),
1006            "tampered proof must fail"
1007        );
1008    }
1009
1010    /// SPEC-HISTORY-PROOF §3 enumerates six failure modes
1011    /// `verify_inclusion` MUST reject without panicking. Three are
1012    /// already covered above (tampered digest, wrong commit, wrong
1013    /// root); the remaining three — wrong position, mismatched leaf
1014    /// count, and a truncated/over-long `digests` vector — were only
1015    /// exercised indirectly, by trusting commonware's own test suite at
1016    /// the pinned version. Pin them directly here.
1017    #[test]
1018    fn verify_inclusion_rejects_wrong_position() {
1019        let mut h = CommitHistory::open();
1020        let commits: Vec<Hash> = (0..64u64).map(synth).collect();
1021        for c in &commits {
1022            h.append(c).unwrap();
1023        }
1024        let target = Position(42);
1025        let proof = h.prove(target).unwrap();
1026        let root = h.root();
1027
1028        assert!(verify_inclusion(&commits[42], target, &proof, &root));
1029        // Same commit_hash, proof, and root — only the claimed position
1030        // differs. The proof was built for position 42; claiming it
1031        // proves position 41 (or any other) instead must fail.
1032        assert!(!verify_inclusion(&commits[42], Position(41), &proof, &root));
1033        assert!(!verify_inclusion(&commits[42], Position(0), &proof, &root));
1034    }
1035
1036    #[test]
1037    fn verify_inclusion_rejects_mismatched_leaf_count() {
1038        let mut h = CommitHistory::open();
1039        let commits: Vec<Hash> = (0..64u64).map(synth).collect();
1040        for c in &commits {
1041            h.append(c).unwrap();
1042        }
1043        let target = Position(42);
1044        let mut proof = h.prove(target).unwrap();
1045        let root = h.root();
1046        assert!(verify_inclusion(&commits[42], target, &proof, &root));
1047
1048        // `proof.leaves` claims how many leaves the MMR had when the
1049        // proof was built. Disagreeing with the actual count (64) must
1050        // fail — this is the prover asserting a different-length
1051        // history than the root it's paired with actually commits to.
1052        proof.leaves = MmrLocation::new(63);
1053        assert!(!verify_inclusion(&commits[42], target, &proof, &root));
1054    }
1055
1056    #[test]
1057    fn verify_inclusion_rejects_truncated_or_over_long_digests() {
1058        let mut h = CommitHistory::open();
1059        let commits: Vec<Hash> = (0..64u64).map(synth).collect();
1060        for c in &commits {
1061            h.append(c).unwrap();
1062        }
1063        let target = Position(42);
1064        let proof = h.prove(target).unwrap();
1065        let root = h.root();
1066        assert!(verify_inclusion(&commits[42], target, &proof, &root));
1067        assert!(
1068            !proof.digests.is_empty(),
1069            "non-trivial proof must carry at least one digest"
1070        );
1071
1072        // Truncated: drop the last digest the fold-consumer expects.
1073        let mut truncated = proof.clone();
1074        truncated.digests.pop();
1075        assert!(!verify_inclusion(&commits[42], target, &truncated, &root));
1076
1077        // Over-long: append a bogus extra digest past what the
1078        // consumer-pointer walk expects to find.
1079        let mut over_long = proof;
1080        over_long.digests.push(over_long.digests[0]);
1081        assert!(!verify_inclusion(&commits[42], target, &over_long, &root));
1082    }
1083
1084    // ---- journaled API --------------------------------------------
1085
1086    #[test]
1087    fn open_at_rejects_invalid_branch() {
1088        let (_tmp, mkit_dir) = fresh_mkit_dir();
1089        let exec = fresh_executor();
1090        let err = CommitHistory::open_at(exec, &mkit_dir, "../escape").expect_err("invalid branch");
1091        assert!(matches!(err, HistoryError::InvalidBranch(_)));
1092    }
1093
1094    #[test]
1095    fn open_at_empty_root_matches_mem() {
1096        let (_tmp, mkit_dir) = fresh_mkit_dir();
1097        let exec = fresh_executor();
1098        let h_disk = CommitHistory::open_at(exec, &mkit_dir, "main").unwrap();
1099        let h_mem = CommitHistory::open();
1100        assert_eq!(
1101            h_disk.root(),
1102            h_mem.root(),
1103            "empty journaled root must match empty mem root"
1104        );
1105        assert!(h_disk.is_empty());
1106    }
1107
1108    #[test]
1109    fn open_at_round_trip_100_commits() {
1110        let (_tmp, mkit_dir) = fresh_mkit_dir();
1111        let exec = fresh_executor();
1112
1113        let commits: Vec<Hash> = (0..100u64).map(synth).collect();
1114        let (root_before, len_before) = {
1115            let mut h = CommitHistory::open_at(exec.clone(), &mkit_dir, "main").unwrap();
1116            for c in &commits {
1117                h.append(c).unwrap();
1118            }
1119            (h.root(), h.len())
1120        };
1121        // Re-open and verify the root survives.
1122        let h = CommitHistory::open_at(exec.clone(), &mkit_dir, "main").unwrap();
1123        assert_eq!(h.len(), len_before, "leaf count must survive reopen");
1124        assert_eq!(h.root(), root_before, "root must survive reopen");
1125    }
1126
1127    #[test]
1128    fn open_at_prove_after_reopen() {
1129        let (_tmp, mkit_dir) = fresh_mkit_dir();
1130        let exec = fresh_executor();
1131        let commits: Vec<Hash> = (0..64u64).map(synth).collect();
1132        let root = {
1133            let mut h = CommitHistory::open_at(exec.clone(), &mkit_dir, "main").unwrap();
1134            for c in &commits {
1135                h.append(c).unwrap();
1136            }
1137            h.root()
1138        };
1139        let h = CommitHistory::open_at(exec.clone(), &mkit_dir, "main").unwrap();
1140        let target = Position(17);
1141        let proof = h.prove(target).unwrap();
1142        assert!(verify_inclusion(&commits[17], target, &proof, &root));
1143    }
1144
1145    /// Issue #640: `write_ref_recording_history` does one `open_at`
1146    /// before the repo lock, then `update_ref_with_history`'s
1147    /// `reopen()` re-derives state under the lock. That sequence —
1148    /// `open_at` followed by `reopen()` — must pay for exactly ONE
1149    /// commonware Context bootstrap (one OS thread spawning a full
1150    /// tokio runtime, metrics task, and buffer pools), not two. A
1151    /// second bootstrap is pure waste: everything the first one built
1152    /// is torn down milliseconds later.
1153    #[test]
1154    fn reopen_reuses_the_bootstrapped_commonware_context_instead_of_rebootstrapping() {
1155        let (_tmp, mkit_dir) = fresh_mkit_dir();
1156        let exec = fresh_executor();
1157
1158        let counter = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1159        let _guard = bootstrap_probe::track(counter.clone());
1160
1161        // Mirrors write_ref_recording_history's pre-lock open.
1162        let mut h = CommitHistory::open_at(exec, &mkit_dir, "main").unwrap();
1163        assert_eq!(
1164            counter.load(std::sync::atomic::Ordering::SeqCst),
1165            1,
1166            "open_at must bootstrap the commonware Context exactly once"
1167        );
1168
1169        // Mirrors update_ref_with_history's post-lock reopen().
1170        h.reopen().unwrap();
1171        assert_eq!(
1172            counter.load(std::sync::atomic::Ordering::SeqCst),
1173            1,
1174            "reopen() must reuse the already-bootstrapped Context rather than \
1175             spawning a second bootstrap thread"
1176        );
1177    }
1178
1179    #[test]
1180    fn open_at_distinct_branches_have_distinct_roots() {
1181        let (_tmp, mkit_dir) = fresh_mkit_dir();
1182        let exec = fresh_executor();
1183        let mut main = CommitHistory::open_at(exec.clone(), &mkit_dir, "main").unwrap();
1184        let mut dev = CommitHistory::open_at(exec.clone(), &mkit_dir, "dev").unwrap();
1185        main.append(&synth(0)).unwrap();
1186        dev.append(&synth(1)).unwrap();
1187        assert_ne!(main.root(), dev.root());
1188    }
1189
1190    #[test]
1191    fn open_at_branch_with_slash_is_isolated() {
1192        // Two branches that sanitize to different partition names
1193        // must not share state.
1194        let (_tmp, mkit_dir) = fresh_mkit_dir();
1195        let exec = fresh_executor();
1196        let mut a = CommitHistory::open_at(exec.clone(), &mkit_dir, "feat/v1").unwrap();
1197        let mut b = CommitHistory::open_at(exec.clone(), &mkit_dir, "feat/v2").unwrap();
1198        a.append(&synth(0)).unwrap();
1199        assert_eq!(a.len(), 1);
1200        assert_eq!(b.len(), 0, "sibling branch must not see appended leaf");
1201        b.append(&synth(1)).unwrap();
1202        assert_ne!(a.root(), b.root());
1203    }
1204
1205    // ---- destroy (issue #648: branch-delete journal lifecycle) ----
1206
1207    #[test]
1208    fn destroy_removes_on_disk_partition() {
1209        let (_tmp, mkit_dir) = fresh_mkit_dir();
1210        let exec = fresh_executor();
1211        let commits: Vec<Hash> = (0..5u64).map(synth).collect();
1212
1213        let mut h = CommitHistory::open_at(exec, &mkit_dir, "feature").unwrap();
1214        for c in &commits {
1215            h.append(c).unwrap();
1216        }
1217        assert_eq!(h.len(), 5);
1218
1219        // Sanitized partition name for "feature" is "feature" →
1220        // "feature__journal-blobs" (same layout documented on
1221        // `HISTORY_DIR` and exercised by the truncation test above).
1222        let journal_blobs = mkit_dir.history_dir().join("feature__journal-blobs");
1223        let journal_metadata = mkit_dir.history_dir().join("feature__journal-metadata");
1224        assert!(
1225            journal_blobs.exists(),
1226            "journal blob dir must exist after appends"
1227        );
1228
1229        h.destroy().unwrap();
1230
1231        assert!(
1232            !journal_blobs.exists(),
1233            "destroy must remove the on-disk journal blob partition"
1234        );
1235        assert!(
1236            !journal_metadata.exists(),
1237            "destroy must remove the on-disk journal metadata partition"
1238        );
1239    }
1240
1241    #[test]
1242    fn destroyed_journal_reopens_empty_not_resuming_dead_incarnation() {
1243        // This is the exact invariant `mkit branch -d` + recreate under
1244        // the same name relies on (issue #648): once a branch's journal
1245        // has been destroyed, reopening the SAME sanitized partition
1246        // name must start completely fresh — not resume the deleted
1247        // incarnation's leaves.
1248        let (_tmp, mkit_dir) = fresh_mkit_dir();
1249        let exec = fresh_executor();
1250        let commits: Vec<Hash> = (0..3u64).map(synth).collect();
1251
1252        let mut h = CommitHistory::open_at(exec.clone(), &mkit_dir, "feature").unwrap();
1253        for c in &commits {
1254            h.append(c).unwrap();
1255        }
1256        assert_eq!(h.len(), 3);
1257        h.destroy().unwrap();
1258
1259        let reopened = CommitHistory::open_at(exec, &mkit_dir, "feature").unwrap();
1260        assert_eq!(
1261            reopened.len(),
1262            0,
1263            "a destroyed journal must reopen with zero leaves, not resume the dead incarnation"
1264        );
1265        assert_eq!(
1266            reopened.root(),
1267            CommitHistory::open().root(),
1268            "a fresh reopen after destroy must match a genuinely empty MMR's root"
1269        );
1270    }
1271
1272    #[test]
1273    fn destroy_of_one_branch_does_not_touch_a_sibling_branch() {
1274        let (_tmp, mkit_dir) = fresh_mkit_dir();
1275        let exec = fresh_executor();
1276
1277        let mut a = CommitHistory::open_at(exec.clone(), &mkit_dir, "a").unwrap();
1278        let mut b = CommitHistory::open_at(exec.clone(), &mkit_dir, "b").unwrap();
1279        a.append(&synth(0)).unwrap();
1280        b.append(&synth(1)).unwrap();
1281        let b_root = b.root();
1282        drop(b);
1283
1284        a.destroy().unwrap();
1285
1286        let b_reopened = CommitHistory::open_at(exec, &mkit_dir, "b").unwrap();
1287        assert_eq!(
1288            b_reopened.len(),
1289            1,
1290            "destroying branch 'a' must not affect sibling branch 'b'"
1291        );
1292        assert_eq!(b_reopened.root(), b_root);
1293    }
1294
1295    #[test]
1296    fn open_at_root_matches_live_mem_root() {
1297        // Executor swap test: a journaled MMR's root must equal the
1298        // pure-mem MMR's root computed over the same leaf sequence.
1299        // This proves the executor and the persistence layer are
1300        // opaque to MMR semantics.
1301        let (_tmp, mkit_dir) = fresh_mkit_dir();
1302        let exec = fresh_executor();
1303        let commits: Vec<Hash> = (0..50u64).map(synth).collect();
1304
1305        let mut journaled = CommitHistory::open_at(exec, &mkit_dir, "main").unwrap();
1306        let mut mem = CommitHistory::open();
1307        for c in &commits {
1308            journaled.append(c).unwrap();
1309            mem.append(c).unwrap();
1310        }
1311        assert_eq!(
1312            journaled.root(),
1313            mem.root(),
1314            "journaled and mem MMRs must produce the same root for the same leaf sequence"
1315        );
1316    }
1317
1318    // ---- Negative-path verification (journaled flow) -------------
1319
1320    /// Mem-flavour-equivalent: appending must change the root. Same
1321    /// property as the deleted `root_changes_on_append` test, lifted
1322    /// onto the journaled flavour.
1323    #[test]
1324    fn journaled_root_changes_on_append() {
1325        let (_tmp, mkit_dir) = fresh_mkit_dir();
1326        let exec = fresh_executor();
1327        let mut h = CommitHistory::open_at(exec, &mkit_dir, "main").unwrap();
1328        let root_before = h.root();
1329        h.append(&synth(0)).unwrap();
1330        let root_after = h.root();
1331        assert_ne!(
1332            root_before, root_after,
1333            "appending a leaf must change the journaled MMR root"
1334        );
1335    }
1336
1337    /// Mem-flavour-equivalent: an honest inclusion proof must not verify
1338    /// against a different commit hash than the one that was appended.
1339    #[test]
1340    fn journaled_wrong_commit_fails_verification() {
1341        let (_tmp, mkit_dir) = fresh_mkit_dir();
1342        let exec = fresh_executor();
1343        let mut h = CommitHistory::open_at(exec, &mkit_dir, "main").unwrap();
1344        let h_a = synth(0);
1345        let h_other = synth(99);
1346        h.append(&h_a).unwrap();
1347        let proof = h.prove(Position(0)).unwrap();
1348        let root = h.root();
1349
1350        assert!(
1351            verify_inclusion(&h_a, Position(0), &proof, &root),
1352            "honest proof must verify with the appended commit"
1353        );
1354        assert!(
1355            !verify_inclusion(&h_other, Position(0), &proof, &root),
1356            "swapping in a different commit hash must fail verification"
1357        );
1358    }
1359
1360    /// Mem-flavour-equivalent: an inclusion proof from one branch's MMR
1361    /// must not verify against another branch's root.
1362    #[test]
1363    fn journaled_wrong_root_fails_verification() {
1364        let (_tmp, mkit_dir) = fresh_mkit_dir();
1365        let exec = fresh_executor();
1366
1367        let h_a = synth(0);
1368        let mut a = CommitHistory::open_at(exec.clone(), &mkit_dir, "main").unwrap();
1369        a.append(&h_a).unwrap();
1370        let proof = a.prove(Position(0)).unwrap();
1371        let root_a = a.root();
1372        assert!(
1373            verify_inclusion(&h_a, Position(0), &proof, &root_a),
1374            "sanity: honest proof verifies against its own root"
1375        );
1376
1377        // Independent branch with different leaves — distinct root.
1378        let mut b = CommitHistory::open_at(exec, &mkit_dir, "dev").unwrap();
1379        b.append(&synth(1)).unwrap();
1380        b.append(&synth(2)).unwrap();
1381        let root_b = b.root();
1382        assert_ne!(root_a, root_b, "distinct branches must have distinct roots");
1383
1384        assert!(
1385            !verify_inclusion(&h_a, Position(0), &proof, &root_b),
1386            "proof from one branch must not verify against another branch's root"
1387        );
1388    }
1389
1390    // ---- Crash recovery (commonware-native semantics) ----------
1391
1392    /// Simulate a torn write: truncate one journal blob mid-frame and
1393    /// verify that commonware's `Journaled::init` either rolls forward
1394    /// to the last valid size OR surfaces a recoverable error.
1395    ///
1396    /// SPEC-HISTORY-PROOF §4.4: mkit relies on commonware's native
1397    /// recovery; mkit's own contract is only that reopening a
1398    /// half-written journal does NOT panic and does NOT silently
1399    /// expose stale data.
1400    #[test]
1401    fn truncated_journal_rolls_forward_or_surfaces_corruption() {
1402        let (_tmp, mkit_dir) = fresh_mkit_dir();
1403        let exec = fresh_executor();
1404        let commits: Vec<Hash> = (0..32u64).map(synth).collect();
1405
1406        let len_before = {
1407            let mut h = CommitHistory::open_at(exec.clone(), &mkit_dir, "main").unwrap();
1408            for c in &commits {
1409                h.append(c).unwrap();
1410            }
1411            h.len()
1412        };
1413        assert_eq!(len_before, 32);
1414
1415        // Find the journal blob directory and chop a few bytes off
1416        // the tail of the largest file. The sanitized partition name
1417        // for branch "main" is "main" → "main__journal".
1418        // commonware lays out the journal partition's blobs in a
1419        // subdirectory named `<partition>-blobs`; the journal metadata
1420        // sidecar sits at `<partition>-metadata`. The truncation
1421        // target is whichever blob holds actual digest data.
1422        let journal_root = mkit_dir.history_dir().join("main__journal-blobs");
1423        assert!(
1424            journal_root.exists(),
1425            "journal blob dir must exist after appends"
1426        );
1427        let mut largest: Option<(std::path::PathBuf, u64)> = None;
1428        for entry in std::fs::read_dir(&journal_root).unwrap() {
1429            let entry = entry.unwrap();
1430            let meta = entry.metadata().unwrap();
1431            if meta.is_file() {
1432                let path = entry.path();
1433                let len = meta.len();
1434                if largest.as_ref().is_none_or(|(_, l)| len > *l) {
1435                    largest = Some((path, len));
1436                }
1437            }
1438        }
1439        let (blob_path, blob_len) = largest.expect("at least one blob present after 32 appends");
1440        assert!(
1441            blob_len > 5,
1442            "blob must be large enough to truncate (got {blob_len} bytes)"
1443        );
1444
1445        // Truncate 5 bytes off the end — fewer than a digest (32 B), so
1446        // commonware sees a torn frame at the tail.
1447        let truncated_len = blob_len - 5;
1448        let f = std::fs::OpenOptions::new()
1449            .write(true)
1450            .open(&blob_path)
1451            .unwrap();
1452        f.set_len(truncated_len).unwrap();
1453        drop(f);
1454
1455        // Reopen. mkit's contract: either succeeds (rolled forward to
1456        // last valid size, possibly fewer leaves than before) OR
1457        // surfaces a `Corrupted` error. Crucially: no panic, no
1458        // silent-stale-root.
1459        let reopened = CommitHistory::open_at(exec, &mkit_dir, "main");
1460        match reopened {
1461            Ok(h) => {
1462                assert!(
1463                    h.len() <= len_before,
1464                    "rolled-forward leaf count must not exceed the pre-truncation count"
1465                );
1466                // The root of the rolled-forward state, if non-empty,
1467                // must equal the root of a freshly-built mem MMR over
1468                // the surviving leaves — proving roll-forward is
1469                // semantically consistent.
1470                if !h.is_empty() {
1471                    let n = usize::try_from(h.len()).expect("leaf count fits in usize");
1472                    let mut reference = CommitHistory::open();
1473                    for c in &commits[..n] {
1474                        reference.append(c).unwrap();
1475                    }
1476                    assert_eq!(
1477                        h.root(),
1478                        reference.root(),
1479                        "rolled-forward root must equal a clean replay of the surviving leaves"
1480                    );
1481                }
1482            }
1483            Err(HistoryError::Corrupted(_)) => {
1484                // Acceptable: commonware refused to recover. Surfaced
1485                // to the caller via the documented error variant.
1486            }
1487            Err(other) => panic!("unexpected error on truncated journal: {other:?}"),
1488        }
1489    }
1490
1491    // ---- Rebuild shim --------------------------------------------
1492
1493    #[test]
1494    fn rebuild_from_chain_matches_live_appends() {
1495        let (_tmp, mkit_dir_a) = fresh_mkit_dir();
1496        let (_tmp_b, mkit_dir_b) = fresh_mkit_dir();
1497        let exec = fresh_executor();
1498
1499        // Build the "reference" history by live appends.
1500        let commits: Vec<Hash> = (0..10u64).map(synth).collect();
1501        let reference_root = {
1502            let mut h = CommitHistory::open_at(exec.clone(), &mkit_dir_a, "main").unwrap();
1503            for c in &commits {
1504                h.append(c).unwrap();
1505            }
1506            h.root()
1507        };
1508
1509        // Simulate the v0.1.x situation: refs/heads/main has a tip
1510        // commit, but `<mkit_dir>/history/` is empty. The walker
1511        // returns each commit's parent until root.
1512        let mut h = CommitHistory::open_at(exec.clone(), &mkit_dir_b, "main").unwrap();
1513        let tip = commits[commits.len() - 1];
1514        let count = rebuild_from_chain::<TokioExecutor, _, std::io::Error>(&mut h, tip, |hash| {
1515            // Find this hash in `commits`, return the prior one or
1516            // None if at index 0.
1517            let idx = commits
1518                .iter()
1519                .position(|c| c == hash)
1520                .expect("walker called with unknown hash");
1521            if idx == 0 {
1522                Ok(None)
1523            } else {
1524                Ok(Some(commits[idx - 1]))
1525            }
1526        })
1527        .unwrap();
1528        assert_eq!(count, commits.len() as u64);
1529        assert_eq!(
1530            h.root(),
1531            reference_root,
1532            "rebuilt root must match live-append root"
1533        );
1534    }
1535
1536    /// SPEC-HISTORY-PROOF's backfill cost claim ("single-digit
1537    /// milliseconds for branches up to a few hundred thousand commits")
1538    /// only holds if the backfill fsyncs once for the whole chain, not
1539    /// once per commit. `journal.sync()` is fsync-latency-bound (~1-10ms
1540    /// each), so 500 unbatched syncs alone would blow well past the
1541    /// spec's budget. Instrumentation-based rather than wall-clock-based
1542    /// so it can't flake under CI load.
1543    #[test]
1544    fn rebuild_from_chain_batches_backfill_into_a_single_sync() {
1545        let (_tmp, mkit_dir) = fresh_mkit_dir();
1546        let exec = fresh_executor();
1547        let commits: Vec<Hash> = (0..500u64).map(synth).collect();
1548
1549        let mut h = CommitHistory::open_at(exec, &mkit_dir, "main").unwrap();
1550        reset_sync_call_count();
1551
1552        let tip = commits[commits.len() - 1];
1553        let count = rebuild_from_chain::<TokioExecutor, _, std::io::Error>(&mut h, tip, |hash| {
1554            let idx = commits
1555                .iter()
1556                .position(|c| c == hash)
1557                .expect("walker called with unknown hash");
1558            if idx == 0 {
1559                Ok(None)
1560            } else {
1561                Ok(Some(commits[idx - 1]))
1562            }
1563        })
1564        .unwrap();
1565
1566        assert_eq!(count, 500, "sanity: all 500 commits were backfilled");
1567        assert_eq!(h.len(), 500, "sanity: all 500 leaves landed in the MMR");
1568        assert_eq!(
1569            sync_call_count(),
1570            1,
1571            "backfilling 500 commits must fsync exactly once for the \
1572             whole batch, not once per commit"
1573        );
1574    }
1575
1576    /// A single live `append` (the non-backfill path) must still fsync
1577    /// — batching must not silently drop the per-append durability
1578    /// guarantee for ordinary commit-time writes.
1579    #[test]
1580    fn append_still_syncs_once_per_call() {
1581        let (_tmp, mkit_dir) = fresh_mkit_dir();
1582        let exec = fresh_executor();
1583        let mut h = CommitHistory::open_at(exec, &mkit_dir, "main").unwrap();
1584        reset_sync_call_count();
1585
1586        h.append(&synth(0)).unwrap();
1587        h.append(&synth(1)).unwrap();
1588        h.append(&synth(2)).unwrap();
1589
1590        assert_eq!(
1591            sync_call_count(),
1592            3,
1593            "each live `append` must still fsync on its own — only the \
1594             backfill path batches"
1595        );
1596    }
1597
1598    // ---- Sanitization --------------------------------------------
1599
1600    #[test]
1601    fn sanitize_branch_round_trip_invariants() {
1602        // Different ref-name byte sequences must encode to different
1603        // partition tokens.
1604        assert_ne!(sanitize_branch("feat/v1.0"), sanitize_branch("feat_v1_0"));
1605        // Plain alpha-numeric / dash names pass through unchanged.
1606        assert_eq!(sanitize_branch("main"), "main");
1607        assert_eq!(sanitize_branch("release-2026"), "release-2026");
1608        // `/` escapes to `_2f`.
1609        assert_eq!(sanitize_branch("a/b"), "a_2fb");
1610        // `.` escapes to `_2e`.
1611        assert_eq!(sanitize_branch("v1.0"), "v1_2e0");
1612        // `_` itself escapes to `_5f` to keep the encoding injective.
1613        assert_eq!(sanitize_branch("a_b"), "a_5fb");
1614    }
1615}