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}