Append-only conversation event log on a commonware-storage journal.
This crate persists the ordered stream of events that make up a conversation (user messages, planner decisions, tool calls, …) to an append-only log backed by the Commonware storage stack — keeping persistence on the Commonware primitives rather than a relational store.
Storage primitive
[EventLog] wraps
[commonware_storage::journal::contiguous::variable::Journal]: a
contiguous, position-based, variable-length append-only journal. It is
the natural fit here:
- Append-only. [
EventLog::append] writes one [Event] and returns the monotonically increasingu64position the journal assigned it. Positions start at0and never reused; pruning earlier entries does not shift later positions. - Ordered replay. [
EventLog::replay] returns every event in append order, each paired with its position. Append order is the ordering contract: the caller appends events in conversation order (turn, then sequence within a turn), and replay yields them back in exactly that order. The position therefore is the (turn, seq) ordinal flattened into one strictly increasing sequence — there is no separate sort key to maintain, which is precisely what an append-only log buys us. - Variable-length items. Each event's
payloadis an opaque, buffa-encoded byte blob of arbitrary size; thevariablejournal stores variable-length items natively (thecontiguous::fixedsibling is for fixed-width records and would not fit).
Runtime genericity (tokio vs. deterministic)
The journal — and therefore [EventLog] — is generic over a
[commonware_storage::Context] (the Storage + Clock + Metrics bound every
Commonware storage type carries). Production drives it on the
commonware_runtime::tokio backend; tests drive it on the
commonware_runtime::deterministic backend for seeded, reproducible runs.
The two never nest: per the prior commonware-transport spike, the
Commonware runtime cannot be started from inside a live tokio runtime, so
every tokio process that embeds this crate runs it on a dedicated thread.
The state plane does so for the conversation journal it alone writes; the
control plane does so for the non-conversation logs it keeps of its own.
This crate stays runtime-agnostic and leaves that hosting decision to the
caller.
Conversation scoping
One [EventLog] instance maps to one conversation's log, identified by the
storage partition name passed to [EventLog::open] (derive it from the
conversation id, e.g. format!("conv-{uid}")). Distinct conversations use
distinct partitions and so are fully isolated on disk.
Example
use commonware_runtime::{deterministic, Runner};
use polyc_eventlog::{Event, EventLog, EventLogConfig};
let executor = deterministic::Runner::default();
executor.start(|context| async move {
let log = EventLog::open(context, EventLogConfig::for_partition("conv-1"))
.await
.expect("open log");
log.append(&Event::new("user_msg", b"hello".to_vec())).await.unwrap();
log.append(&Event::new("tool_call", b"\x01\x02".to_vec())).await.unwrap();
log.commit().await.unwrap();
let events = log.replay().await.unwrap();
assert_eq!(events.len(), 2);
assert_eq!(events[0].kind, "user_msg");
});