polyc-eventlog 0.1.3

Append-only conversation event log on a commonware-storage journal.
Documentation
//! The [`Event`] item stored in the log and its [`commonware_codec`] wiring.
//!
//! An [`Event`] is the unit the journal persists: a `kind` discriminator plus
//! an opaque `payload`. This crate is deliberately **payload-agnostic** — the
//! payload is a `Vec<u8>` of buffa-encoded bytes whose concrete schema lives in
//! `polyc-proto`'s `events.proto`. The event log neither encodes nor
//! interprets it; it round-trips the bytes verbatim.
//!
//! The journal primitive (`commonware_storage::journal::contiguous::variable`)
//! requires its item type to implement [`commonware_codec`]'s [`Write`],
//! [`EncodeSize`], and [`Read`] traits. [`Event`] implements them by length-
//! prefixing the `kind` UTF-8 bytes and the `payload` bytes, exactly the
//! variable-size pattern documented in the `commonware-codec` crate.

use bytes::{Buf, BufMut};
use commonware_codec::{EncodeSize, Error as CodecError, Read, ReadRangeExt as _, Write};

/// A single conversation event: a `kind` tag and an opaque encoded payload.
///
/// `kind` is a short string discriminator (e.g. `"user_msg"`,
/// `"planner_decision"`, `"tool_call"`). `payload` is opaque to this crate —
/// it is the buffa-encoded body of whichever `events.proto` message the `kind`
/// names, stored and replayed byte-for-byte.
///
/// The position (turn/seq ordering) is **not** carried in the event itself: the
/// journal assigns each appended event a monotonically increasing position, and
/// [`crate::EventLog::replay`] yields events in that append order. See the crate
/// docs for the ordering contract.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Event {
    /// Discriminator naming the payload's schema (e.g. `"tool_call"`).
    pub kind: String,
    /// Opaque, buffa-encoded payload bytes. Round-tripped verbatim.
    pub payload: Vec<u8>,
}

impl Event {
    /// Construct an [`Event`] from a `kind` and an owned payload byte vector.
    #[must_use]
    pub fn new(kind: impl Into<String>, payload: Vec<u8>) -> Self {
        Self {
            kind: kind.into(),
            payload,
        }
    }
}

/// Decode-time bounds for an [`Event`], supplied as the journal's
/// `codec_config`.
///
/// `commonware-codec` requires an explicit maximum length when reading any
/// variable-length field, so that a corrupt or hostile length prefix cannot
/// trigger an unbounded allocation. These caps are applied when decoding the
/// `kind` and `payload` of each event during a [`crate::EventLog::replay`].
///
/// [`EventCfg::DEFAULT`] provides generous defaults suitable for conversation
/// events; tighten them per deployment if desired.
#[derive(Debug, Clone, Copy)]
pub struct EventCfg {
    /// Maximum byte length permitted for a decoded `kind` string.
    pub max_kind_len: usize,
    /// Maximum byte length permitted for a decoded `payload`.
    pub max_payload_len: usize,
}

impl EventCfg {
    /// Default decode bounds: 256-byte `kind`, 16 MiB `payload`.
    pub const DEFAULT: Self = Self {
        max_kind_len: 256,
        max_payload_len: 16 * 1024 * 1024,
    };
}

impl Default for EventCfg {
    fn default() -> Self {
        Self::DEFAULT
    }
}

impl Write for Event {
    fn write(&self, buf: &mut impl BufMut) {
        // `Vec<u8>: Write` length-prefixes then writes the bytes. We encode the
        // `kind` as its UTF-8 bytes (a `Vec<u8>` on the wire) and the `payload`
        // as-is, so decoding is the symmetric `read_range` of each.
        self.kind.as_bytes().to_vec().write(buf);
        self.payload.write(buf);
    }
}

impl EncodeSize for Event {
    fn encode_size(&self) -> usize {
        self.kind.as_bytes().to_vec().encode_size() + self.payload.encode_size()
    }
}

impl Read for Event {
    type Cfg = EventCfg;

    fn read_cfg(buf: &mut impl Buf, cfg: &EventCfg) -> Result<Self, CodecError> {
        let kind_bytes = <Vec<u8>>::read_range(buf, 0..=cfg.max_kind_len)?;
        let kind = String::from_utf8(kind_bytes)
            .map_err(|_| CodecError::Invalid("Event", "kind is not valid UTF-8"))?;
        let payload = <Vec<u8>>::read_range(buf, 0..=cfg.max_payload_len)?;
        Ok(Self { kind, payload })
    }
}

#[cfg(test)]
mod tests {
    use super::{Event, EventCfg};
    use commonware_codec::{Decode as _, Encode as _};

    #[test]
    fn round_trips_through_codec() {
        let event = Event::new("tool_call", vec![1, 2, 3, 0xff, 0]);
        let bytes = event.encode();
        let decoded = Event::decode_cfg(bytes, &EventCfg::DEFAULT).expect("decode");
        assert_eq!(event, decoded);
    }

    #[test]
    fn round_trips_empty_payload() {
        let event = Event::new("user_msg", Vec::new());
        let bytes = event.encode();
        let decoded = Event::decode_cfg(bytes, &EventCfg::DEFAULT).expect("decode");
        assert_eq!(event, decoded);
        assert!(decoded.payload.is_empty());
    }

    #[test]
    fn rejects_payload_over_cap() {
        let event = Event::new("k", vec![0u8; 64]);
        let bytes = event.encode();
        let tight = EventCfg {
            max_kind_len: 256,
            max_payload_len: 8,
        };
        assert!(Event::decode_cfg(bytes, &tight).is_err());
    }
}