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