Skip to main content

polyc_eventlog_model/
event.rs

1//! The [`Event`] item stored in a journal and its [`commonware_codec`] wiring.
2//!
3//! An [`Event`] is the unit the journal persists: a `kind` discriminator plus
4//! an opaque `payload`. This crate is deliberately **payload-agnostic** — the
5//! payload is a `Vec<u8>` of buffa-encoded bytes whose concrete schema lives in
6//! `polyc-proto`'s `events.proto`. The event log neither encodes nor
7//! interprets it; it round-trips the bytes verbatim.
8//!
9//! The journal primitive (`commonware_storage::journal::contiguous::variable`)
10//! requires its item type to implement [`commonware_codec`]'s [`Write`],
11//! [`EncodeSize`], and [`Read`] traits. [`Event`] implements them by length-
12//! prefixing the `kind` UTF-8 bytes and the `payload` bytes, exactly the
13//! variable-size pattern documented in the `commonware-codec` crate.
14
15use bytes::{Buf, BufMut};
16use commonware_codec::{
17    EncodeSize, Error as CodecError, Read, ReadExt as _, ReadRangeExt as _, Write,
18};
19
20use crate::taint::TrustTag;
21
22/// A single conversation event: a `kind` tag, a [`TrustTag`] provenance
23/// capability, and an opaque encoded payload.
24///
25/// `kind` is a short string discriminator (e.g. `"user_msg"`,
26/// `"planner_decision"`, `"tool_call"`). `payload` is opaque to this crate —
27/// it is the buffa-encoded body of whichever `events.proto` message the `kind`
28/// names, stored and replayed byte-for-byte. `trust` is the CaMeL-style trust
29/// tag assigned at ingress (see [`TrustTag`]); it travels with the event
30/// through the durable log so a data-flow policy can reason over provenance
31/// without re-decoding payloads.
32///
33/// The position (turn/seq ordering) is **not** carried in the event itself: the
34/// journal assigns each appended event a monotonically increasing position, and
35/// the physical journal adapter yields events in that append order. See the
36/// adapter's documentation for the ordering contract.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct Event {
39    /// Discriminator naming the payload's schema (e.g. `"tool_call"`).
40    pub kind: String,
41    /// Provenance/trust capability assigned at ingress.
42    pub trust: TrustTag,
43    /// Opaque, buffa-encoded payload bytes. Round-tripped verbatim.
44    pub payload: Vec<u8>,
45}
46
47impl Event {
48    /// Construct an unclassified ([`TrustTag::Unspecified`]) event from a
49    /// `kind` and an owned payload — for control/marker events that carry no
50    /// external content provenance.
51    #[must_use]
52    pub fn new(kind: impl Into<String>, payload: Vec<u8>) -> Self {
53        Self::with_trust(kind, payload, TrustTag::Unspecified)
54    }
55
56    /// Construct a [`TrustTag::TrustedUser`] event — an authenticated
57    /// principal's own message.
58    #[must_use]
59    pub fn trusted(kind: impl Into<String>, payload: Vec<u8>) -> Self {
60        Self::with_trust(kind, payload, TrustTag::TrustedUser)
61    }
62
63    /// Construct a [`TrustTag::QuarantinedContent`] event — tool output,
64    /// fetched content, or otherwise untrusted inbound content.
65    #[must_use]
66    pub fn quarantined(kind: impl Into<String>, payload: Vec<u8>) -> Self {
67        Self::with_trust(kind, payload, TrustTag::QuarantinedContent)
68    }
69
70    /// Construct an event with an explicit [`TrustTag`].
71    #[must_use]
72    pub fn with_trust(kind: impl Into<String>, payload: Vec<u8>, trust: TrustTag) -> Self {
73        Self {
74            kind: kind.into(),
75            trust,
76            payload,
77        }
78    }
79}
80
81/// Decode-time bounds for an [`Event`], supplied as the journal's
82/// `codec_config`.
83///
84/// `commonware-codec` requires an explicit maximum length when reading any
85/// variable-length field, so that a corrupt or hostile length prefix cannot
86/// trigger an unbounded allocation. These caps are applied when decoding the
87/// `kind` and `payload` of each event during a physical journal replay.
88///
89/// [`EventCfg::DEFAULT`] provides generous defaults suitable for conversation
90/// events; tighten them per deployment if desired.
91#[derive(Debug, Clone, Copy)]
92pub struct EventCfg {
93    /// Maximum byte length permitted for a decoded `kind` string.
94    pub max_kind_len: usize,
95    /// Maximum byte length permitted for a decoded `payload`.
96    pub max_payload_len: usize,
97}
98
99impl EventCfg {
100    /// Default decode bounds: 256-byte `kind`, 16 MiB `payload`.
101    pub const DEFAULT: Self = Self {
102        max_kind_len: 256,
103        max_payload_len: 16 * 1024 * 1024,
104    };
105}
106
107impl Default for EventCfg {
108    fn default() -> Self {
109        Self::DEFAULT
110    }
111}
112
113impl Write for Event {
114    fn write(&self, buf: &mut impl BufMut) {
115        // Forward-compatible layout: the original fields (`kind` UTF-8 bytes as a
116        // length-prefixed `Vec<u8>`, then the `payload`) are written FIRST, in
117        // their original order and encoding, and the trust discriminant byte is
118        // APPENDED last. Keeping the trust byte at the tail — rather than
119        // prepending it — means a record written before the trust field existed
120        // (kind + payload, no trailing byte) is still a valid prefix of this
121        // layout: the journal frames each item, so on read "no bytes left after
122        // payload" is unambiguously the absence of a trust byte (→ Unspecified).
123        // Deploying the trust substrate therefore does not invalidate a single
124        // pre-existing event-log record.
125        self.kind.as_bytes().to_vec().write(buf);
126        self.payload.write(buf);
127        self.trust.as_u8().write(buf);
128    }
129}
130
131impl EncodeSize for Event {
132    fn encode_size(&self) -> usize {
133        self.trust.as_u8().encode_size()
134            + self.kind.as_bytes().to_vec().encode_size()
135            + self.payload.encode_size()
136    }
137}
138
139impl Read for Event {
140    type Cfg = EventCfg;
141
142    fn read_cfg(buf: &mut impl Buf, cfg: &EventCfg) -> Result<Self, CodecError> {
143        let kind_bytes = <Vec<u8>>::read_range(buf, 0..=cfg.max_kind_len)?;
144        let kind = String::from_utf8(kind_bytes)
145            .map_err(|_| CodecError::Invalid("Event", "kind is not valid UTF-8"))?;
146        let payload = <Vec<u8>>::read_range(buf, 0..=cfg.max_payload_len)?;
147        // Forward-compatible trust tag: the journal frames each item, so any
148        // bytes remaining after the payload are the trailing trust discriminant
149        // written by the current layout. A record with none left predates the
150        // trust field and is read as `Unspecified` — NOT a decode failure — so
151        // existing logs survive the upgrade. A present-but-unknown byte is still
152        // corruption (`from_u8` rejects it); `Decode` enforces that exactly one
153        // trailing byte was consumed (any extra is `ExtraData`).
154        let trust = if buf.has_remaining() {
155            TrustTag::from_u8(u8::read(buf)?)
156                .ok_or(CodecError::Invalid("Event", "unknown trust tag"))?
157        } else {
158            TrustTag::Unspecified
159        };
160        Ok(Self {
161            kind,
162            trust,
163            payload,
164        })
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::{Event, EventCfg};
171    use crate::taint::TrustTag;
172    use bytes::BytesMut;
173    use commonware_codec::{Decode as _, Encode as _, Write as _};
174
175    // Deploy-safety: a record written in the OLD on-disk layout — `kind` then
176    // `payload`, with NO trailing trust byte (every record that predates the
177    // trust field) — must still decode, classified as `TrustTag::Unspecified`.
178    // The journal frames each item, so the decoder sees "no bytes remain after
179    // payload" and reads the absence as Unspecified rather than failing. This is
180    // what stops deploying the trust substrate from wiping existing event logs.
181    #[test]
182    fn old_layout_without_trust_byte_decodes_as_unspecified() {
183        // Reproduce the pre-trust-field encoding exactly: the `kind` UTF-8 bytes
184        // as a length-prefixed `Vec<u8>`, then the `payload` — and nothing else.
185        let mut buf = BytesMut::new();
186        b"user_msg".to_vec().write(&mut buf);
187        b"summarize my inbox".to_vec().write(&mut buf);
188        let decoded =
189            Event::decode_cfg(buf.freeze(), &EventCfg::DEFAULT).expect("old record must decode");
190        assert_eq!(decoded.kind, "user_msg");
191        assert_eq!(decoded.payload, b"summarize my inbox".to_vec());
192        assert_eq!(
193            decoded.trust,
194            TrustTag::Unspecified,
195            "a record with no trust byte must read as Unspecified"
196        );
197    }
198
199    #[test]
200    fn round_trips_through_codec() {
201        let event = Event::new("tool_call", vec![1, 2, 3, 0xff, 0]);
202        let bytes = event.encode();
203        let decoded = Event::decode_cfg(bytes, &EventCfg::DEFAULT).expect("decode");
204        assert_eq!(event, decoded);
205    }
206
207    #[test]
208    fn trust_tag_survives_codec_round_trip() {
209        for event in [
210            Event::trusted("user_msg", b"hi".to_vec()),
211            Event::quarantined("output_msg", b"<tool result>".to_vec()),
212            Event::new("turn_start", Vec::new()),
213        ] {
214            let decoded = Event::decode_cfg(event.encode(), &EventCfg::DEFAULT).expect("decode");
215            assert_eq!(decoded, event);
216            assert_eq!(decoded.trust, event.trust);
217        }
218    }
219
220    #[test]
221    fn rejects_unknown_trust_tag_byte() {
222        // The trust discriminant is the LAST byte of the encoding (trailing, for
223        // backward-readability); a present value naming no known tag is
224        // corruption and must fail to decode.
225        let mut bytes = Event::trusted("user_msg", b"x".to_vec()).encode().to_vec();
226        let last = bytes.len() - 1;
227        bytes[last] = 0xff;
228        assert!(Event::decode_cfg(&bytes[..], &EventCfg::DEFAULT).is_err());
229    }
230
231    #[test]
232    fn round_trips_empty_payload() {
233        let event = Event::new("user_msg", Vec::new());
234        let bytes = event.encode();
235        let decoded = Event::decode_cfg(bytes, &EventCfg::DEFAULT).expect("decode");
236        assert_eq!(event, decoded);
237        assert!(decoded.payload.is_empty());
238    }
239
240    #[test]
241    fn rejects_payload_over_cap() {
242        let event = Event::new("k", vec![0u8; 64]);
243        let bytes = event.encode();
244        let tight = EventCfg {
245            max_kind_len: 256,
246            max_payload_len: 8,
247        };
248        assert!(Event::decode_cfg(bytes, &tight).is_err());
249    }
250}