Skip to main content

polyc_eventlog/
lib.rs

1//! Append-only conversation event log on a `commonware-storage` journal.
2//!
3//! This crate persists the ordered stream of events that make up a conversation
4//! (user messages, planner decisions, tool calls, …) to an append-only log
5//! backed by the Commonware storage stack — keeping persistence on the
6//! Commonware primitives rather than a relational store.
7//!
8//! # Storage primitive
9//!
10//! [`EventLog`] wraps
11//! [`commonware_storage::journal::contiguous::variable::Journal`]: a
12//! **contiguous, position-based, variable-length** append-only journal. It is
13//! the natural fit here:
14//!
15//! - **Append-only.** [`EventLog::append`] writes one [`Event`] and returns the
16//!   monotonically increasing `u64` *position* the journal assigned it.
17//!   Positions start at `0` and never reused; pruning earlier entries does not
18//!   shift later positions.
19//! - **Ordered replay.** [`EventLog::replay`] returns every event in append
20//!   order, each paired with its position. **Append order is the ordering
21//!   contract**: the caller appends events in conversation order (turn, then
22//!   sequence within a turn), and replay yields them back in exactly that
23//!   order. The position therefore *is* the (turn, seq) ordinal flattened into
24//!   one strictly increasing sequence — there is no separate sort key to
25//!   maintain, which is precisely what an append-only log buys us.
26//! - **Variable-length items.** Each event's `payload` is an opaque,
27//!   buffa-encoded byte blob of arbitrary size; the `variable` journal stores
28//!   variable-length items natively (the `contiguous::fixed` sibling is for
29//!   fixed-width records and would not fit).
30//!
31//! # Runtime genericity (tokio vs. deterministic)
32//!
33//! The journal — and therefore [`EventLog`] — is generic over a
34//! [`commonware_storage::Context`] (the `Storage + Clock + Metrics` bound every
35//! Commonware storage type carries). Production drives it on the
36//! `commonware_runtime::tokio` backend; tests drive it on the
37//! `commonware_runtime::deterministic` backend for seeded, reproducible runs.
38//! The two never nest: per the prior `commonware-transport` spike, the
39//! Commonware runtime cannot be started from inside a live tokio runtime, so
40//! every tokio process that embeds this crate runs it on a dedicated thread.
41//! The state plane does so for the conversation journal it alone writes; the
42//! control plane does so for the non-conversation logs it keeps of its own.
43//! This crate stays runtime-agnostic and leaves that hosting decision to the
44//! caller.
45//!
46//! # Conversation scoping
47//!
48//! One [`EventLog`] instance maps to one conversation's log, identified by the
49//! storage *partition* name passed to [`EventLog::open`] (derive it from the
50//! conversation id, e.g. `format!("conv-{uid}")`). Distinct conversations use
51//! distinct partitions and so are fully isolated on disk.
52//!
53//! # Example
54//!
55//! <!--
56//! Marked `ignore`, not run: a runnable doctest statically links the entire
57//! Commonware storage stack into its own dedicated binary, and that link
58//! OOMs/bus-errors CI's linker. The example is mirrored verbatim by the
59//! `doc_example_open_append_replay` unit test, which folds into the crate's
60//! existing (already-linked) test binary rather than adding a second heavy
61//! link — so the snippet stays verified without the extra link unit.
62//! -->
63//! ```ignore
64//! use commonware_runtime::{deterministic, Runner};
65//! use polyc_eventlog::{Event, EventLog, EventLogConfig};
66//!
67//! let executor = deterministic::Runner::default();
68//! executor.start(|context| async move {
69//!     let log = EventLog::open(context, EventLogConfig::for_partition("conv-1"))
70//!         .await
71//!         .expect("open log");
72//!
73//!     log.append(&Event::new("user_msg", b"hello".to_vec())).await.unwrap();
74//!     log.append(&Event::new("tool_call", b"\x01\x02".to_vec())).await.unwrap();
75//!     log.commit().await.unwrap();
76//!
77//!     let events = log.replay().await.unwrap();
78//!     assert_eq!(events.len(), 2);
79//!     assert_eq!(events[0].kind, "user_msg");
80//! });
81//! ```
82
83pub mod checkpoint;
84pub mod error;
85mod metrics;
86
87pub use checkpoint::EventCountCheckpoint;
88pub use error::EventLogError;
89pub use polyc_eventlog_model::integrity;
90pub use polyc_eventlog_model::integrity::{
91    IntegrityError, MMR_SIGNED_ROOT_KIND, extend_and_sign, rebuild_from_events,
92    verify_extension_with_trust, verify_replay, verify_replay_with_trust,
93};
94pub use polyc_eventlog_model::nav;
95pub use polyc_eventlog_model::taint;
96pub use polyc_eventlog_model::taint::{
97    GrantedCapabilities, TrifectaLegs, TrustTag, any_untrusted, any_untrusted_excluding,
98    trifecta_legs,
99};
100pub use polyc_eventlog_model::{BoundedReplay, Event, EventCfg};
101
102/// Force-register this crate's Prometheus append-latency histogram.
103///
104/// Makes it appear in a `/metrics` scrape immediately — before any event has
105/// been appended. Idempotent (backed by a `OnceLock`); call once at process
106/// startup, alongside any other crate's own `init_metrics`.
107pub fn init_metrics() {
108    metrics::force();
109}
110
111use commonware_runtime::buffer::paged::CacheRef;
112use commonware_storage::journal::contiguous::{Contiguous as _, variable};
113use commonware_utils::sync::AsyncMutex;
114use commonware_utils::{NZU16, NZU64, NZUsize};
115use futures::StreamExt as _;
116use std::num::{NonZeroU16, NonZeroU64, NonZeroUsize};
117
118/// Buffer size (in items) for the replay stream from the underlying journal.
119const REPLAY_BUFFER: NonZeroUsize = NZUsize!(1024);
120
121/// One event [`EventLog::replay_quarantining`] could not decode, and why.
122#[derive(Debug, Clone)]
123pub struct QuarantinedItem {
124    /// Journal position of the corrupted item.
125    pub position: u64,
126    /// The underlying decode/storage error's `Display` text.
127    pub error: String,
128}
129
130/// Configuration for opening an [`EventLog`].
131///
132/// Most fields mirror the underlying journal's tuning knobs and have sensible
133/// defaults via [`EventLogConfig::for_partition`]; only the `partition`
134/// (which conversation's log) is mandatory.
135#[derive(Debug, Clone)]
136pub struct EventLogConfig {
137    /// Storage partition name — one per conversation. Sub-partitions for the
138    /// data and offset indexes are derived from it by the journal.
139    pub partition: String,
140
141    /// Number of events stored per journal section. Sections roll over at this
142    /// count; only the final (partial) section is replayed on open to recover
143    /// the exact size. **Immutable once a partition exists** — changing it
144    /// across restarts corrupts the log.
145    pub items_per_section: NonZeroU64,
146
147    /// Decode-time bounds applied to each event during [`EventLog::replay`].
148    pub event_cfg: EventCfg,
149
150    /// Page size for the read cache over the underlying storage blobs.
151    pub page_size: NonZeroU16,
152
153    /// Page cache capacity, in pages.
154    pub page_cache_pages: NonZeroUsize,
155
156    /// Per-section write buffer size, in bytes.
157    pub write_buffer: NonZeroUsize,
158}
159
160impl EventLogConfig {
161    /// Build a config for `partition` with defaults for every other field.
162    ///
163    /// Defaults: 1024 events per section, [`EventCfg::DEFAULT`] decode bounds,
164    /// a 16 KiB page size with a 64-page cache, and a 64 KiB write buffer.
165    #[must_use]
166    pub fn for_partition(partition: impl Into<String>) -> Self {
167        Self {
168            partition: partition.into(),
169            items_per_section: NZU64!(1024),
170            event_cfg: EventCfg::DEFAULT,
171            page_size: NZU16!(16384),
172            page_cache_pages: NZUsize!(64),
173            write_buffer: NZUsize!(65536),
174        }
175    }
176}
177
178/// An append-only, ordered log of conversation [`Event`]s.
179///
180/// Generic over a [`commonware_storage::Context`] so the same code runs on the
181/// tokio backend in production and the deterministic backend in tests. See the
182/// crate-level docs for the ordering contract and runtime-coexistence notes.
183pub struct EventLog<E>
184where
185    E: commonware_storage::Context + commonware_runtime::BufferPooler,
186{
187    /// The journal's append/commit/sync/snapshot operations take `&mut self`
188    /// (commonware 2026.7's journal API), but `EventLog` hands out a single
189    /// shared handle to a control-plane host, forensics reads, and the
190    /// workqueue alike. This lock recovers that shared surface; it is not a
191    /// new concurrency model — a `Lease` at a higher layer already serializes
192    /// writers per conversation, so contention here is only ever between the
193    /// lone writer and concurrent readers.
194    journal: AsyncMutex<variable::Journal<E, Event>>,
195}
196
197impl<E> EventLog<E>
198where
199    E: commonware_storage::Context + commonware_runtime::BufferPooler,
200{
201    /// Open (creating if absent, recovering if present) the event log for a
202    /// conversation on the given runtime `context`.
203    ///
204    /// On open the journal replays only its final section to recover the exact
205    /// append size, and self-heals any data/offset divergence left by a crash.
206    ///
207    /// # Errors
208    ///
209    /// Returns [`EventLogError::Journal`] if the underlying storage fails to
210    /// initialize or recover the journal.
211    pub async fn open(context: E, config: EventLogConfig) -> Result<Self, EventLogError> {
212        let page_cache = CacheRef::from_pooler(&context, config.page_size, config.page_cache_pages);
213        let journal_cfg = variable::Config {
214            partition: config.partition,
215            items_per_section: config.items_per_section,
216            compression: None,
217            codec_config: config.event_cfg,
218            page_cache,
219            write_buffer: config.write_buffer,
220        };
221        let journal = variable::Journal::init(context, journal_cfg).await?;
222        Ok(Self {
223            journal: AsyncMutex::new(journal),
224        })
225    }
226
227    /// Destroy the log: consume the handle and REMOVE the partition's
228    /// underlying blobs (data + offsets) from storage. The erasure primitive
229    /// (#216): after this, a fresh [`EventLog::open`] of the same partition
230    /// starts empty.
231    ///
232    /// Deliberately consuming — a destroyed log has no valid further
233    /// operation, and the caller must drop every other handle first (the
234    /// control plane's host serializes this through its single command
235    /// loop and evicts its cache entry before destroying).
236    ///
237    /// # Errors
238    ///
239    /// Returns [`EventLogError::Journal`] if the underlying blob removal
240    /// fails.
241    pub async fn destroy(self) -> Result<(), EventLogError> {
242        Ok(self.journal.into_inner().destroy().await?)
243    }
244
245    /// Append a single event, returning the position the journal assigned it.
246    ///
247    /// Positions are strictly increasing from `0` and define replay order. The
248    /// caller must append in conversation order (turn, then seq within a turn)
249    /// for replay to reflect that order.
250    ///
251    /// Takes `&self`: [`EventLog`]'s own lock (see the struct-level doc)
252    /// recovers a shared reference over the journal's `&mut self` ops.
253    ///
254    /// Appends are buffered for durability; call [`EventLog::commit`] (or
255    /// [`EventLog::sync`]) to guarantee they survive a crash.
256    ///
257    /// # Errors
258    ///
259    /// Returns [`EventLogError::Journal`] if the item cannot be encoded or the
260    /// underlying storage write fails.
261    pub async fn append(&self, event: &Event) -> Result<u64, EventLogError> {
262        let start = std::time::Instant::now(); // determinism-allow: metrics-only timing, never persisted or replayed
263        let result = self.journal.lock().await.append(event).await;
264        metrics::record_append(result.is_ok(), start.elapsed());
265        Ok(result?)
266    }
267
268    /// Number of events appended to the log (the position the *next* append
269    /// will receive). Not reduced by pruning.
270    pub async fn len(&self) -> u64 {
271        self.journal.lock().await.size()
272    }
273
274    /// Whether the log has no appended events.
275    pub async fn is_empty(&self) -> bool {
276        self.len().await == 0
277    }
278
279    /// Replay every event in append order, each paired with its position.
280    ///
281    /// The returned `Vec` is ordered by position ascending (`0, 1, 2, …`),
282    /// which is conversation order. Each tuple is `(position, event)`.
283    ///
284    /// This collects the full log into memory; it is intended for rebuilding
285    /// in-memory conversation state on resume. For very large logs a streaming
286    /// variant could be added later (the journal exposes a `Stream`), but the
287    /// foundational API materializes for simplicity.
288    ///
289    /// # Errors
290    ///
291    /// Returns [`EventLogError::Journal`] if the journal cannot start the replay
292    /// stream or if decoding any stored event fails.
293    // `snapshot()` returns an owned, `'static` reader (unlike the borrowed
294    // `reader()` of commonware 2026.5), so the journal lock is released as
295    // soon as the snapshot is taken — the stream below never holds it.
296    pub async fn replay_with_positions(&self) -> Result<Vec<(u64, Event)>, EventLogError> {
297        let reader = self.journal.lock().await.snapshot().await?;
298        let start = reader.bounds().start;
299        let stream = reader.replay(start, REPLAY_BUFFER).await?;
300        futures::pin_mut!(stream);
301        let mut out = Vec::new();
302        while let Some(item) = stream.next().await {
303            out.push(item?);
304        }
305        Ok(out)
306    }
307
308    /// Replay every event in append order, each paired with its position —
309    /// same as [`EventLog::replay_with_positions`] — but STOP pulling from
310    /// the underlying replay stream the instant the cumulative payload bytes
311    /// read so far exceed `max_bytes`.
312    ///
313    /// This is issue #1541's early-abort primitive: [`EventLog::replay_with_positions`]
314    /// always drains the whole stream into one `Vec` before any caller can
315    /// check its size, so a budget checked only after that call returns has
316    /// already paid the full allocation cost it meant to avoid. This method
317    /// instead checks the running byte total INSIDE the same loop that pulls
318    /// from the stream, so the returned `Vec` never grows past `max_bytes`
319    /// plus one event's own payload size (the one event whose read tips the
320    /// budget over is kept, then the loop breaks — the rest of the
321    /// partition, however large, is never fetched from the journal).
322    ///
323    /// # Errors
324    ///
325    /// Returns [`EventLogError::Journal`] if the journal cannot start the
326    /// replay stream or if decoding any stored item fails before the budget
327    /// trips.
328    pub async fn replay_with_positions_bounded(
329        &self,
330        max_bytes: u64,
331    ) -> Result<BoundedReplay, EventLogError> {
332        let reader = self.journal.lock().await.snapshot().await?;
333        let start = reader.bounds().start;
334        let stream = reader.replay(start, REPLAY_BUFFER).await?;
335        futures::pin_mut!(stream);
336        let mut events = Vec::new();
337        let mut bytes_read: u64 = 0;
338        let mut budget_exceeded = false;
339        while let Some(item) = stream.next().await {
340            let (position, event) = item?;
341            bytes_read = bytes_read.saturating_add(event.payload.len() as u64);
342            events.push((position, event));
343            if bytes_read > max_bytes {
344                budget_exceeded = true;
345                break;
346            }
347        }
348        Ok(BoundedReplay {
349            events,
350            bytes_read,
351            budget_exceeded,
352        })
353    }
354
355    /// Replay events in append order starting at position `start`, each paired
356    /// with its position.
357    ///
358    /// The journal is position-indexed, so resuming at an offset is cheap — the
359    /// reader seeks to `start` rather than scanning from zero. This is what lets
360    /// a caller replay only the tail since a durable checkpoint instead of
361    /// re-reading the whole partition every time. `start` is clamped up to the
362    /// pruning boundary, and a `start` at or past the end yields an empty `Vec`.
363    /// Returned tuples are `(position, event)` for positions in
364    /// `[max(start, bounds.start), len)`, ascending.
365    ///
366    /// # Errors
367    ///
368    /// Returns [`EventLogError::Journal`] if the journal cannot start the replay
369    /// stream or if decoding any stored event fails.
370    // See [`EventLog::replay_with_positions`]: the snapshot is owned, so the
371    // journal lock is released before the stream is consumed.
372    pub async fn replay_from_with_positions(
373        &self,
374        start: u64,
375    ) -> Result<Vec<(u64, Event)>, EventLogError> {
376        let reader = self.journal.lock().await.snapshot().await?;
377        let bounds = reader.bounds();
378        let from = start.max(bounds.start);
379        if from >= bounds.end {
380            return Ok(Vec::new());
381        }
382        let stream = reader.replay(from, REPLAY_BUFFER).await?;
383        futures::pin_mut!(stream);
384        let mut out = Vec::new();
385        while let Some(item) = stream.next().await {
386            out.push(item?);
387        }
388        Ok(out)
389    }
390
391    /// Replay events in append order starting at position `start`, each
392    /// paired with its position — same resume semantics as
393    /// [`EventLog::replay_from_with_positions`] — but STOP pulling from the
394    /// underlying replay stream the instant the cumulative payload bytes read
395    /// so far exceed `max_bytes`, the same early-abort mechanic
396    /// [`EventLog::replay_with_positions_bounded`] applies to a replay from
397    /// the very start.
398    ///
399    /// This is the combined primitive neither of the other two replay
400    /// methods can express alone: [`EventLog::replay_with_positions_bounded`]
401    /// bounds bytes but always starts at position `0`, and
402    /// [`EventLog::replay_from_with_positions`] resumes at `start` but has no
403    /// byte cap, so a partition whose TAIL (the part after a caller-held
404    /// watermark) is itself large could still be materialized in full before
405    /// any caller ever gets a chance to reject it. This method closes that
406    /// gap: a caller resuming from its own cached watermark (`polyc_query`'s
407    /// per-partition decode cache is the first consumer) gets the same
408    /// mid-stream budget enforcement a fresh replay already had, without
409    /// paying to re-read anything before `start`.
410    ///
411    /// `start` is clamped up to the partition's own pruning boundary exactly
412    /// as [`EventLog::replay_from_with_positions`] does, and a `start` at or
413    /// past the partition's end yields an empty, `budget_exceeded: false`
414    /// [`BoundedReplay`] rather than an error.
415    ///
416    /// # Errors
417    ///
418    /// Returns [`EventLogError::Journal`] if the journal cannot start the
419    /// replay stream or if decoding any stored item fails before the budget
420    /// trips.
421    // See [`EventLog::replay_with_positions`]: the snapshot is owned, so the
422    // journal lock is released before the stream is consumed.
423    pub async fn replay_from_with_positions_bounded(
424        &self,
425        start: u64,
426        max_bytes: u64,
427    ) -> Result<BoundedReplay, EventLogError> {
428        let reader = self.journal.lock().await.snapshot().await?;
429        let bounds = reader.bounds();
430        let from = start.max(bounds.start);
431        if from >= bounds.end {
432            return Ok(BoundedReplay {
433                events: Vec::new(),
434                bytes_read: 0,
435                budget_exceeded: false,
436            });
437        }
438        let stream = reader.replay(from, REPLAY_BUFFER).await?;
439        futures::pin_mut!(stream);
440        let mut events = Vec::new();
441        let mut bytes_read: u64 = 0;
442        let mut budget_exceeded = false;
443        while let Some(item) = stream.next().await {
444            let (position, event) = item?;
445            bytes_read = bytes_read.saturating_add(event.payload.len() as u64);
446            events.push((position, event));
447            if bytes_read > max_bytes {
448                budget_exceeded = true;
449                break;
450            }
451        }
452        Ok(BoundedReplay {
453            events,
454            bytes_read,
455            budget_exceeded,
456        })
457    }
458
459    /// Replay events in `[start, end)` — end EXCLUSIVE — each paired with its
460    /// position, under the same byte cap
461    /// [`EventLog::replay_from_with_positions_bounded`] applies.
462    ///
463    /// The only replay primitive here that accepts an UPPER bound. Every
464    /// other one drains to the journal's tail: `replay_with_positions` and
465    /// `replay_from_with_positions` have no cap at all, and the two
466    /// `_bounded` siblings cap BYTES, which stops a large read but cannot
467    /// express "these events and no others". A caller that knows the exact
468    /// span it wants — one turn's events, say, located by a prior index —
469    /// otherwise has to replay from `start` to the tail and discard the
470    /// remainder, so the cost of fetching a hit near the beginning of a long
471    /// conversation scales with the conversation rather than with the hit.
472    ///
473    /// Both ends are clamped to the partition's own bounds: `start` up to the
474    /// pruning boundary (as [`EventLog::replay_from_with_positions`] does),
475    /// `end` down to the journal's tail, so an `end` past the tail reads to
476    /// the tail rather than erroring. An empty or inverted range — `start`
477    /// at or past the clamped `end` — yields an empty,
478    /// `budget_exceeded: false` [`BoundedReplay`], never an error.
479    ///
480    /// The byte cap keeps the same meaning it has on the sibling methods: the
481    /// event that trips the budget is INCLUDED, and `budget_exceeded` is set
482    /// so the caller can tell a truncated read from a complete one. A range
483    /// that ends before the cap trips returns `budget_exceeded: false` even
484    /// if `bytes_read` is large, because the range, not the budget, is what
485    /// stopped it.
486    ///
487    /// # Errors
488    ///
489    /// Returns [`EventLogError::Journal`] if the journal cannot start the
490    /// replay stream or if decoding any stored item fails before either bound
491    /// stops it.
492    // See [`EventLog::replay_with_positions`]: the snapshot is owned, so the
493    // journal lock is released before the stream is consumed.
494    pub async fn replay_range_with_positions_bounded(
495        &self,
496        start: u64,
497        end: u64,
498        max_bytes: u64,
499    ) -> Result<BoundedReplay, EventLogError> {
500        let reader = self.journal.lock().await.snapshot().await?;
501        let bounds = reader.bounds();
502        let from = start.max(bounds.start);
503        let until = end.min(bounds.end);
504        if from >= until {
505            return Ok(BoundedReplay {
506                events: Vec::new(),
507                bytes_read: 0,
508                budget_exceeded: false,
509            });
510        }
511        let stream = reader.replay(from, REPLAY_BUFFER).await?;
512        futures::pin_mut!(stream);
513        let mut events = Vec::new();
514        let mut bytes_read: u64 = 0;
515        let mut budget_exceeded = false;
516        while let Some(item) = stream.next().await {
517            let (position, event) = item?;
518            // Checked BEFORE accounting: an event at or past `end` is outside
519            // the requested range, so it must not reach the caller and must
520            // not spend the caller's byte budget either.
521            if position >= until {
522                break;
523            }
524            bytes_read = bytes_read.saturating_add(event.payload.len() as u64);
525            events.push((position, event));
526            if bytes_read > max_bytes {
527                budget_exceeded = true;
528                break;
529            }
530        }
531        Ok(BoundedReplay {
532            events,
533            bytes_read,
534            budget_exceeded,
535        })
536    }
537
538    /// Replay every event in append order, discarding positions.
539    ///
540    /// Convenience over [`EventLog::replay_with_positions`] for callers that
541    /// only need the ordered events.
542    ///
543    /// # Errors
544    ///
545    /// Returns [`EventLogError::Journal`] on the same conditions as
546    /// [`EventLog::replay_with_positions`].
547    pub async fn replay(&self) -> Result<Vec<Event>, EventLogError> {
548        Ok(self
549            .replay_with_positions()
550            .await?
551            .into_iter()
552            .map(|(_pos, event)| event)
553            .collect())
554    }
555
556    /// Replay events from position `start` in append order, discarding
557    /// positions. Convenience over [`EventLog::replay_from_with_positions`].
558    ///
559    /// # Errors
560    ///
561    /// Returns [`EventLogError::Journal`] on the same conditions as
562    /// [`EventLog::replay_from_with_positions`].
563    pub async fn replay_from(&self, start: u64) -> Result<Vec<Event>, EventLogError> {
564        Ok(self
565            .replay_from_with_positions(start)
566            .await?
567            .into_iter()
568            .map(|(_pos, event)| event)
569            .collect())
570    }
571
572    /// Replay every event in append order, skipping any position whose item
573    /// cannot be decoded rather than aborting the whole replay.
574    ///
575    /// [`EventLog::replay_with_positions`] stops at the first bad item (the
576    /// backup/DR gap #799 tracks: one corrupted event permanently locks a
577    /// conversation out of replay). This reads each position independently
578    /// through the journal's position index — a corrupted item's neighbors
579    /// don't depend on decoding it — so it recovers everything readable and
580    /// reports the rest as [`QuarantinedItem`]s. This is the primitive a
581    /// `conversation repair` operation uses to drop only the unreadable
582    /// event(s) and let the rest of the log replay again; it is otherwise
583    /// intended for that recovery path, not routine replay (one read per
584    /// position, versus one streamed pass).
585    ///
586    /// # Errors
587    ///
588    /// Returns [`EventLogError::Journal`] if the journal cannot report its
589    /// own bounds. A per-item decode failure is reported in the returned
590    /// quarantine list, never as an `Err`.
591    pub async fn replay_quarantining(
592        &self,
593    ) -> Result<(Vec<(u64, Event)>, Vec<QuarantinedItem>), EventLogError> {
594        let reader = self.journal.lock().await.snapshot().await?;
595        let bounds = reader.bounds();
596        let mut ok = Vec::new();
597        let mut quarantined = Vec::new();
598        for position in bounds {
599            match reader.read(position).await {
600                Ok(event) => ok.push((position, event)),
601                Err(err) => quarantined.push(QuarantinedItem {
602                    position,
603                    error: err.to_string(),
604                }),
605            }
606        }
607        Ok((ok, quarantined))
608    }
609
610    /// Durably persist all buffered appends, guaranteeing they survive a crash.
611    ///
612    /// Committed appends survive a crash, but the next [`EventLog::open`] may
613    /// perform recovery work rebuilding the position index from data before
614    /// replay is available — [`EventLog::sync`] additionally makes the next
615    /// open recovery-free.
616    ///
617    /// # Errors
618    ///
619    /// Returns [`EventLogError::Journal`] if the underlying flush fails.
620    pub async fn commit(&self) -> Result<(), EventLogError> {
621        Ok(self.journal.lock().await.commit().await?)
622    }
623
624    /// Stronger durability than [`EventLog::commit`]: persist and guarantee no
625    /// recovery work is needed on next open.
626    ///
627    /// # Errors
628    ///
629    /// Returns [`EventLogError::Journal`] if the underlying sync fails.
630    pub async fn sync(&self) -> Result<(), EventLogError> {
631        Ok(self.journal.lock().await.sync().await?)
632    }
633}
634
635#[cfg(test)]
636mod tests {
637    use super::{Event, EventLog, EventLogConfig, EventLogError};
638    use commonware_runtime::{Runner, Supervisor as _, deterministic};
639
640    /// Mirrors the crate-level `# Example` doctest verbatim. The doc block is
641    /// marked `ignore` because a runnable doctest links the whole Commonware
642    /// stack into its own binary, exhausting CI's linker; this test re-verifies the
643    /// same code in the crate's already-linked test binary so the documented
644    /// example can't silently rot.
645    #[test]
646    fn doc_example_open_append_replay() {
647        let executor = deterministic::Runner::default();
648        executor.start(|context| async move {
649            let log = EventLog::open(context, EventLogConfig::for_partition("conv-1"))
650                .await
651                .expect("open log");
652
653            log.append(&Event::new("user_msg", b"hello".to_vec()))
654                .await
655                .unwrap();
656            log.append(&Event::new("tool_call", b"\x01\x02".to_vec()))
657                .await
658                .unwrap();
659            log.commit().await.unwrap();
660
661            let events = log.replay().await.unwrap();
662            assert_eq!(events.len(), 2);
663            assert_eq!(events[0].kind, "user_msg");
664        });
665    }
666
667    /// Append events across several conversation turns, then assert replay
668    /// returns them in append (conversation) order with payload bytes intact.
669    #[test]
670    fn append_then_replay_preserves_order_and_payload() {
671        let executor = deterministic::Runner::default();
672        executor.start(|context| async move {
673            let log = EventLog::open(context, EventLogConfig::for_partition("conv-order"))
674                .await
675                .expect("open");
676
677            // Two turns: turn 0 = user_msg + planner_decision; turn 1 =
678            // tool_call + tool_result. Appended in conversation order.
679            let appended = vec![
680                Event::new("user_msg", b"what is 2+2?".to_vec()),
681                Event::new("planner_decision", vec![0xde, 0xad]),
682                Event::new("tool_call", vec![0x01, 0x02, 0x03]),
683                Event::new("tool_result", vec![0xff, 0x00, 0xff]),
684            ];
685            for (i, event) in appended.iter().enumerate() {
686                let pos = log.append(event).await.expect("append");
687                assert_eq!(pos, i as u64, "positions are 0-indexed and contiguous");
688            }
689            log.commit().await.expect("commit");
690
691            assert_eq!(log.len().await, 4);
692            assert!(!log.is_empty().await);
693
694            // Replay yields exactly the appended sequence, in order.
695            let replayed = log.replay().await.expect("replay");
696            assert_eq!(replayed, appended);
697
698            // Positions are ascending and dense.
699            let with_pos = log.replay_with_positions().await.expect("replay+pos");
700            let positions: Vec<u64> = with_pos.iter().map(|(p, _)| *p).collect();
701            assert_eq!(positions, vec![0, 1, 2, 3]);
702
703            // Payload bytes round-trip verbatim.
704            assert_eq!(with_pos[2].1.payload, vec![0x01, 0x02, 0x03]);
705        });
706    }
707
708    /// Bounded replay: `replay_from(start)` seeks to `start` and yields only the
709    /// tail `[start, len)`, never re-reading earlier positions — the position-
710    /// indexed primitive a checkpointed replay starts from. `replay_from(0)`
711    /// equals a full replay; a `start` at/past the end yields nothing.
712    #[test]
713    fn replay_from_offset_returns_tail_only() {
714        let executor = deterministic::Runner::default();
715        executor.start(|context| async move {
716            let log = EventLog::open(context, EventLogConfig::for_partition("conv-from"))
717                .await
718                .expect("open");
719            for i in 0..5u8 {
720                log.append(&Event::new(format!("k{i}"), vec![i]))
721                    .await
722                    .expect("append");
723            }
724            log.commit().await.expect("commit");
725
726            // A full replay sees all five from position 0.
727            assert_eq!(log.replay_with_positions().await.expect("replay").len(), 5);
728
729            // `replay_from(2)` starts at the offset, not 0: positions 2,3,4 only.
730            let tail = log
731                .replay_from_with_positions(2)
732                .await
733                .expect("replay_from");
734            let positions: Vec<u64> = tail.iter().map(|(p, _)| *p).collect();
735            assert_eq!(positions, vec![2, 3, 4]);
736            assert_eq!(tail[0].1.kind, "k2");
737            assert_eq!(tail.last().expect("non-empty").1.kind, "k4");
738
739            // Starting at or past the end yields nothing.
740            assert!(log.replay_from(5).await.expect("from end").is_empty());
741            assert!(log.replay_from(99).await.expect("past end").is_empty());
742
743            // `replay_from(0)` is exactly a full replay.
744            assert_eq!(
745                log.replay_from(0).await.expect("from 0"),
746                log.replay().await.expect("replay")
747            );
748        });
749    }
750
751    /// Issue #1541's core early-abort proof: [`EventLog::replay_with_positions_bounded`]
752    /// stops pulling from the journal's own replay stream the instant
753    /// cumulative payload bytes cross the caller's budget — it does NOT
754    /// drain the whole partition first and check afterward. Ten events of
755    /// exactly 1,000 bytes each (10,000 bytes total) replayed under a 3,500
756    /// byte budget must stop after the FOURTH event (4,000 bytes — the
757    /// first cumulative total to exceed 3,500), never reaching the
758    /// remaining six. This test fails if a future change reintroduces
759    /// full-materialize-then-check: it would return all 10 events instead
760    /// of 4.
761    #[test]
762    fn replay_with_positions_bounded_stops_reading_mid_partition_once_the_budget_trips() {
763        let executor = deterministic::Runner::default();
764        executor.start(|context| async move {
765            let log = EventLog::open(context, EventLogConfig::for_partition("conv-bounded"))
766                .await
767                .expect("open");
768            for i in 0..10u32 {
769                log.append(&Event::new(format!("k{i}"), vec![0u8; 1_000]))
770                    .await
771                    .expect("append");
772            }
773            log.commit().await.expect("commit");
774
775            let bounded = log
776                .replay_with_positions_bounded(3_500)
777                .await
778                .expect("bounded replay");
779
780            assert!(
781                bounded.budget_exceeded,
782                "3,500-byte budget over a 10,000-byte partition must trip"
783            );
784            assert_eq!(
785                bounded.events.len(),
786                4,
787                "replay must stop the instant cumulative bytes (4,000 after the 4th event) \
788                 cross the 3,500 budget — reading a 5th event (or draining the whole partition) \
789                 means the abort happened too late, or not at all"
790            );
791            assert_eq!(
792                bounded.bytes_read, 4_000,
793                "bytes_read must reflect exactly the events actually returned, not the whole \
794                 partition's real 10,000 bytes"
795            );
796            assert!(
797                bounded.bytes_read < 10_000,
798                "peak materialized bytes must stay bounded well under the partition's real \
799                 size — the whole point of stopping mid-stream"
800            );
801
802            // The returned prefix is still ordered and intact — an early
803            // abort must not corrupt what it DID manage to read.
804            let positions: Vec<u64> = bounded.events.iter().map(|(p, _)| *p).collect();
805            assert_eq!(positions, vec![0, 1, 2, 3]);
806        });
807    }
808
809    /// The companion happy path: a partition whose whole payload fits under
810    /// the budget replays completely, `budget_exceeded` is `false`, and the
811    /// result matches [`EventLog::replay_with_positions`] exactly — the byte
812    /// budget must never truncate a scope that is genuinely within it.
813    #[test]
814    fn replay_with_positions_bounded_reads_everything_when_under_budget() {
815        let executor = deterministic::Runner::default();
816        executor.start(|context| async move {
817            let log = EventLog::open(context, EventLogConfig::for_partition("conv-under-budget"))
818                .await
819                .expect("open");
820            for i in 0..5u32 {
821                log.append(&Event::new(format!("k{i}"), vec![0u8; 100]))
822                    .await
823                    .expect("append");
824            }
825            log.commit().await.expect("commit");
826
827            let unbounded = log.replay_with_positions().await.expect("replay");
828            let bounded = log
829                .replay_with_positions_bounded(10_000)
830                .await
831                .expect("bounded replay");
832
833            assert!(
834                !bounded.budget_exceeded,
835                "500 bytes under a 10,000 byte budget must never trip"
836            );
837            assert_eq!(
838                bounded.events, unbounded,
839                "must match the unbounded replay exactly"
840            );
841            assert_eq!(bounded.bytes_read, 500);
842        });
843    }
844
845    /// [`EventLog::replay_from_with_positions_bounded`]'s own core proof: it
846    /// honors BOTH `start` (skip everything before the caller's watermark,
847    /// like [`EventLog::replay_from_with_positions`]) AND `max_bytes` (stop
848    /// mid-stream once the budget trips, like
849    /// [`EventLog::replay_with_positions_bounded`]) in the same call — the
850    /// combined primitive neither of those two alone can express. Ten
851    /// 1,000-byte events; resuming at position 3 (skipping the first three,
852    /// 3,000 bytes) under a 2,500-byte budget must stop after the SECOND
853    /// event it actually reads (positions 3 and 4 — 2,000 bytes), never
854    /// reaching position 5, and never re-reading positions 0..3 at all.
855    #[test]
856    fn replay_from_with_positions_bounded_honors_both_start_and_the_byte_budget() {
857        let executor = deterministic::Runner::default();
858        executor.start(|context| async move {
859            let log = EventLog::open(context, EventLogConfig::for_partition("conv-tail-bounded"))
860                .await
861                .expect("open");
862            for i in 0..10u32 {
863                log.append(&Event::new(format!("k{i}"), vec![0u8; 1_000]))
864                    .await
865                    .expect("append");
866            }
867            log.commit().await.expect("commit");
868
869            let bounded = log
870                .replay_from_with_positions_bounded(3, 2_500)
871                .await
872                .expect("bounded tail replay");
873
874            assert!(
875                bounded.budget_exceeded,
876                "a 2,500-byte budget over a 7,000-byte tail (positions 3..10) must trip"
877            );
878            let positions: Vec<u64> = bounded.events.iter().map(|(p, _)| *p).collect();
879            assert_eq!(
880                positions,
881                vec![3, 4, 5],
882                "must resume at position 3 (never re-reading 0..3) and stop on the event that \
883                 CROSSES the 2,500 budget — that event is returned, matching \
884                 `EventLog::replay_with_positions_bounded`'s own accumulate-push-then-check \
885                 order, so the two differ only in where they start"
886            );
887            assert_eq!(
888                bounded.bytes_read, 3_000,
889                "bytes_read counts exactly the events actually returned, the budget-crossing \
890                 one included — never the tail's whole 7,000 bytes"
891            );
892        });
893    }
894
895    /// `end` is EXCLUSIVE, and the boundary is exact: a range ending at `n`
896    /// returns position `n - 1` and never `n`.
897    ///
898    /// The off-by-one this pins is the whole point of the primitive. A caller
899    /// fetching one turn locates `[turn_start, turn_end)` from an index and
900    /// must get that span and nothing adjacent — an inclusive end would leak
901    /// the first event of the NEXT turn into every fetch.
902    #[test]
903    fn replay_range_excludes_the_end_position_exactly() {
904        let executor = deterministic::Runner::default();
905        executor.start(|context| async move {
906            let log = EventLog::open(context, EventLogConfig::for_partition("conv-range-exact"))
907                .await
908                .expect("open");
909            for i in 0..10u32 {
910                log.append(&Event::new(format!("k{i}"), vec![0u8; 100]))
911                    .await
912                    .expect("append");
913            }
914            log.commit().await.expect("commit");
915
916            let bounded = log
917                .replay_range_with_positions_bounded(3, 6, u64::MAX)
918                .await
919                .expect("range replay");
920
921            let positions: Vec<u64> = bounded.events.iter().map(|(p, _)| *p).collect();
922            assert_eq!(
923                positions,
924                vec![3, 4, 5],
925                "[3, 6) is positions 3, 4, 5 — position 6 is outside the range and must not be \
926                 returned"
927            );
928            assert!(
929                !bounded.budget_exceeded,
930                "the RANGE stopped this replay, not the budget; conflating the two would tell a \
931                 caller its result was truncated when it is complete"
932            );
933            assert_eq!(
934                bounded.bytes_read, 300,
935                "the excluded end event must not spend the caller's byte budget either"
936            );
937        });
938    }
939
940    /// An `end` past the journal's tail clamps to the tail rather than
941    /// erroring — the upper-bound mirror of `start`'s own clamp up to the
942    /// pruning boundary. A caller holding a stale end position gets what
943    /// exists, not a failure.
944    #[test]
945    fn replay_range_end_past_the_tail_clamps_to_the_tail() {
946        let executor = deterministic::Runner::default();
947        executor.start(|context| async move {
948            let log = EventLog::open(context, EventLogConfig::for_partition("conv-range-clamp"))
949                .await
950                .expect("open");
951            for i in 0..4u32 {
952                log.append(&Event::new(format!("k{i}"), vec![0u8; 100]))
953                    .await
954                    .expect("append");
955            }
956            log.commit().await.expect("commit");
957
958            let bounded = log
959                .replay_range_with_positions_bounded(2, 9_999, u64::MAX)
960                .await
961                .expect("range replay");
962
963            let positions: Vec<u64> = bounded.events.iter().map(|(p, _)| *p).collect();
964            assert_eq!(positions, vec![2, 3], "clamped to the tail, not an error");
965            assert!(!bounded.budget_exceeded);
966        });
967    }
968
969    /// The LOWER-bound mirror of `replay_range_end_past_the_tail_clamps_to_the_tail`
970    /// just above: a `start` BELOW the partition's own pruning boundary
971    /// clamps UP to it (`start.max(bounds.start)`), rather than asking the
972    /// journal to replay from an already-pruned position. The journal
973    /// refuses that outright
974    /// (`commonware_storage::journal::contiguous::variable`'s
975    /// `Error::ItemPruned`, surfacing here as `EventLogError::Journal`), so
976    /// without the clamp this call would return `Err`, not the `Ok` this
977    /// test asserts.
978    ///
979    /// `items_per_section` is overridden to 1 for the same reason
980    /// `repair_replay_quarantining_recovers_events_after_a_corrupted_earlier_section`
981    /// overrides it: the default 1,024-item section makes the pruning
982    /// boundary land on a multiple of 1,024, far past anything a fast unit
983    /// test could plausibly append — with one item per section, pruning to
984    /// position 3 lands the boundary exactly on 3.
985    #[test]
986    fn replay_range_start_below_the_pruning_boundary_clamps_up_to_it() {
987        let executor = deterministic::Runner::default();
988        executor.start(|context| async move {
989            let mut cfg = EventLogConfig::for_partition("conv-range-pruned");
990            cfg.items_per_section = commonware_utils::NZU64!(1);
991            let log = EventLog::open(context, cfg).await.expect("open");
992            for i in 0..6u32 {
993                log.append(&Event::new(format!("k{i}"), vec![0u8; 100]))
994                    .await
995                    .expect("append");
996            }
997            log.commit().await.expect("commit");
998
999            // Prune positions 0..3 — the store's own pruning boundary, not a
1000            // caller-held watermark. Accessed via the underlying journal
1001            // directly: `EventLog` has no public prune wrapper of its own,
1002            // and this test module is the crate root's own child, so the
1003            // private `journal` field is reachable here.
1004            let pruned = log.journal.lock().await.prune(3).await.expect("prune");
1005            assert!(
1006                pruned,
1007                "positions 0..3 must actually have been pruned — otherwise the clamp below is \
1008                 never exercised"
1009            );
1010
1011            let bounded = log
1012                .replay_range_with_positions_bounded(1, 5, u64::MAX)
1013                .await
1014                .expect("a start below the pruning boundary must clamp up to it, never error");
1015
1016            let positions: Vec<u64> = bounded.events.iter().map(|(p, _)| *p).collect();
1017            assert_eq!(
1018                positions,
1019                vec![3, 4],
1020                "clamped up to the pruning boundary (3), not the caller's stale start (1) — and \
1021                 still respecting the requested end (5)"
1022            );
1023            assert!(!bounded.budget_exceeded);
1024        });
1025    }
1026
1027    /// An empty or inverted range yields an empty, non-exceeded
1028    /// [`BoundedReplay`] — never an error. Equal bounds are empty because the
1029    /// end is exclusive; an inverted range is empty rather than being
1030    /// silently reordered into a real one.
1031    #[test]
1032    fn replay_range_empty_and_inverted_are_empty_not_errors() {
1033        let executor = deterministic::Runner::default();
1034        executor.start(|context| async move {
1035            let log = EventLog::open(context, EventLogConfig::for_partition("conv-range-empty"))
1036                .await
1037                .expect("open");
1038            for i in 0..5u32 {
1039                log.append(&Event::new(format!("k{i}"), vec![0u8; 100]))
1040                    .await
1041                    .expect("append");
1042            }
1043            log.commit().await.expect("commit");
1044
1045            for (start, end, why) in [
1046                (2u64, 2u64, "equal bounds are empty — the end is exclusive"),
1047                (4, 1, "an inverted range is empty, never reordered"),
1048                (99, 200, "a range entirely past the tail is empty"),
1049            ] {
1050                let bounded = log
1051                    .replay_range_with_positions_bounded(start, end, u64::MAX)
1052                    .await
1053                    .expect("range replay");
1054                assert!(bounded.events.is_empty(), "{why}");
1055                assert_eq!(bounded.bytes_read, 0, "{why}");
1056                assert!(
1057                    !bounded.budget_exceeded,
1058                    "an empty range must not report a tripped budget: {why}"
1059                );
1060            }
1061        });
1062    }
1063
1064    /// The byte cap still applies WITHIN a range, and stops the replay before
1065    /// the range does — so a caller can tell "the range ended" from "I ran out
1066    /// of budget" by `budget_exceeded` alone.
1067    #[test]
1068    fn replay_range_byte_cap_trips_inside_the_range() {
1069        let executor = deterministic::Runner::default();
1070        executor.start(|context| async move {
1071            let log = EventLog::open(context, EventLogConfig::for_partition("conv-range-budget"))
1072                .await
1073                .expect("open");
1074            for i in 0..10u32 {
1075                log.append(&Event::new(format!("k{i}"), vec![0u8; 1_000]))
1076                    .await
1077                    .expect("append");
1078            }
1079            log.commit().await.expect("commit");
1080
1081            let bounded = log
1082                .replay_range_with_positions_bounded(2, 9, 2_500)
1083                .await
1084                .expect("range replay");
1085
1086            assert!(
1087                bounded.budget_exceeded,
1088                "a 2,500-byte budget over a 7,000-byte range must trip"
1089            );
1090            let positions: Vec<u64> = bounded.events.iter().map(|(p, _)| *p).collect();
1091            assert_eq!(
1092                positions,
1093                vec![2, 3, 4],
1094                "stops on the event that CROSSES the budget, which is returned — the same \
1095                 accumulate-push-then-check order the sibling bounded replays use, so the three \
1096                 differ only in where they start and stop"
1097            );
1098            assert_eq!(bounded.bytes_read, 3_000);
1099        });
1100    }
1101
1102    /// The empty-range case: a `start` at or past the partition's end yields
1103    /// an empty, non-exceeded [`BoundedReplay`] — never an error, and never a
1104    /// spurious `budget_exceeded` — mirroring
1105    /// [`EventLog::replay_from_with_positions`]'s own empty-range rule.
1106    #[test]
1107    fn replay_from_with_positions_bounded_past_the_end_is_empty_not_exceeded() {
1108        let executor = deterministic::Runner::default();
1109        executor.start(|context| async move {
1110            let log = EventLog::open(
1111                context,
1112                EventLogConfig::for_partition("conv-tail-bounded-empty"),
1113            )
1114            .await
1115            .expect("open");
1116            for i in 0..3u32 {
1117                log.append(&Event::new(format!("k{i}"), vec![0u8; 1_000]))
1118                    .await
1119                    .expect("append");
1120            }
1121            log.commit().await.expect("commit");
1122
1123            let bounded = log
1124                .replay_from_with_positions_bounded(99, 1)
1125                .await
1126                .expect("bounded tail replay past the end");
1127
1128            assert!(bounded.events.is_empty());
1129            assert_eq!(bounded.bytes_read, 0);
1130            assert!(
1131                !bounded.budget_exceeded,
1132                "an empty replay must never report the budget as exceeded"
1133            );
1134        });
1135    }
1136
1137    /// A healthy log's quarantining replay reports every event decoded and
1138    /// nothing quarantined — the repair primitive's happy path.
1139    #[test]
1140    fn repair_replay_quarantining_reports_nothing_bad_on_a_healthy_log() {
1141        let executor = deterministic::Runner::default();
1142        executor.start(|context| async move {
1143            let log = EventLog::open(context, EventLogConfig::for_partition("conv-healthy"))
1144                .await
1145                .expect("open");
1146            for i in 0..4u8 {
1147                log.append(&Event::new(format!("k{i}"), vec![i]))
1148                    .await
1149                    .expect("append");
1150            }
1151            log.commit().await.expect("commit");
1152
1153            let (ok, quarantined) = log.replay_quarantining().await.expect("quarantine replay");
1154            assert_eq!(ok.len(), 4);
1155            assert!(quarantined.is_empty());
1156            assert_eq!(ok, log.replay_with_positions().await.expect("replay"));
1157        });
1158    }
1159
1160    /// A freshly opened log is empty and replays nothing.
1161    #[test]
1162    fn empty_log_replays_empty() {
1163        let executor = deterministic::Runner::default();
1164        executor.start(|context| async move {
1165            let log = EventLog::open(context, EventLogConfig::for_partition("conv-empty"))
1166                .await
1167                .expect("open");
1168            assert!(log.is_empty().await);
1169            assert_eq!(log.len().await, 0);
1170            assert!(log.replay().await.expect("replay").is_empty());
1171        });
1172    }
1173
1174    /// Events appended, committed, and re-opened from the same partition
1175    /// replay identically — persistence survives dropping the handle.
1176    /// Destroy removes the partition wholesale: a reopen starts empty, and
1177    /// appends after the reopen work normally (no resurrection of old
1178    /// events). The #216 erasure primitive.
1179    #[test]
1180    fn destroy_removes_the_partition_and_reopen_is_empty() {
1181        let executor = deterministic::Runner::default();
1182        executor.start(|context| async move {
1183            let cfg = EventLogConfig::for_partition("destroy-me");
1184            let log = EventLog::open(context.child("first"), cfg.clone())
1185                .await
1186                .expect("open");
1187            log.append(&Event::new("k", b"payload".to_vec()))
1188                .await
1189                .expect("append");
1190            log.commit().await.expect("commit");
1191            log.destroy().await.expect("destroy");
1192
1193            let log = EventLog::open(context.child("second"), cfg)
1194                .await
1195                .expect("reopen");
1196            assert!(
1197                log.replay().await.expect("replay").is_empty(),
1198                "a destroyed partition must reopen empty"
1199            );
1200            let pos = log
1201                .append(&Event::new("k2", b"fresh".to_vec()))
1202                .await
1203                .expect("append after destroy");
1204            assert_eq!(pos, 0, "the fresh partition starts at position zero");
1205        });
1206    }
1207
1208    /// Pins the `sync()` durability contract: after a `sync`, reopen needs no
1209    /// recovery work and returns exactly the synced events. (`commit()`
1210    /// alone is a distinct, weaker contract — see
1211    /// `reopen_recovers_committed_events_after_commit_only` below, which
1212    /// pins that one instead.)
1213    #[test]
1214    fn reopen_recovers_synced_events() {
1215        let executor = deterministic::Runner::default();
1216        executor.start(|context| async move {
1217            let cfg = EventLogConfig::for_partition("conv-reopen");
1218
1219            {
1220                // Distinct supervision-tree label per open simulates a separate
1221                // process (the deterministic runtime's metric registry is
1222                // shared for the whole run, so re-registering under the same
1223                // label panics — a real restart gets a fresh registry).
1224                let log = EventLog::open(context.child("first"), cfg.clone())
1225                    .await
1226                    .expect("open first");
1227                log.append(&Event::new("user_msg", b"persist me".to_vec()))
1228                    .await
1229                    .expect("append");
1230                log.sync().await.expect("sync");
1231            } // drop the handle
1232
1233            let log = EventLog::open(context.child("second"), cfg)
1234                .await
1235                .expect("reopen");
1236            let replayed = log.replay().await.expect("replay");
1237            assert_eq!(replayed.len(), 1);
1238            assert_eq!(replayed[0].kind, "user_msg");
1239            assert_eq!(replayed[0].payload, b"persist me".to_vec());
1240        });
1241    }
1242
1243    /// Pins the explicit 2026.7 `commit()`-only durability contract: every
1244    /// production batch boundary (`append_batch_one` in `polyc-eventlog-host`)
1245    /// ends in `commit()`, never `sync()`. Upstream's `commit()` fsyncs dirty data blobs but does not
1246    /// advance the offsets recovery watermark, so reopen after a commit-only
1247    /// crash may perform recovery work — rebuilding the missing offset
1248    /// entries by replaying data from the recovery anchor — rather than
1249    /// finding a synced index ready to go. This test crashes deliberately
1250    /// between `commit()` and any `sync()` (the handle is simply dropped, on
1251    /// a real OS-backed runtime so the process boundary is genuine, not
1252    /// simulated in-memory state) and asserts the committed events are still
1253    /// fully recovered on reopen, in order, with appends able to continue
1254    /// past them. This catches a regression where committed bytes never
1255    /// leave the application-level tail buffer at all (an in-process reopen
1256    /// can't tell a real `fsync` apart from a plain unsynced `write()`, since
1257    /// the OS page cache survives process death, not just power loss) or
1258    /// where the offsets-rebuild recovery path breaks; the fsync guarantee
1259    /// itself rests on reading upstream's `commit()` source, not on this
1260    /// test.
1261    #[test]
1262    fn reopen_recovers_committed_events_after_commit_only() {
1263        use commonware_runtime::{Runner as _, tokio as cw_tokio};
1264
1265        let dir =
1266            std::env::temp_dir().join(format!("polyc-eventlog-commit-only-{}", std::process::id()));
1267        let _ = std::fs::remove_dir_all(&dir);
1268        let cfg = EventLogConfig::for_partition("conv-commit-only");
1269
1270        let write_cfg = cfg.clone();
1271        let write_runner =
1272            cw_tokio::Runner::new(cw_tokio::Config::default().with_storage_directory(dir.clone()));
1273        write_runner.start(move |context| async move {
1274            let log = EventLog::open(context, write_cfg).await.expect("open");
1275            log.append(&Event::new("user_msg", b"one".to_vec()))
1276                .await
1277                .expect("append 0");
1278            log.append(&Event::new("output_msg", b"two".to_vec()))
1279                .await
1280                .expect("append 1");
1281            log.append(&Event::new("tool_call", b"three".to_vec()))
1282                .await
1283                .expect("append 2");
1284            log.commit().await.expect("commit");
1285            // No `sync()` — the runner ends here and the handle drops,
1286            // simulating a crash immediately after the commit-only durability
1287            // point every production batch boundary actually uses.
1288        });
1289
1290        let read_cfg = cfg;
1291        let read_runner =
1292            cw_tokio::Runner::new(cw_tokio::Config::default().with_storage_directory(dir.clone()));
1293        read_runner.start(move |context| async move {
1294            let log = EventLog::open(context, read_cfg)
1295                .await
1296                .expect("reopen recovers committed-only data with no sync");
1297            let replayed = log.replay().await.expect("replay");
1298            assert_eq!(replayed.len(), 3, "all three committed events survive");
1299            assert_eq!(replayed[0].kind, "user_msg");
1300            assert_eq!(replayed[0].payload, b"one".to_vec());
1301            assert_eq!(replayed[1].kind, "output_msg");
1302            assert_eq!(replayed[1].payload, b"two".to_vec());
1303            assert_eq!(replayed[2].kind, "tool_call");
1304            assert_eq!(replayed[2].payload, b"three".to_vec());
1305
1306            let pos = log
1307                .append(&Event::new("recovered", b"still writable".to_vec()))
1308                .await
1309                .expect("append continues after commit-only recovery");
1310            assert_eq!(pos, 3, "the new append continues at position 3");
1311        });
1312
1313        let _ = std::fs::remove_dir_all(&dir);
1314    }
1315
1316    /// Deterministic-runtime sibling of
1317    /// `reopen_recovers_committed_events_after_commit_only`: the crate
1318    /// documents both the deterministic and tokio runtimes, so the
1319    /// commit-only durability contract is pinned on both. Cheaper than the
1320    /// tokio version (no real filesystem I/O) but does not exercise a real
1321    /// OS-backed crash boundary — that's what the tokio sibling is for.
1322    #[test]
1323    fn reopen_recovers_committed_events_after_commit_only_deterministic() {
1324        let executor = deterministic::Runner::default();
1325        executor.start(|context| async move {
1326            let cfg = EventLogConfig::for_partition("conv-reopen-commit-only");
1327
1328            {
1329                let log = EventLog::open(context.child("first"), cfg.clone())
1330                    .await
1331                    .expect("open first");
1332                log.append(&Event::new("user_msg", b"persist me".to_vec()))
1333                    .await
1334                    .expect("append");
1335                log.commit().await.expect("commit");
1336                // No `sync()` before drop.
1337            }
1338
1339            let log = EventLog::open(context.child("second"), cfg)
1340                .await
1341                .expect("reopen");
1342            let replayed = log.replay().await.expect("replay");
1343            assert_eq!(replayed.len(), 1);
1344            assert_eq!(replayed[0].kind, "user_msg");
1345            assert_eq!(replayed[0].payload, b"persist me".to_vec());
1346        });
1347    }
1348
1349    /// Determinism / reproducibility: two independent deterministic runs with
1350    /// the same seeded program produce the same auditor state. This is the
1351    /// property replay tests rely on (mirrors the runtime spike's
1352    /// `auditor().state()` assertion).
1353    #[test]
1354    fn deterministic_runs_are_reproducible() {
1355        fn run() -> String {
1356            let executor = deterministic::Runner::default();
1357            executor.start(|context| async move {
1358                // `Context` is no longer `Clone` in 2026.5 — use `child`
1359                // to produce a sibling context for the log while keeping
1360                // the parent available for the `auditor()` read at the end.
1361                let log =
1362                    EventLog::open(context.child("det"), EventLogConfig::for_partition("det"))
1363                        .await
1364                        .expect("open");
1365                for i in 0..6u8 {
1366                    log.append(&Event::new(format!("kind-{i}"), vec![i; i as usize]))
1367                        .await
1368                        .expect("append");
1369                }
1370                log.commit().await.expect("commit");
1371                let _ = log.replay().await.expect("replay");
1372                context.auditor().state()
1373            })
1374        }
1375
1376        let first = run();
1377        let second = run();
1378        assert_eq!(first, second, "deterministic runtime must be reproducible");
1379    }
1380
1381    /// The repair primitive (`#799`), on real on-disk files: `EventLog::open`
1382    /// only re-validates its FINAL (still-active) section on open — see the
1383    /// crate docs — so a corrupted item in an EARLIER, already-closed
1384    /// section is invisible to that recovery and `open` succeeds anyway. A
1385    /// plain streaming [`EventLog::replay`] then HARD FAILS as soon as it
1386    /// streams past the corrupted item (commonware 2026.7 tightened this:
1387    /// 2026.5 silently dropped the corrupted item with no error signal at
1388    /// all — an even worse gap, since a caller saw a shorter-than-expected
1389    /// transcript with no indication anything was missing). Because the
1390    /// corrupted position here is the earliest one, the whole replay
1391    /// aborts and the later, undamaged events (1 and 2) are inaccessible
1392    /// through this path too. [`EventLog::replay_quarantining`] reads each
1393    /// position independently through the journal's offset index,
1394    /// correctly reports the corrupted position (with the underlying
1395    /// storage error), and still recovers everything after it — the
1396    /// primitive this repair path exists for.
1397    #[test]
1398    fn repair_replay_quarantining_recovers_events_after_a_corrupted_earlier_section() {
1399        use commonware_runtime::{Runner as _, tokio as cw_tokio};
1400
1401        let dir = std::env::temp_dir().join(format!(
1402            "polyc-eventlog-repair-earlier-section-{}",
1403            std::process::id()
1404        ));
1405        let _ = std::fs::remove_dir_all(&dir);
1406
1407        // One event per section, so item 0 lands in a section that is no
1408        // longer "final" (and so no longer re-validated) once items 1 and 2
1409        // are appended after it.
1410        let mut cfg = EventLogConfig::for_partition("conv-repair");
1411        cfg.items_per_section = commonware_utils::NZU64!(1);
1412
1413        let write_cfg = cfg.clone();
1414        let write_runner =
1415            cw_tokio::Runner::new(cw_tokio::Config::default().with_storage_directory(dir.clone()));
1416        write_runner.start(move |context| async move {
1417            let log = EventLog::open(context, write_cfg).await.expect("open");
1418            log.append(&Event::new("user_msg", vec![b'A'; 20]))
1419                .await
1420                .expect("append 0");
1421            log.append(&Event::new("output_msg", vec![b'B'; 20]))
1422                .await
1423                .expect("append 1");
1424            log.append(&Event::new("tool_call", vec![b'C'; 20]))
1425                .await
1426                .expect("append 2");
1427            log.sync().await.expect("sync");
1428        });
1429
1430        // Corrupt section 0's item at the first byte of its `kind` STRING
1431        // content (byte offset 10: 8-byte section magic header + 1-byte
1432        // outer item-length prefix + 1-byte inner kind-length prefix),
1433        // leaving both length prefixes intact so the outer framing still
1434        // parses fine. `0xFF` is not a valid UTF-8 lead byte in any
1435        // position, and [`Event`]'s decode is strict (`String::from_utf8`,
1436        // not lossy), so this is a genuine decode failure — simulating bit
1437        // rot or tampering in an already-closed section — rather than
1438        // silently decoding into different-but-still-valid content (the
1439        // kind of tamper only the MMR check in `polyc_eventlog::integrity`
1440        // catches, not this decode-level quarantine).
1441        let data_file = dir.join("conv-repair_data").join("0000000000000000");
1442        let mut bytes = std::fs::read(&data_file).expect("read section 0");
1443        bytes[10] = 0xFF;
1444        std::fs::write(&data_file, &bytes).expect("write corrupted section 0");
1445
1446        let read_cfg = cfg;
1447        let read_runner =
1448            cw_tokio::Runner::new(cw_tokio::Config::default().with_storage_directory(dir.clone()));
1449        read_runner.start(move |context| async move {
1450            let log = EventLog::open(context, read_cfg)
1451                .await
1452                .expect("reopen succeeds: only the final section is re-validated on open");
1453
1454            let plain_err = log
1455                .replay()
1456                .await
1457                .expect_err("a corrupted item makes the whole streamed replay fail");
1458            assert!(
1459                matches!(plain_err, EventLogError::Journal(_)),
1460                "unexpected error variant: {plain_err:?}"
1461            );
1462
1463            let (ok, quarantined) = log
1464                .replay_quarantining()
1465                .await
1466                .expect("quarantining replay reports positions, not an Err");
1467            assert_eq!(quarantined.len(), 1, "exactly the corrupted item");
1468            assert_eq!(quarantined[0].position, 0);
1469            assert_eq!(
1470                ok.len(),
1471                2,
1472                "the two events after the corrupted one recover"
1473            );
1474            assert_eq!(ok[0], (1, Event::new("output_msg", vec![b'B'; 20])));
1475            assert_eq!(ok[1], (2, Event::new("tool_call", vec![b'C'; 20])));
1476        });
1477
1478        let _ = std::fs::remove_dir_all(&dir);
1479    }
1480
1481    /// Torn-write recovery (`#799`): a crash mid-append leaves a partition
1482    /// whose on-disk section no longer matches what the offset index
1483    /// expects. Reopening must not error or panic, replay must return
1484    /// successfully (never hard-fail the whole partition open over a crash
1485    /// artifact), and the partition must accept new appends afterward — the
1486    /// durability property every append-only log needs to survive a real
1487    /// power loss / OOM-kill: a torn write degrades the log, it does not
1488    /// permanently brick the conversation.
1489    #[test]
1490    fn torn_write_truncated_journal_reopens_and_replays() {
1491        use commonware_runtime::{Runner as _, tokio as cw_tokio};
1492
1493        let dir =
1494            std::env::temp_dir().join(format!("polyc-eventlog-torn-write-{}", std::process::id()));
1495        let _ = std::fs::remove_dir_all(&dir);
1496        let cfg = EventLogConfig::for_partition("conv-torn");
1497
1498        let write_cfg = cfg.clone();
1499        let write_runner =
1500            cw_tokio::Runner::new(cw_tokio::Config::default().with_storage_directory(dir.clone()));
1501        write_runner.start(move |context| async move {
1502            let log = EventLog::open(context, write_cfg).await.expect("open");
1503            log.append(&Event::new("user_msg", vec![b'A'; 20]))
1504                .await
1505                .expect("append 0");
1506            log.append(&Event::new("output_msg", vec![b'B'; 20]))
1507                .await
1508                .expect("append 1");
1509            log.append(&Event::new("tool_call", vec![b'C'; 20]))
1510                .await
1511                .expect("append 2");
1512            log.sync().await.expect("sync");
1513        });
1514
1515        // Simulate a crash mid-write: the blob's storage space is
1516        // pre-allocated well beyond the ~107 bytes the three items occupy,
1517        // so a real crash leaves the file at its full pre-allocated length
1518        // with an un-written (zero) tail rather than a shorter file. Locate
1519        // item 2's payload by content (rather than hand-deriving on-disk
1520        // item-framing offsets) and zero everything from partway through it
1521        // onward — a torn (incomplete) final item, exactly what a crash
1522        // mid-append-of-item-2 leaves behind.
1523        let data_file = dir.join("conv-torn_data").join("0000000000000000");
1524        let full = std::fs::read(&data_file).expect("read section 0");
1525        let marker = [b'C'; 20];
1526        let payload_start = full
1527            .windows(marker.len())
1528            .position(|w| w == marker)
1529            .expect("item 2's payload is present in the untruncated section");
1530        let mut corrupted = full;
1531        for b in &mut corrupted[payload_start + 10..] {
1532            *b = 0;
1533        }
1534        std::fs::write(&data_file, &corrupted).expect("simulate a torn write");
1535
1536        let read_cfg = cfg;
1537        let read_runner =
1538            cw_tokio::Runner::new(cw_tokio::Config::default().with_storage_directory(dir.clone()));
1539        read_runner.start(move |context| async move {
1540            let log = EventLog::open(context, read_cfg)
1541                .await
1542                .expect("reopen recovers from a torn tail without erroring");
1543            let events = log
1544                .replay()
1545                .await
1546                .expect("replay succeeds (never hard-fails) after a torn write");
1547            // The engine's own crash-recovery decides how much of the torn
1548            // section it can trust; this pins the property that matters for
1549            // durability — recovery is conservative (it never returns
1550            // content associated with an item it couldn't fully validate),
1551            // and it never returns more than what was actually written.
1552            assert!(
1553                events.len() <= 3,
1554                "recovery must never fabricate events beyond what was appended"
1555            );
1556            for event in &events {
1557                assert!(
1558                    [
1559                        Event::new("user_msg", vec![b'A'; 20]),
1560                        Event::new("output_msg", vec![b'B'; 20]),
1561                    ]
1562                    .contains(event),
1563                    "recovery must never return the torn (never-fully-written) third item"
1564                );
1565            }
1566
1567            // The partition is not permanently bricked: it still accepts
1568            // new appends after the crash.
1569            let pos = log
1570                .append(&Event::new("recovered", b"still writable".to_vec()))
1571                .await
1572                .expect("the partition accepts appends again after torn-write recovery");
1573            log.commit().await.expect("commit after recovery");
1574            assert_eq!(
1575                pos,
1576                events.len() as u64,
1577                "the new append continues from wherever recovery left off"
1578            );
1579        });
1580
1581        let _ = std::fs::remove_dir_all(&dir);
1582    }
1583}