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}