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 a
40//! tokio control plane hosts it on a dedicated thread. This crate stays
41//! runtime-agnostic and leaves that hosting decision to the caller.
42//!
43//! # Conversation scoping
44//!
45//! One [`EventLog`] instance maps to one conversation's log, identified by the
46//! storage *partition* name passed to [`EventLog::open`] (derive it from the
47//! conversation id, e.g. `format!("conv-{uid}")`). Distinct conversations use
48//! distinct partitions and so are fully isolated on disk.
49//!
50//! # Example
51//!
52//! <!--
53//! Marked `ignore`, not run: a runnable doctest statically links the entire
54//! Commonware storage stack into its own dedicated binary, and that link
55//! OOMs/bus-errors CI's linker. The example is mirrored verbatim by the
56//! `doc_example_open_append_replay` unit test, which folds into the crate's
57//! existing (already-linked) test binary rather than adding a second heavy
58//! link — so the snippet stays verified without the extra link unit.
59//! -->
60//! ```ignore
61//! use commonware_runtime::{deterministic, Runner};
62//! use polyc_eventlog::{Event, EventLog, EventLogConfig};
63//!
64//! let executor = deterministic::Runner::default();
65//! executor.start(|context| async move {
66//!     let log = EventLog::open(context, EventLogConfig::for_partition("conv-1"))
67//!         .await
68//!         .expect("open log");
69//!
70//!     log.append(&Event::new("user_msg", b"hello".to_vec())).await.unwrap();
71//!     log.append(&Event::new("tool_call", b"\x01\x02".to_vec())).await.unwrap();
72//!     log.commit().await.unwrap();
73//!
74//!     let events = log.replay().await.unwrap();
75//!     assert_eq!(events.len(), 2);
76//!     assert_eq!(events[0].kind, "user_msg");
77//! });
78//! ```
79
80pub mod checkpoint;
81pub mod error;
82pub mod event;
83pub mod integrity;
84mod metrics;
85pub mod nav;
86pub mod taint;
87
88pub use checkpoint::EventCountCheckpoint;
89pub use error::EventLogError;
90pub use event::{Event, EventCfg};
91pub use integrity::{
92    IntegrityError, MMR_SIGNED_ROOT_KIND, extend_and_sign, rebuild_from_events, verify_replay,
93};
94pub use taint::{
95    GrantedCapabilities, TrifectaLegs, TrustTag, any_untrusted, any_untrusted_excluding,
96    trifecta_legs,
97};
98
99/// Force-register this crate's Prometheus append-latency histogram.
100///
101/// Makes it appear in a `/metrics` scrape immediately — before any event has
102/// been appended. Idempotent (backed by a `OnceLock`); call once at process
103/// startup, alongside any other crate's own `init_metrics`.
104pub fn init_metrics() {
105    metrics::force();
106}
107
108use commonware_runtime::buffer::paged::CacheRef;
109use commonware_storage::journal::contiguous::{Contiguous as _, variable};
110use commonware_utils::sync::AsyncMutex;
111use commonware_utils::{NZU16, NZU64, NZUsize};
112use futures::StreamExt as _;
113use std::num::{NonZeroU16, NonZeroU64, NonZeroUsize};
114
115/// Buffer size (in items) for the replay stream from the underlying journal.
116const REPLAY_BUFFER: NonZeroUsize = NZUsize!(1024);
117
118/// One event [`EventLog::replay_quarantining`] could not decode, and why.
119#[derive(Debug, Clone)]
120pub struct QuarantinedItem {
121    /// Journal position of the corrupted item.
122    pub position: u64,
123    /// The underlying decode/storage error's `Display` text.
124    pub error: String,
125}
126
127/// Configuration for opening an [`EventLog`].
128///
129/// Most fields mirror the underlying journal's tuning knobs and have sensible
130/// defaults via [`EventLogConfig::for_partition`]; only the `partition`
131/// (which conversation's log) is mandatory.
132#[derive(Debug, Clone)]
133pub struct EventLogConfig {
134    /// Storage partition name — one per conversation. Sub-partitions for the
135    /// data and offset indexes are derived from it by the journal.
136    pub partition: String,
137
138    /// Number of events stored per journal section. Sections roll over at this
139    /// count; only the final (partial) section is replayed on open to recover
140    /// the exact size. **Immutable once a partition exists** — changing it
141    /// across restarts corrupts the log.
142    pub items_per_section: NonZeroU64,
143
144    /// Decode-time bounds applied to each event during [`EventLog::replay`].
145    pub event_cfg: EventCfg,
146
147    /// Page size for the read cache over the underlying storage blobs.
148    pub page_size: NonZeroU16,
149
150    /// Page cache capacity, in pages.
151    pub page_cache_pages: NonZeroUsize,
152
153    /// Per-section write buffer size, in bytes.
154    pub write_buffer: NonZeroUsize,
155}
156
157impl EventLogConfig {
158    /// Build a config for `partition` with defaults for every other field.
159    ///
160    /// Defaults: 1024 events per section, [`EventCfg::DEFAULT`] decode bounds,
161    /// a 16 KiB page size with a 64-page cache, and a 64 KiB write buffer.
162    #[must_use]
163    pub fn for_partition(partition: impl Into<String>) -> Self {
164        Self {
165            partition: partition.into(),
166            items_per_section: NZU64!(1024),
167            event_cfg: EventCfg::DEFAULT,
168            page_size: NZU16!(16384),
169            page_cache_pages: NZUsize!(64),
170            write_buffer: NZUsize!(65536),
171        }
172    }
173}
174
175/// An append-only, ordered log of conversation [`Event`]s.
176///
177/// Generic over a [`commonware_storage::Context`] so the same code runs on the
178/// tokio backend in production and the deterministic backend in tests. See the
179/// crate-level docs for the ordering contract and runtime-coexistence notes.
180pub struct EventLog<E>
181where
182    E: commonware_storage::Context + commonware_runtime::BufferPooler,
183{
184    /// The journal's append/commit/sync/snapshot operations take `&mut self`
185    /// (commonware 2026.7's journal API), but `EventLog` hands out a single
186    /// shared handle to a control-plane host, forensics reads, and the
187    /// workqueue alike. This lock recovers that shared surface; it is not a
188    /// new concurrency model — a `Lease` at a higher layer already serializes
189    /// writers per conversation, so contention here is only ever between the
190    /// lone writer and concurrent readers.
191    journal: AsyncMutex<variable::Journal<E, Event>>,
192}
193
194impl<E> EventLog<E>
195where
196    E: commonware_storage::Context + commonware_runtime::BufferPooler,
197{
198    /// Open (creating if absent, recovering if present) the event log for a
199    /// conversation on the given runtime `context`.
200    ///
201    /// On open the journal replays only its final section to recover the exact
202    /// append size, and self-heals any data/offset divergence left by a crash.
203    ///
204    /// # Errors
205    ///
206    /// Returns [`EventLogError::Journal`] if the underlying storage fails to
207    /// initialize or recover the journal.
208    pub async fn open(context: E, config: EventLogConfig) -> Result<Self, EventLogError> {
209        let page_cache = CacheRef::from_pooler(&context, config.page_size, config.page_cache_pages);
210        let journal_cfg = variable::Config {
211            partition: config.partition,
212            items_per_section: config.items_per_section,
213            compression: None,
214            codec_config: config.event_cfg,
215            page_cache,
216            write_buffer: config.write_buffer,
217        };
218        let journal = variable::Journal::init(context, journal_cfg).await?;
219        Ok(Self {
220            journal: AsyncMutex::new(journal),
221        })
222    }
223
224    /// Destroy the log: consume the handle and REMOVE the partition's
225    /// underlying blobs (data + offsets) from storage. The erasure primitive
226    /// (#216): after this, a fresh [`EventLog::open`] of the same partition
227    /// starts empty.
228    ///
229    /// Deliberately consuming — a destroyed log has no valid further
230    /// operation, and the caller must drop every other handle first (the
231    /// control plane's host serializes this through its single command
232    /// loop and evicts its cache entry before destroying).
233    ///
234    /// # Errors
235    ///
236    /// Returns [`EventLogError::Journal`] if the underlying blob removal
237    /// fails.
238    pub async fn destroy(self) -> Result<(), EventLogError> {
239        Ok(self.journal.into_inner().destroy().await?)
240    }
241
242    /// Append a single event, returning the position the journal assigned it.
243    ///
244    /// Positions are strictly increasing from `0` and define replay order. The
245    /// caller must append in conversation order (turn, then seq within a turn)
246    /// for replay to reflect that order.
247    ///
248    /// Takes `&self`: [`EventLog`]'s own lock (see the struct-level doc)
249    /// recovers a shared reference over the journal's `&mut self` ops.
250    ///
251    /// Appends are buffered for durability; call [`EventLog::commit`] (or
252    /// [`EventLog::sync`]) to guarantee they survive a crash.
253    ///
254    /// # Errors
255    ///
256    /// Returns [`EventLogError::Journal`] if the item cannot be encoded or the
257    /// underlying storage write fails.
258    pub async fn append(&self, event: &Event) -> Result<u64, EventLogError> {
259        let start = std::time::Instant::now(); // determinism-allow: metrics-only timing, never persisted or replayed
260        let result = self.journal.lock().await.append(event).await;
261        metrics::record_append(result.is_ok(), start.elapsed());
262        Ok(result?)
263    }
264
265    /// Number of events appended to the log (the position the *next* append
266    /// will receive). Not reduced by pruning.
267    pub async fn len(&self) -> u64 {
268        self.journal.lock().await.size()
269    }
270
271    /// Whether the log has no appended events.
272    pub async fn is_empty(&self) -> bool {
273        self.len().await == 0
274    }
275
276    /// Replay every event in append order, each paired with its position.
277    ///
278    /// The returned `Vec` is ordered by position ascending (`0, 1, 2, …`),
279    /// which is conversation order. Each tuple is `(position, event)`.
280    ///
281    /// This collects the full log into memory; it is intended for rebuilding
282    /// in-memory conversation state on resume. For very large logs a streaming
283    /// variant could be added later (the journal exposes a `Stream`), but the
284    /// foundational API materializes for simplicity.
285    ///
286    /// # Errors
287    ///
288    /// Returns [`EventLogError::Journal`] if the journal cannot start the replay
289    /// stream or if decoding any stored event fails.
290    // `snapshot()` returns an owned, `'static` reader (unlike the borrowed
291    // `reader()` of commonware 2026.5), so the journal lock is released as
292    // soon as the snapshot is taken — the stream below never holds it.
293    pub async fn replay_with_positions(&self) -> Result<Vec<(u64, Event)>, EventLogError> {
294        let reader = self.journal.lock().await.snapshot().await?;
295        let start = reader.bounds().start;
296        let stream = reader.replay(start, REPLAY_BUFFER).await?;
297        futures::pin_mut!(stream);
298        let mut out = Vec::new();
299        while let Some(item) = stream.next().await {
300            out.push(item?);
301        }
302        Ok(out)
303    }
304
305    /// Replay events in append order starting at position `start`, each paired
306    /// with its position.
307    ///
308    /// The journal is position-indexed, so resuming at an offset is cheap — the
309    /// reader seeks to `start` rather than scanning from zero. This is what lets
310    /// a caller replay only the tail since a durable checkpoint instead of
311    /// re-reading the whole partition every time. `start` is clamped up to the
312    /// pruning boundary, and a `start` at or past the end yields an empty `Vec`.
313    /// Returned tuples are `(position, event)` for positions in
314    /// `[max(start, bounds.start), len)`, ascending.
315    ///
316    /// # Errors
317    ///
318    /// Returns [`EventLogError::Journal`] if the journal cannot start the replay
319    /// stream or if decoding any stored event fails.
320    // See [`EventLog::replay_with_positions`]: the snapshot is owned, so the
321    // journal lock is released before the stream is consumed.
322    pub async fn replay_from_with_positions(
323        &self,
324        start: u64,
325    ) -> Result<Vec<(u64, Event)>, EventLogError> {
326        let reader = self.journal.lock().await.snapshot().await?;
327        let bounds = reader.bounds();
328        let from = start.max(bounds.start);
329        if from >= bounds.end {
330            return Ok(Vec::new());
331        }
332        let stream = reader.replay(from, REPLAY_BUFFER).await?;
333        futures::pin_mut!(stream);
334        let mut out = Vec::new();
335        while let Some(item) = stream.next().await {
336            out.push(item?);
337        }
338        Ok(out)
339    }
340
341    /// Replay every event in append order, discarding positions.
342    ///
343    /// Convenience over [`EventLog::replay_with_positions`] for callers that
344    /// only need the ordered events.
345    ///
346    /// # Errors
347    ///
348    /// Returns [`EventLogError::Journal`] on the same conditions as
349    /// [`EventLog::replay_with_positions`].
350    pub async fn replay(&self) -> Result<Vec<Event>, EventLogError> {
351        Ok(self
352            .replay_with_positions()
353            .await?
354            .into_iter()
355            .map(|(_pos, event)| event)
356            .collect())
357    }
358
359    /// Replay events from position `start` in append order, discarding
360    /// positions. Convenience over [`EventLog::replay_from_with_positions`].
361    ///
362    /// # Errors
363    ///
364    /// Returns [`EventLogError::Journal`] on the same conditions as
365    /// [`EventLog::replay_from_with_positions`].
366    pub async fn replay_from(&self, start: u64) -> Result<Vec<Event>, EventLogError> {
367        Ok(self
368            .replay_from_with_positions(start)
369            .await?
370            .into_iter()
371            .map(|(_pos, event)| event)
372            .collect())
373    }
374
375    /// Replay every event in append order, skipping any position whose item
376    /// cannot be decoded rather than aborting the whole replay.
377    ///
378    /// [`EventLog::replay_with_positions`] stops at the first bad item (the
379    /// backup/DR gap #799 tracks: one corrupted event permanently locks a
380    /// conversation out of replay). This reads each position independently
381    /// through the journal's position index — a corrupted item's neighbors
382    /// don't depend on decoding it — so it recovers everything readable and
383    /// reports the rest as [`QuarantinedItem`]s. This is the primitive a
384    /// `conversation repair` operation uses to drop only the unreadable
385    /// event(s) and let the rest of the log replay again; it is otherwise
386    /// intended for that recovery path, not routine replay (one read per
387    /// position, versus one streamed pass).
388    ///
389    /// # Errors
390    ///
391    /// Returns [`EventLogError::Journal`] if the journal cannot report its
392    /// own bounds. A per-item decode failure is reported in the returned
393    /// quarantine list, never as an `Err`.
394    pub async fn replay_quarantining(
395        &self,
396    ) -> Result<(Vec<(u64, Event)>, Vec<QuarantinedItem>), EventLogError> {
397        let reader = self.journal.lock().await.snapshot().await?;
398        let bounds = reader.bounds();
399        let mut ok = Vec::new();
400        let mut quarantined = Vec::new();
401        for position in bounds {
402            match reader.read(position).await {
403                Ok(event) => ok.push((position, event)),
404                Err(err) => quarantined.push(QuarantinedItem {
405                    position,
406                    error: err.to_string(),
407                }),
408            }
409        }
410        Ok((ok, quarantined))
411    }
412
413    /// Durably persist all buffered appends, guaranteeing they survive a crash.
414    ///
415    /// Committed appends survive a crash, but the next [`EventLog::open`] may
416    /// perform recovery work rebuilding the position index from data before
417    /// replay is available — [`EventLog::sync`] additionally makes the next
418    /// open recovery-free.
419    ///
420    /// # Errors
421    ///
422    /// Returns [`EventLogError::Journal`] if the underlying flush fails.
423    pub async fn commit(&self) -> Result<(), EventLogError> {
424        Ok(self.journal.lock().await.commit().await?)
425    }
426
427    /// Stronger durability than [`EventLog::commit`]: persist and guarantee no
428    /// recovery work is needed on next open.
429    ///
430    /// # Errors
431    ///
432    /// Returns [`EventLogError::Journal`] if the underlying sync fails.
433    pub async fn sync(&self) -> Result<(), EventLogError> {
434        Ok(self.journal.lock().await.sync().await?)
435    }
436}
437
438#[cfg(test)]
439mod tests {
440    use super::{Event, EventLog, EventLogConfig, EventLogError};
441    use commonware_runtime::{Runner, Supervisor as _, deterministic};
442
443    /// Mirrors the crate-level `# Example` doctest verbatim. The doc block is
444    /// marked `ignore` because a runnable doctest links the whole Commonware
445    /// stack into its own binary, exhausting CI's linker; this test re-verifies the
446    /// same code in the crate's already-linked test binary so the documented
447    /// example can't silently rot.
448    #[test]
449    fn doc_example_open_append_replay() {
450        let executor = deterministic::Runner::default();
451        executor.start(|context| async move {
452            let log = EventLog::open(context, EventLogConfig::for_partition("conv-1"))
453                .await
454                .expect("open log");
455
456            log.append(&Event::new("user_msg", b"hello".to_vec()))
457                .await
458                .unwrap();
459            log.append(&Event::new("tool_call", b"\x01\x02".to_vec()))
460                .await
461                .unwrap();
462            log.commit().await.unwrap();
463
464            let events = log.replay().await.unwrap();
465            assert_eq!(events.len(), 2);
466            assert_eq!(events[0].kind, "user_msg");
467        });
468    }
469
470    /// Append events across several conversation turns, then assert replay
471    /// returns them in append (conversation) order with payload bytes intact.
472    #[test]
473    fn append_then_replay_preserves_order_and_payload() {
474        let executor = deterministic::Runner::default();
475        executor.start(|context| async move {
476            let log = EventLog::open(context, EventLogConfig::for_partition("conv-order"))
477                .await
478                .expect("open");
479
480            // Two turns: turn 0 = user_msg + planner_decision; turn 1 =
481            // tool_call + tool_result. Appended in conversation order.
482            let appended = vec![
483                Event::new("user_msg", b"what is 2+2?".to_vec()),
484                Event::new("planner_decision", vec![0xde, 0xad]),
485                Event::new("tool_call", vec![0x01, 0x02, 0x03]),
486                Event::new("tool_result", vec![0xff, 0x00, 0xff]),
487            ];
488            for (i, event) in appended.iter().enumerate() {
489                let pos = log.append(event).await.expect("append");
490                assert_eq!(pos, i as u64, "positions are 0-indexed and contiguous");
491            }
492            log.commit().await.expect("commit");
493
494            assert_eq!(log.len().await, 4);
495            assert!(!log.is_empty().await);
496
497            // Replay yields exactly the appended sequence, in order.
498            let replayed = log.replay().await.expect("replay");
499            assert_eq!(replayed, appended);
500
501            // Positions are ascending and dense.
502            let with_pos = log.replay_with_positions().await.expect("replay+pos");
503            let positions: Vec<u64> = with_pos.iter().map(|(p, _)| *p).collect();
504            assert_eq!(positions, vec![0, 1, 2, 3]);
505
506            // Payload bytes round-trip verbatim.
507            assert_eq!(with_pos[2].1.payload, vec![0x01, 0x02, 0x03]);
508        });
509    }
510
511    /// Bounded replay: `replay_from(start)` seeks to `start` and yields only the
512    /// tail `[start, len)`, never re-reading earlier positions — the position-
513    /// indexed primitive a checkpointed replay starts from. `replay_from(0)`
514    /// equals a full replay; a `start` at/past the end yields nothing.
515    #[test]
516    fn replay_from_offset_returns_tail_only() {
517        let executor = deterministic::Runner::default();
518        executor.start(|context| async move {
519            let log = EventLog::open(context, EventLogConfig::for_partition("conv-from"))
520                .await
521                .expect("open");
522            for i in 0..5u8 {
523                log.append(&Event::new(format!("k{i}"), vec![i]))
524                    .await
525                    .expect("append");
526            }
527            log.commit().await.expect("commit");
528
529            // A full replay sees all five from position 0.
530            assert_eq!(log.replay_with_positions().await.expect("replay").len(), 5);
531
532            // `replay_from(2)` starts at the offset, not 0: positions 2,3,4 only.
533            let tail = log
534                .replay_from_with_positions(2)
535                .await
536                .expect("replay_from");
537            let positions: Vec<u64> = tail.iter().map(|(p, _)| *p).collect();
538            assert_eq!(positions, vec![2, 3, 4]);
539            assert_eq!(tail[0].1.kind, "k2");
540            assert_eq!(tail.last().expect("non-empty").1.kind, "k4");
541
542            // Starting at or past the end yields nothing.
543            assert!(log.replay_from(5).await.expect("from end").is_empty());
544            assert!(log.replay_from(99).await.expect("past end").is_empty());
545
546            // `replay_from(0)` is exactly a full replay.
547            assert_eq!(
548                log.replay_from(0).await.expect("from 0"),
549                log.replay().await.expect("replay")
550            );
551        });
552    }
553
554    /// A healthy log's quarantining replay reports every event decoded and
555    /// nothing quarantined — the repair primitive's happy path.
556    #[test]
557    fn repair_replay_quarantining_reports_nothing_bad_on_a_healthy_log() {
558        let executor = deterministic::Runner::default();
559        executor.start(|context| async move {
560            let log = EventLog::open(context, EventLogConfig::for_partition("conv-healthy"))
561                .await
562                .expect("open");
563            for i in 0..4u8 {
564                log.append(&Event::new(format!("k{i}"), vec![i]))
565                    .await
566                    .expect("append");
567            }
568            log.commit().await.expect("commit");
569
570            let (ok, quarantined) = log.replay_quarantining().await.expect("quarantine replay");
571            assert_eq!(ok.len(), 4);
572            assert!(quarantined.is_empty());
573            assert_eq!(ok, log.replay_with_positions().await.expect("replay"));
574        });
575    }
576
577    /// A freshly opened log is empty and replays nothing.
578    #[test]
579    fn empty_log_replays_empty() {
580        let executor = deterministic::Runner::default();
581        executor.start(|context| async move {
582            let log = EventLog::open(context, EventLogConfig::for_partition("conv-empty"))
583                .await
584                .expect("open");
585            assert!(log.is_empty().await);
586            assert_eq!(log.len().await, 0);
587            assert!(log.replay().await.expect("replay").is_empty());
588        });
589    }
590
591    /// Events appended, committed, and re-opened from the same partition
592    /// replay identically — persistence survives dropping the handle.
593    /// Destroy removes the partition wholesale: a reopen starts empty, and
594    /// appends after the reopen work normally (no resurrection of old
595    /// events). The #216 erasure primitive.
596    #[test]
597    fn destroy_removes_the_partition_and_reopen_is_empty() {
598        let executor = deterministic::Runner::default();
599        executor.start(|context| async move {
600            let cfg = EventLogConfig::for_partition("destroy-me");
601            let log = EventLog::open(context.child("first"), cfg.clone())
602                .await
603                .expect("open");
604            log.append(&Event::new("k", b"payload".to_vec()))
605                .await
606                .expect("append");
607            log.commit().await.expect("commit");
608            log.destroy().await.expect("destroy");
609
610            let log = EventLog::open(context.child("second"), cfg)
611                .await
612                .expect("reopen");
613            assert!(
614                log.replay().await.expect("replay").is_empty(),
615                "a destroyed partition must reopen empty"
616            );
617            let pos = log
618                .append(&Event::new("k2", b"fresh".to_vec()))
619                .await
620                .expect("append after destroy");
621            assert_eq!(pos, 0, "the fresh partition starts at position zero");
622        });
623    }
624
625    /// Pins the `sync()` durability contract: after a `sync`, reopen needs no
626    /// recovery work and returns exactly the synced events. (`commit()`
627    /// alone is a distinct, weaker contract — see
628    /// `reopen_recovers_committed_events_after_commit_only` below, which
629    /// pins that one instead.)
630    #[test]
631    fn reopen_recovers_synced_events() {
632        let executor = deterministic::Runner::default();
633        executor.start(|context| async move {
634            let cfg = EventLogConfig::for_partition("conv-reopen");
635
636            {
637                // Distinct supervision-tree label per open simulates a separate
638                // process (the deterministic runtime's metric registry is
639                // shared for the whole run, so re-registering under the same
640                // label panics — a real restart gets a fresh registry).
641                let log = EventLog::open(context.child("first"), cfg.clone())
642                    .await
643                    .expect("open first");
644                log.append(&Event::new("user_msg", b"persist me".to_vec()))
645                    .await
646                    .expect("append");
647                log.sync().await.expect("sync");
648            } // drop the handle
649
650            let log = EventLog::open(context.child("second"), cfg)
651                .await
652                .expect("reopen");
653            let replayed = log.replay().await.expect("replay");
654            assert_eq!(replayed.len(), 1);
655            assert_eq!(replayed[0].kind, "user_msg");
656            assert_eq!(replayed[0].payload, b"persist me".to_vec());
657        });
658    }
659
660    /// Pins the explicit 2026.7 `commit()`-only durability contract: every
661    /// production batch boundary (`append_batch_one` in `polyc-eventlog-host`)
662    /// ends in `commit()`, never `sync()`. Upstream's `commit()` fsyncs dirty data blobs but does not
663    /// advance the offsets recovery watermark, so reopen after a commit-only
664    /// crash may perform recovery work — rebuilding the missing offset
665    /// entries by replaying data from the recovery anchor — rather than
666    /// finding a synced index ready to go. This test crashes deliberately
667    /// between `commit()` and any `sync()` (the handle is simply dropped, on
668    /// a real OS-backed runtime so the process boundary is genuine, not
669    /// simulated in-memory state) and asserts the committed events are still
670    /// fully recovered on reopen, in order, with appends able to continue
671    /// past them. This catches a regression where committed bytes never
672    /// leave the application-level tail buffer at all (an in-process reopen
673    /// can't tell a real `fsync` apart from a plain unsynced `write()`, since
674    /// the OS page cache survives process death, not just power loss) or
675    /// where the offsets-rebuild recovery path breaks; the fsync guarantee
676    /// itself rests on reading upstream's `commit()` source, not on this
677    /// test.
678    #[test]
679    fn reopen_recovers_committed_events_after_commit_only() {
680        use commonware_runtime::{Runner as _, tokio as cw_tokio};
681
682        let dir =
683            std::env::temp_dir().join(format!("polyc-eventlog-commit-only-{}", std::process::id()));
684        let _ = std::fs::remove_dir_all(&dir);
685        let cfg = EventLogConfig::for_partition("conv-commit-only");
686
687        let write_cfg = cfg.clone();
688        let write_runner =
689            cw_tokio::Runner::new(cw_tokio::Config::default().with_storage_directory(dir.clone()));
690        write_runner.start(move |context| async move {
691            let log = EventLog::open(context, write_cfg).await.expect("open");
692            log.append(&Event::new("user_msg", b"one".to_vec()))
693                .await
694                .expect("append 0");
695            log.append(&Event::new("output_msg", b"two".to_vec()))
696                .await
697                .expect("append 1");
698            log.append(&Event::new("tool_call", b"three".to_vec()))
699                .await
700                .expect("append 2");
701            log.commit().await.expect("commit");
702            // No `sync()` — the runner ends here and the handle drops,
703            // simulating a crash immediately after the commit-only durability
704            // point every production batch boundary actually uses.
705        });
706
707        let read_cfg = cfg;
708        let read_runner =
709            cw_tokio::Runner::new(cw_tokio::Config::default().with_storage_directory(dir.clone()));
710        read_runner.start(move |context| async move {
711            let log = EventLog::open(context, read_cfg)
712                .await
713                .expect("reopen recovers committed-only data with no sync");
714            let replayed = log.replay().await.expect("replay");
715            assert_eq!(replayed.len(), 3, "all three committed events survive");
716            assert_eq!(replayed[0].kind, "user_msg");
717            assert_eq!(replayed[0].payload, b"one".to_vec());
718            assert_eq!(replayed[1].kind, "output_msg");
719            assert_eq!(replayed[1].payload, b"two".to_vec());
720            assert_eq!(replayed[2].kind, "tool_call");
721            assert_eq!(replayed[2].payload, b"three".to_vec());
722
723            let pos = log
724                .append(&Event::new("recovered", b"still writable".to_vec()))
725                .await
726                .expect("append continues after commit-only recovery");
727            assert_eq!(pos, 3, "the new append continues at position 3");
728        });
729
730        let _ = std::fs::remove_dir_all(&dir);
731    }
732
733    /// Deterministic-runtime sibling of
734    /// `reopen_recovers_committed_events_after_commit_only`: the crate
735    /// documents both the deterministic and tokio runtimes, so the
736    /// commit-only durability contract is pinned on both. Cheaper than the
737    /// tokio version (no real filesystem I/O) but does not exercise a real
738    /// OS-backed crash boundary — that's what the tokio sibling is for.
739    #[test]
740    fn reopen_recovers_committed_events_after_commit_only_deterministic() {
741        let executor = deterministic::Runner::default();
742        executor.start(|context| async move {
743            let cfg = EventLogConfig::for_partition("conv-reopen-commit-only");
744
745            {
746                let log = EventLog::open(context.child("first"), cfg.clone())
747                    .await
748                    .expect("open first");
749                log.append(&Event::new("user_msg", b"persist me".to_vec()))
750                    .await
751                    .expect("append");
752                log.commit().await.expect("commit");
753                // No `sync()` before drop.
754            }
755
756            let log = EventLog::open(context.child("second"), cfg)
757                .await
758                .expect("reopen");
759            let replayed = log.replay().await.expect("replay");
760            assert_eq!(replayed.len(), 1);
761            assert_eq!(replayed[0].kind, "user_msg");
762            assert_eq!(replayed[0].payload, b"persist me".to_vec());
763        });
764    }
765
766    /// Determinism / reproducibility: two independent deterministic runs with
767    /// the same seeded program produce the same auditor state. This is the
768    /// property replay tests rely on (mirrors the runtime spike's
769    /// `auditor().state()` assertion).
770    #[test]
771    fn deterministic_runs_are_reproducible() {
772        fn run() -> String {
773            let executor = deterministic::Runner::default();
774            executor.start(|context| async move {
775                // `Context` is no longer `Clone` in 2026.5 — use `child`
776                // to produce a sibling context for the log while keeping
777                // the parent available for the `auditor()` read at the end.
778                let log =
779                    EventLog::open(context.child("det"), EventLogConfig::for_partition("det"))
780                        .await
781                        .expect("open");
782                for i in 0..6u8 {
783                    log.append(&Event::new(format!("kind-{i}"), vec![i; i as usize]))
784                        .await
785                        .expect("append");
786                }
787                log.commit().await.expect("commit");
788                let _ = log.replay().await.expect("replay");
789                context.auditor().state()
790            })
791        }
792
793        let first = run();
794        let second = run();
795        assert_eq!(first, second, "deterministic runtime must be reproducible");
796    }
797
798    /// The repair primitive (`#799`), on real on-disk files: `EventLog::open`
799    /// only re-validates its FINAL (still-active) section on open — see the
800    /// crate docs — so a corrupted item in an EARLIER, already-closed
801    /// section is invisible to that recovery and `open` succeeds anyway. A
802    /// plain streaming [`EventLog::replay`] then HARD FAILS as soon as it
803    /// streams past the corrupted item (commonware 2026.7 tightened this:
804    /// 2026.5 silently dropped the corrupted item with no error signal at
805    /// all — an even worse gap, since a caller saw a shorter-than-expected
806    /// transcript with no indication anything was missing). Because the
807    /// corrupted position here is the earliest one, the whole replay
808    /// aborts and the later, undamaged events (1 and 2) are inaccessible
809    /// through this path too. [`EventLog::replay_quarantining`] reads each
810    /// position independently through the journal's offset index,
811    /// correctly reports the corrupted position (with the underlying
812    /// storage error), and still recovers everything after it — the
813    /// primitive this repair path exists for.
814    #[test]
815    fn repair_replay_quarantining_recovers_events_after_a_corrupted_earlier_section() {
816        use commonware_runtime::{Runner as _, tokio as cw_tokio};
817
818        let dir = std::env::temp_dir().join(format!(
819            "polyc-eventlog-repair-earlier-section-{}",
820            std::process::id()
821        ));
822        let _ = std::fs::remove_dir_all(&dir);
823
824        // One event per section, so item 0 lands in a section that is no
825        // longer "final" (and so no longer re-validated) once items 1 and 2
826        // are appended after it.
827        let mut cfg = EventLogConfig::for_partition("conv-repair");
828        cfg.items_per_section = commonware_utils::NZU64!(1);
829
830        let write_cfg = cfg.clone();
831        let write_runner =
832            cw_tokio::Runner::new(cw_tokio::Config::default().with_storage_directory(dir.clone()));
833        write_runner.start(move |context| async move {
834            let log = EventLog::open(context, write_cfg).await.expect("open");
835            log.append(&Event::new("user_msg", vec![b'A'; 20]))
836                .await
837                .expect("append 0");
838            log.append(&Event::new("output_msg", vec![b'B'; 20]))
839                .await
840                .expect("append 1");
841            log.append(&Event::new("tool_call", vec![b'C'; 20]))
842                .await
843                .expect("append 2");
844            log.sync().await.expect("sync");
845        });
846
847        // Corrupt section 0's item at the first byte of its `kind` STRING
848        // content (byte offset 10: 8-byte section magic header + 1-byte
849        // outer item-length prefix + 1-byte inner kind-length prefix),
850        // leaving both length prefixes intact so the outer framing still
851        // parses fine. `0xFF` is not a valid UTF-8 lead byte in any
852        // position, and [`Event`]'s decode is strict (`String::from_utf8`,
853        // not lossy), so this is a genuine decode failure — simulating bit
854        // rot or tampering in an already-closed section — rather than
855        // silently decoding into different-but-still-valid content (the
856        // kind of tamper only the MMR check in `polyc_eventlog::integrity`
857        // catches, not this decode-level quarantine).
858        let data_file = dir.join("conv-repair_data").join("0000000000000000");
859        let mut bytes = std::fs::read(&data_file).expect("read section 0");
860        bytes[10] = 0xFF;
861        std::fs::write(&data_file, &bytes).expect("write corrupted section 0");
862
863        let read_cfg = cfg;
864        let read_runner =
865            cw_tokio::Runner::new(cw_tokio::Config::default().with_storage_directory(dir.clone()));
866        read_runner.start(move |context| async move {
867            let log = EventLog::open(context, read_cfg)
868                .await
869                .expect("reopen succeeds: only the final section is re-validated on open");
870
871            let plain_err = log
872                .replay()
873                .await
874                .expect_err("a corrupted item makes the whole streamed replay fail");
875            assert!(
876                matches!(plain_err, EventLogError::Journal(_)),
877                "unexpected error variant: {plain_err:?}"
878            );
879
880            let (ok, quarantined) = log
881                .replay_quarantining()
882                .await
883                .expect("quarantining replay reports positions, not an Err");
884            assert_eq!(quarantined.len(), 1, "exactly the corrupted item");
885            assert_eq!(quarantined[0].position, 0);
886            assert_eq!(
887                ok.len(),
888                2,
889                "the two events after the corrupted one recover"
890            );
891            assert_eq!(ok[0], (1, Event::new("output_msg", vec![b'B'; 20])));
892            assert_eq!(ok[1], (2, Event::new("tool_call", vec![b'C'; 20])));
893        });
894
895        let _ = std::fs::remove_dir_all(&dir);
896    }
897
898    /// Torn-write recovery (`#799`): a crash mid-append leaves a partition
899    /// whose on-disk section no longer matches what the offset index
900    /// expects. Reopening must not error or panic, replay must return
901    /// successfully (never hard-fail the whole partition open over a crash
902    /// artifact), and the partition must accept new appends afterward — the
903    /// durability property every append-only log needs to survive a real
904    /// power loss / OOM-kill: a torn write degrades the log, it does not
905    /// permanently brick the conversation.
906    #[test]
907    fn torn_write_truncated_journal_reopens_and_replays() {
908        use commonware_runtime::{Runner as _, tokio as cw_tokio};
909
910        let dir =
911            std::env::temp_dir().join(format!("polyc-eventlog-torn-write-{}", std::process::id()));
912        let _ = std::fs::remove_dir_all(&dir);
913        let cfg = EventLogConfig::for_partition("conv-torn");
914
915        let write_cfg = cfg.clone();
916        let write_runner =
917            cw_tokio::Runner::new(cw_tokio::Config::default().with_storage_directory(dir.clone()));
918        write_runner.start(move |context| async move {
919            let log = EventLog::open(context, write_cfg).await.expect("open");
920            log.append(&Event::new("user_msg", vec![b'A'; 20]))
921                .await
922                .expect("append 0");
923            log.append(&Event::new("output_msg", vec![b'B'; 20]))
924                .await
925                .expect("append 1");
926            log.append(&Event::new("tool_call", vec![b'C'; 20]))
927                .await
928                .expect("append 2");
929            log.sync().await.expect("sync");
930        });
931
932        // Simulate a crash mid-write: the blob's storage space is
933        // pre-allocated well beyond the ~107 bytes the three items occupy,
934        // so a real crash leaves the file at its full pre-allocated length
935        // with an un-written (zero) tail rather than a shorter file. Locate
936        // item 2's payload by content (rather than hand-deriving on-disk
937        // item-framing offsets) and zero everything from partway through it
938        // onward — a torn (incomplete) final item, exactly what a crash
939        // mid-append-of-item-2 leaves behind.
940        let data_file = dir.join("conv-torn_data").join("0000000000000000");
941        let full = std::fs::read(&data_file).expect("read section 0");
942        let marker = [b'C'; 20];
943        let payload_start = full
944            .windows(marker.len())
945            .position(|w| w == marker)
946            .expect("item 2's payload is present in the untruncated section");
947        let mut corrupted = full;
948        for b in &mut corrupted[payload_start + 10..] {
949            *b = 0;
950        }
951        std::fs::write(&data_file, &corrupted).expect("simulate a torn write");
952
953        let read_cfg = cfg;
954        let read_runner =
955            cw_tokio::Runner::new(cw_tokio::Config::default().with_storage_directory(dir.clone()));
956        read_runner.start(move |context| async move {
957            let log = EventLog::open(context, read_cfg)
958                .await
959                .expect("reopen recovers from a torn tail without erroring");
960            let events = log
961                .replay()
962                .await
963                .expect("replay succeeds (never hard-fails) after a torn write");
964            // The engine's own crash-recovery decides how much of the torn
965            // section it can trust; this pins the property that matters for
966            // durability — recovery is conservative (it never returns
967            // content associated with an item it couldn't fully validate),
968            // and it never returns more than what was actually written.
969            assert!(
970                events.len() <= 3,
971                "recovery must never fabricate events beyond what was appended"
972            );
973            for event in &events {
974                assert!(
975                    [
976                        Event::new("user_msg", vec![b'A'; 20]),
977                        Event::new("output_msg", vec![b'B'; 20]),
978                    ]
979                    .contains(event),
980                    "recovery must never return the torn (never-fully-written) third item"
981                );
982            }
983
984            // The partition is not permanently bricked: it still accepts
985            // new appends after the crash.
986            let pos = log
987                .append(&Event::new("recovered", b"still writable".to_vec()))
988                .await
989                .expect("the partition accepts appends again after torn-write recovery");
990            log.commit().await.expect("commit after recovery");
991            assert_eq!(
992                pos,
993                events.len() as u64,
994                "the new append continues from wherever recovery left off"
995            );
996        });
997
998        let _ = std::fs::remove_dir_all(&dir);
999    }
1000}