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 error;
81pub mod event;
82pub mod nav;
83pub mod taint;
84
85pub use error::EventLogError;
86pub use event::{Event, EventCfg};
87pub use taint::{
88    GrantedCapabilities, TrifectaLegs, TrustTag, any_untrusted, any_untrusted_excluding,
89    trifecta_legs,
90};
91
92use commonware_runtime::buffer::paged::CacheRef;
93use commonware_storage::journal::contiguous::{Reader as _, variable};
94use commonware_utils::{NZU16, NZU64, NZUsize};
95use futures::StreamExt as _;
96use std::num::{NonZeroU16, NonZeroU64, NonZeroUsize};
97
98/// Buffer size (in items) for the replay stream from the underlying journal.
99const REPLAY_BUFFER: NonZeroUsize = NZUsize!(1024);
100
101/// Configuration for opening an [`EventLog`].
102///
103/// Most fields mirror the underlying journal's tuning knobs and have sensible
104/// defaults via [`EventLogConfig::for_partition`]; only the `partition`
105/// (which conversation's log) is mandatory.
106#[derive(Debug, Clone)]
107pub struct EventLogConfig {
108    /// Storage partition name — one per conversation. Sub-partitions for the
109    /// data and offset indexes are derived from it by the journal.
110    pub partition: String,
111
112    /// Number of events stored per journal section. Sections roll over at this
113    /// count; only the final (partial) section is replayed on open to recover
114    /// the exact size. **Immutable once a partition exists** — changing it
115    /// across restarts corrupts the log.
116    pub items_per_section: NonZeroU64,
117
118    /// Decode-time bounds applied to each event during [`EventLog::replay`].
119    pub event_cfg: EventCfg,
120
121    /// Page size for the read cache over the underlying storage blobs.
122    pub page_size: NonZeroU16,
123
124    /// Page cache capacity, in pages.
125    pub page_cache_pages: NonZeroUsize,
126
127    /// Per-section write buffer size, in bytes.
128    pub write_buffer: NonZeroUsize,
129}
130
131impl EventLogConfig {
132    /// Build a config for `partition` with defaults for every other field.
133    ///
134    /// Defaults: 1024 events per section, [`EventCfg::DEFAULT`] decode bounds,
135    /// a 16 KiB page size with a 64-page cache, and a 64 KiB write buffer.
136    #[must_use]
137    pub fn for_partition(partition: impl Into<String>) -> Self {
138        Self {
139            partition: partition.into(),
140            items_per_section: NZU64!(1024),
141            event_cfg: EventCfg::DEFAULT,
142            page_size: NZU16!(16384),
143            page_cache_pages: NZUsize!(64),
144            write_buffer: NZUsize!(65536),
145        }
146    }
147}
148
149/// An append-only, ordered log of conversation [`Event`]s.
150///
151/// Generic over a [`commonware_storage::Context`] so the same code runs on the
152/// tokio backend in production and the deterministic backend in tests. See the
153/// crate-level docs for the ordering contract and runtime-coexistence notes.
154pub struct EventLog<E>
155where
156    E: commonware_storage::Context + commonware_runtime::BufferPooler,
157{
158    journal: variable::Journal<E, Event>,
159}
160
161impl<E> EventLog<E>
162where
163    E: commonware_storage::Context + commonware_runtime::BufferPooler,
164{
165    /// Open (creating if absent, recovering if present) the event log for a
166    /// conversation on the given runtime `context`.
167    ///
168    /// On open the journal replays only its final section to recover the exact
169    /// append size, and self-heals any data/offset divergence left by a crash.
170    ///
171    /// # Errors
172    ///
173    /// Returns [`EventLogError::Journal`] if the underlying storage fails to
174    /// initialize or recover the journal.
175    pub async fn open(context: E, config: EventLogConfig) -> Result<Self, EventLogError> {
176        let page_cache = CacheRef::from_pooler(&context, config.page_size, config.page_cache_pages);
177        let journal_cfg = variable::Config {
178            partition: config.partition,
179            items_per_section: config.items_per_section,
180            compression: None,
181            codec_config: config.event_cfg,
182            page_cache,
183            write_buffer: config.write_buffer,
184        };
185        let journal = variable::Journal::init(context, journal_cfg).await?;
186        Ok(Self { journal })
187    }
188
189    /// Destroy the log: consume the handle and REMOVE the partition's
190    /// underlying blobs (data + offsets) from storage. The erasure primitive
191    /// (#216): after this, a fresh [`EventLog::open`] of the same partition
192    /// starts empty.
193    ///
194    /// Deliberately consuming — a destroyed log has no valid further
195    /// operation, and the caller must drop every other handle first (the
196    /// control plane's host serializes this through its single command
197    /// loop and evicts its cache entry before destroying).
198    ///
199    /// # Errors
200    ///
201    /// Returns [`EventLogError::Journal`] if the underlying blob removal
202    /// fails.
203    pub async fn destroy(self) -> Result<(), EventLogError> {
204        Ok(self.journal.destroy().await?)
205    }
206
207    /// Append a single event, returning the position the journal assigned it.
208    ///
209    /// Positions are strictly increasing from `0` and define replay order. The
210    /// caller must append in conversation order (turn, then seq within a turn)
211    /// for replay to reflect that order.
212    ///
213    /// Takes `&self`: the underlying journal serializes writes internally via
214    /// interior mutability, so a shared reference suffices.
215    ///
216    /// Appends are buffered for durability; call [`EventLog::commit`] (or
217    /// [`EventLog::sync`]) to guarantee they survive a crash.
218    ///
219    /// # Errors
220    ///
221    /// Returns [`EventLogError::Journal`] if the item cannot be encoded or the
222    /// underlying storage write fails.
223    pub async fn append(&self, event: &Event) -> Result<u64, EventLogError> {
224        Ok(self.journal.append(event).await?)
225    }
226
227    /// Number of events appended to the log (the position the *next* append
228    /// will receive). Not reduced by pruning.
229    pub async fn len(&self) -> u64 {
230        self.journal.size().await
231    }
232
233    /// Whether the log has no appended events.
234    pub async fn is_empty(&self) -> bool {
235        self.len().await == 0
236    }
237
238    /// Replay every event in append order, each paired with its position.
239    ///
240    /// The returned `Vec` is ordered by position ascending (`0, 1, 2, …`),
241    /// which is conversation order. Each tuple is `(position, event)`.
242    ///
243    /// This collects the full log into memory; it is intended for rebuilding
244    /// in-memory conversation state on resume. For very large logs a streaming
245    /// variant could be added later (the journal exposes a `Stream`), but the
246    /// foundational API materializes for simplicity.
247    ///
248    /// # Errors
249    ///
250    /// Returns [`EventLogError::Journal`] if the journal cannot start the replay
251    /// stream or if decoding any stored event fails.
252    // The replay stream borrows the `reader` guard for its whole lifetime, so
253    // the guard cannot be dropped before the stream is consumed — the lint's
254    // suggested early drop would not compile here.
255    #[allow(clippy::significant_drop_tightening)]
256    pub async fn replay_with_positions(&self) -> Result<Vec<(u64, Event)>, EventLogError> {
257        let reader = self.journal.reader().await;
258        let start = reader.bounds().start;
259        let stream = reader.replay(REPLAY_BUFFER, start).await?;
260        futures::pin_mut!(stream);
261        let mut out = Vec::new();
262        while let Some(item) = stream.next().await {
263            out.push(item?);
264        }
265        Ok(out)
266    }
267
268    /// Replay events in append order starting at position `start`, each paired
269    /// with its position.
270    ///
271    /// The journal is position-indexed, so resuming at an offset is cheap — the
272    /// reader seeks to `start` rather than scanning from zero. This is what lets
273    /// a caller replay only the tail since a durable checkpoint instead of
274    /// re-reading the whole partition every time. `start` is clamped up to the
275    /// pruning boundary, and a `start` at or past the end yields an empty `Vec`.
276    /// Returned tuples are `(position, event)` for positions in
277    /// `[max(start, bounds.start), len)`, ascending.
278    ///
279    /// # Errors
280    ///
281    /// Returns [`EventLogError::Journal`] if the journal cannot start the replay
282    /// stream or if decoding any stored event fails.
283    // The replay stream borrows the `reader` guard for its whole lifetime (see
284    // [`EventLog::replay_with_positions`]); the lint's suggested early drop
285    // would not compile.
286    #[allow(clippy::significant_drop_tightening)]
287    pub async fn replay_from_with_positions(
288        &self,
289        start: u64,
290    ) -> Result<Vec<(u64, Event)>, EventLogError> {
291        let reader = self.journal.reader().await;
292        let bounds = reader.bounds();
293        let from = start.max(bounds.start);
294        if from >= bounds.end {
295            return Ok(Vec::new());
296        }
297        let stream = reader.replay(REPLAY_BUFFER, from).await?;
298        futures::pin_mut!(stream);
299        let mut out = Vec::new();
300        while let Some(item) = stream.next().await {
301            out.push(item?);
302        }
303        Ok(out)
304    }
305
306    /// Replay every event in append order, discarding positions.
307    ///
308    /// Convenience over [`EventLog::replay_with_positions`] for callers that
309    /// only need the ordered events.
310    ///
311    /// # Errors
312    ///
313    /// Returns [`EventLogError::Journal`] on the same conditions as
314    /// [`EventLog::replay_with_positions`].
315    pub async fn replay(&self) -> Result<Vec<Event>, EventLogError> {
316        Ok(self
317            .replay_with_positions()
318            .await?
319            .into_iter()
320            .map(|(_pos, event)| event)
321            .collect())
322    }
323
324    /// Replay events from position `start` in append order, discarding
325    /// positions. Convenience over [`EventLog::replay_from_with_positions`].
326    ///
327    /// # Errors
328    ///
329    /// Returns [`EventLogError::Journal`] on the same conditions as
330    /// [`EventLog::replay_from_with_positions`].
331    pub async fn replay_from(&self, start: u64) -> Result<Vec<Event>, EventLogError> {
332        Ok(self
333            .replay_from_with_positions(start)
334            .await?
335            .into_iter()
336            .map(|(_pos, event)| event)
337            .collect())
338    }
339
340    /// Durably persist all buffered appends, guaranteeing they survive a crash.
341    ///
342    /// # Errors
343    ///
344    /// Returns [`EventLogError::Journal`] if the underlying flush fails.
345    pub async fn commit(&self) -> Result<(), EventLogError> {
346        Ok(self.journal.commit().await?)
347    }
348
349    /// Stronger durability than [`EventLog::commit`]: persist and guarantee no
350    /// recovery work is needed on next open.
351    ///
352    /// # Errors
353    ///
354    /// Returns [`EventLogError::Journal`] if the underlying sync fails.
355    pub async fn sync(&self) -> Result<(), EventLogError> {
356        Ok(self.journal.sync().await?)
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::{Event, EventLog, EventLogConfig};
363    use commonware_runtime::{Runner, Supervisor as _, deterministic};
364
365    /// Mirrors the crate-level `# Example` doctest verbatim. The doc block is
366    /// marked `ignore` because a runnable doctest links the whole Commonware
367    /// stack into its own binary, exhausting CI's linker; this test re-verifies the
368    /// same code in the crate's already-linked test binary so the documented
369    /// example can't silently rot.
370    #[test]
371    fn doc_example_open_append_replay() {
372        let executor = deterministic::Runner::default();
373        executor.start(|context| async move {
374            let log = EventLog::open(context, EventLogConfig::for_partition("conv-1"))
375                .await
376                .expect("open log");
377
378            log.append(&Event::new("user_msg", b"hello".to_vec()))
379                .await
380                .unwrap();
381            log.append(&Event::new("tool_call", b"\x01\x02".to_vec()))
382                .await
383                .unwrap();
384            log.commit().await.unwrap();
385
386            let events = log.replay().await.unwrap();
387            assert_eq!(events.len(), 2);
388            assert_eq!(events[0].kind, "user_msg");
389        });
390    }
391
392    /// Append events across several conversation turns, then assert replay
393    /// returns them in append (conversation) order with payload bytes intact.
394    #[test]
395    fn append_then_replay_preserves_order_and_payload() {
396        let executor = deterministic::Runner::default();
397        executor.start(|context| async move {
398            let log = EventLog::open(context, EventLogConfig::for_partition("conv-order"))
399                .await
400                .expect("open");
401
402            // Two turns: turn 0 = user_msg + planner_decision; turn 1 =
403            // tool_call + tool_result. Appended in conversation order.
404            let appended = vec![
405                Event::new("user_msg", b"what is 2+2?".to_vec()),
406                Event::new("planner_decision", vec![0xde, 0xad]),
407                Event::new("tool_call", vec![0x01, 0x02, 0x03]),
408                Event::new("tool_result", vec![0xff, 0x00, 0xff]),
409            ];
410            for (i, event) in appended.iter().enumerate() {
411                let pos = log.append(event).await.expect("append");
412                assert_eq!(pos, i as u64, "positions are 0-indexed and contiguous");
413            }
414            log.commit().await.expect("commit");
415
416            assert_eq!(log.len().await, 4);
417            assert!(!log.is_empty().await);
418
419            // Replay yields exactly the appended sequence, in order.
420            let replayed = log.replay().await.expect("replay");
421            assert_eq!(replayed, appended);
422
423            // Positions are ascending and dense.
424            let with_pos = log.replay_with_positions().await.expect("replay+pos");
425            let positions: Vec<u64> = with_pos.iter().map(|(p, _)| *p).collect();
426            assert_eq!(positions, vec![0, 1, 2, 3]);
427
428            // Payload bytes round-trip verbatim.
429            assert_eq!(with_pos[2].1.payload, vec![0x01, 0x02, 0x03]);
430        });
431    }
432
433    /// Bounded replay: `replay_from(start)` seeks to `start` and yields only the
434    /// tail `[start, len)`, never re-reading earlier positions — the position-
435    /// indexed primitive a checkpointed replay starts from. `replay_from(0)`
436    /// equals a full replay; a `start` at/past the end yields nothing.
437    #[test]
438    fn replay_from_offset_returns_tail_only() {
439        let executor = deterministic::Runner::default();
440        executor.start(|context| async move {
441            let log = EventLog::open(context, EventLogConfig::for_partition("conv-from"))
442                .await
443                .expect("open");
444            for i in 0..5u8 {
445                log.append(&Event::new(format!("k{i}"), vec![i]))
446                    .await
447                    .expect("append");
448            }
449            log.commit().await.expect("commit");
450
451            // A full replay sees all five from position 0.
452            assert_eq!(log.replay_with_positions().await.expect("replay").len(), 5);
453
454            // `replay_from(2)` starts at the offset, not 0: positions 2,3,4 only.
455            let tail = log
456                .replay_from_with_positions(2)
457                .await
458                .expect("replay_from");
459            let positions: Vec<u64> = tail.iter().map(|(p, _)| *p).collect();
460            assert_eq!(positions, vec![2, 3, 4]);
461            assert_eq!(tail[0].1.kind, "k2");
462            assert_eq!(tail.last().expect("non-empty").1.kind, "k4");
463
464            // Starting at or past the end yields nothing.
465            assert!(log.replay_from(5).await.expect("from end").is_empty());
466            assert!(log.replay_from(99).await.expect("past end").is_empty());
467
468            // `replay_from(0)` is exactly a full replay.
469            assert_eq!(
470                log.replay_from(0).await.expect("from 0"),
471                log.replay().await.expect("replay")
472            );
473        });
474    }
475
476    /// A freshly opened log is empty and replays nothing.
477    #[test]
478    fn empty_log_replays_empty() {
479        let executor = deterministic::Runner::default();
480        executor.start(|context| async move {
481            let log = EventLog::open(context, EventLogConfig::for_partition("conv-empty"))
482                .await
483                .expect("open");
484            assert!(log.is_empty().await);
485            assert_eq!(log.len().await, 0);
486            assert!(log.replay().await.expect("replay").is_empty());
487        });
488    }
489
490    /// Events appended, committed, and re-opened from the same partition
491    /// replay identically — persistence survives dropping the handle.
492    /// Destroy removes the partition wholesale: a reopen starts empty, and
493    /// appends after the reopen work normally (no resurrection of old
494    /// events). The #216 erasure primitive.
495    #[test]
496    fn destroy_removes_the_partition_and_reopen_is_empty() {
497        let executor = deterministic::Runner::default();
498        executor.start(|context| async move {
499            let cfg = EventLogConfig::for_partition("destroy-me");
500            let log = EventLog::open(context.child("first"), cfg.clone())
501                .await
502                .expect("open");
503            log.append(&Event::new("k", b"payload".to_vec()))
504                .await
505                .expect("append");
506            log.commit().await.expect("commit");
507            log.destroy().await.expect("destroy");
508
509            let log = EventLog::open(context.child("second"), cfg)
510                .await
511                .expect("reopen");
512            assert!(
513                log.replay().await.expect("replay").is_empty(),
514                "a destroyed partition must reopen empty"
515            );
516            let pos = log
517                .append(&Event::new("k2", b"fresh".to_vec()))
518                .await
519                .expect("append after destroy");
520            assert_eq!(pos, 0, "the fresh partition starts at position zero");
521        });
522    }
523
524    #[test]
525    fn reopen_recovers_committed_events() {
526        let executor = deterministic::Runner::default();
527        executor.start(|context| async move {
528            let cfg = EventLogConfig::for_partition("conv-reopen");
529
530            {
531                // Distinct supervision-tree label per open simulates a separate
532                // process (the deterministic runtime's metric registry is
533                // shared for the whole run, so re-registering under the same
534                // label panics — a real restart gets a fresh registry).
535                let log = EventLog::open(context.child("first"), cfg.clone())
536                    .await
537                    .expect("open first");
538                log.append(&Event::new("user_msg", b"persist me".to_vec()))
539                    .await
540                    .expect("append");
541                log.sync().await.expect("sync");
542            } // drop the handle
543
544            let log = EventLog::open(context.child("second"), cfg)
545                .await
546                .expect("reopen");
547            let replayed = log.replay().await.expect("replay");
548            assert_eq!(replayed.len(), 1);
549            assert_eq!(replayed[0].kind, "user_msg");
550            assert_eq!(replayed[0].payload, b"persist me".to_vec());
551        });
552    }
553
554    /// Determinism / reproducibility: two independent deterministic runs with
555    /// the same seeded program produce the same auditor state. This is the
556    /// property replay tests rely on (mirrors the runtime spike's
557    /// `auditor().state()` assertion).
558    #[test]
559    fn deterministic_runs_are_reproducible() {
560        fn run() -> String {
561            let executor = deterministic::Runner::default();
562            executor.start(|context| async move {
563                // `Context` is no longer `Clone` in 2026.5 — use `child`
564                // to produce a sibling context for the log while keeping
565                // the parent available for the `auditor()` read at the end.
566                let log =
567                    EventLog::open(context.child("det"), EventLogConfig::for_partition("det"))
568                        .await
569                        .expect("open");
570                for i in 0..6u8 {
571                    log.append(&Event::new(format!("kind-{i}"), vec![i; i as usize]))
572                        .await
573                        .expect("append");
574                }
575                log.commit().await.expect("commit");
576                let _ = log.replay().await.expect("replay");
577                context.auditor().state()
578            })
579        }
580
581        let first = run();
582        let second = run();
583        assert_eq!(first, second, "deterministic runtime must be reproducible");
584    }
585}