Skip to main content

av_events/
model.rs

1//! Event envelope, agent identity block, event classes, metrics, fingerprints.
2
3use av_core::error::check_jcs_safe;
4use serde::{Deserialize, Serialize};
5
6/// OCSF schema version stamped in every event's metadata (upstream release the
7/// authored profile targets, per the brief).
8pub const OCSF_VERSION: &str = "1.10.0";
9
10/// Product name stamped in metadata.
11pub const PRODUCT_NAME: &str = "agentvisor-ai";
12
13/// OCSF Application Activity category uid.
14pub const CATEGORY_UID: u8 = 6;
15
16/// Event classes. One Bridge topic exists per class (Module F topic layout).
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
18#[non_exhaustive]
19pub enum EventClass {
20    /// MCP / REST tool-call verdicts (Module B).
21    #[serde(rename = "agent.tool_call")]
22    ToolCall,
23    /// Stop-reason emissions incl. loop halts (Modules A/E).
24    #[serde(rename = "agent.stop_reason")]
25    StopReason,
26    /// Receipt issuance notifications (Module G).
27    #[serde(rename = "agent.receipt")]
28    Receipt,
29    /// Context-compression metrics (Module C).
30    #[serde(rename = "agent.compression")]
31    Compression,
32    /// NHI identity validation verdicts (Module D).
33    #[serde(rename = "agent.identity")]
34    Identity,
35    /// Session lifecycle (open/close/promote).
36    #[serde(rename = "agent.session")]
37    Session,
38}
39
40impl EventClass {
41    /// Extension-range class uid (documented in schemas/ocsf-agent-event.schema.json).
42    pub fn class_uid(self) -> u32 {
43        match self {
44            Self::ToolCall => 9901,
45            Self::StopReason => 9902,
46            Self::Receipt => 9903,
47            Self::Compression => 9904,
48            Self::Identity => 9905,
49            Self::Session => 9906,
50        }
51    }
52
53    /// Topic name for the Bridge (`agent.<class>`).
54    pub fn topic(self) -> &'static str {
55        match self {
56            Self::ToolCall => "agent.tool_call",
57            Self::StopReason => "agent.stop_reason",
58            Self::Receipt => "agent.receipt",
59            Self::Compression => "agent.compression",
60            Self::Identity => "agent.identity",
61            Self::Session => "agent.session",
62        }
63    }
64
65    /// All classes (used by the manifest provisioner).
66    pub fn all() -> &'static [EventClass] {
67        &[
68            Self::ToolCall,
69            Self::StopReason,
70            Self::Receipt,
71            Self::Compression,
72            Self::Identity,
73            Self::Session,
74        ]
75    }
76}
77
78/// Outcome status (OCSF convention: 0 unknown, 1 success, 2 failure).
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
80#[serde(rename_all = "snake_case")]
81#[non_exhaustive]
82pub enum StatusId {
83    /// Not determined.
84    Unknown,
85    /// Action allowed / completed.
86    Success,
87    /// Action blocked / failed.
88    Failure,
89}
90
91impl StatusId {
92    /// Numeric wire value.
93    pub fn id(self) -> u8 {
94        match self {
95            Self::Unknown => 0,
96            Self::Success => 1,
97            Self::Failure => 2,
98        }
99    }
100}
101
102/// The agent config-state identity block bound into every event (Module E,
103/// PR #1 pattern: version + charter + instance_uid).
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(deny_unknown_fields)]
106pub struct CharterFile {
107    /// Charter document file name.
108    pub name: String,
109    /// OCSF File type id 1, Regular File.
110    pub type_id: u8,
111}
112
113impl From<String> for CharterFile {
114    fn from(name: String) -> Self {
115        Self { name, type_id: 1 }
116    }
117}
118
119impl From<&str> for CharterFile {
120    fn from(name: &str) -> Self {
121        Self::from(name.to_owned())
122    }
123}
124
125/// OCSF Product object embedded in event metadata.
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127#[serde(deny_unknown_fields)]
128pub struct Product {
129    /// Product name.
130    pub name: String,
131    /// Product vendor.
132    pub vendor_name: String,
133    /// Product version.
134    pub version: String,
135}
136
137/// Agent configuration state bound into every emitted event.
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139#[serde(deny_unknown_fields)]
140pub struct AgentIdentity {
141    /// Deployed agent version (changes on deploy).
142    pub version: String,
143    /// Agent charter — the operating mandate/config name (changes on deploy).
144    pub charter: CharterFile,
145    /// Unique id of this running instance.
146    pub instance_uid: String,
147    /// Remaining identity-token TTL at emission time, seconds (Module D binds
148    /// TTL scope into the identity block).
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub ttl_remaining_s: Option<u64>,
151}
152
153/// Token metrics mirroring ATIF's `prompt/completion/cached` fields (Module C
154/// mandates the mirror for downstream compatibility).
155#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
156#[serde(deny_unknown_fields)]
157pub struct EventMetrics {
158    /// Prompt tokens (approximate unless provider-reported).
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub prompt_tokens: Option<u64>,
161    /// Completion tokens.
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub completion_tokens: Option<u64>,
164    /// Provider-cached tokens.
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub cached_tokens: Option<u64>,
167    /// Tokens pruned by compression (Module C emission).
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub pruned_tokens: Option<u64>,
170    /// Compression ratio ×1000 (integer to stay JCS-exact; 350 = 35.0 %).
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub pruning_ratio_millis: Option<u64>,
173}
174
175/// OCSF Fingerprint observable (id 30) — roadmap: per-forward-pass inventory
176/// fingerprinting of tool schemas + sampling params, chained like `prev_event`.
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178#[serde(deny_unknown_fields)]
179pub struct Fingerprint {
180    /// Hash algorithm id (3 = SHA-256 per OCSF).
181    pub algorithm_id: u8,
182    /// Algorithm caption.
183    pub algorithm: String,
184    /// Serialization id (2 = JCS canonical JSON).
185    pub serialization_id: u8,
186    /// Serialization caption.
187    pub serialization: String,
188    /// Hex digest value.
189    pub value: String,
190}
191
192impl Fingerprint {
193    /// SHA-256-over-JCS fingerprint of a JSON value.
194    pub fn sha256_jcs(value_hex: String) -> Self {
195        Self {
196            algorithm_id: 3,
197            algorithm: "SHA-256".to_owned(),
198            serialization_id: 2,
199            serialization: "JCS".to_owned(),
200            value: value_hex,
201        }
202    }
203}
204
205/// Event metadata (OCSF `metadata` object).
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
207#[serde(deny_unknown_fields)]
208pub struct Metadata {
209    /// OCSF schema version.
210    pub version: String,
211    /// Unique event uid (UUIDv7).
212    pub uid: String,
213    /// Emitting product.
214    pub product: Product,
215    /// Per-session sequence number — the authoritative intra-session order
216    /// (wall clocks are never trusted for ordering).
217    pub sequence: u64,
218}
219
220/// A schema-conformant agent event.
221///
222/// Unknown inbound fields at the TOP LEVEL are preserved in
223/// [`Self::unmapped`] (never silently dropped); outbound serialization
224/// is always the current schema shape.
225///
226/// # Round-34 F3 — additive tolerance is TOP-LEVEL ONLY
227///
228/// The `deny_unknown_fields` attribute on every nested struct
229/// ([`Metadata`], [`AgentIdentity`], [`Product`], [`CharterFile`],
230/// [`EventMetrics`], [`Fingerprint`]) means an unknown field INSIDE
231/// one of those objects fails deserialization for the whole event —
232/// it does NOT flow into `unmapped`. Cross-version replay of a
233/// mixed-fleet stream during a rolling upgrade therefore requires
234/// that nested-object shape additions bump `config_version` in
235/// lockstep with the schema; only newly-added TOP-LEVEL fields are
236/// safe to deploy incrementally. This asymmetry is deliberate:
237/// nested types are the audit-trail schema surface consumers commit
238/// to (SIEM ingestion pipelines depend on their exact shape), while
239/// the top-level union tolerates additive OCSF evolution so
240/// consumers can continue to parse events emitted by newer nodes
241/// during a rolling deploy of the harness itself.
242#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
243pub struct OcsfEvent {
244    /// Event metadata.
245    pub metadata: Metadata,
246    /// Class enum (serialized as `agent.<class>`).
247    pub class_name: EventClass,
248    /// Numeric class uid.
249    pub class_uid: u32,
250    /// OCSF Application Activity category uid.
251    pub category_uid: u8,
252    /// Activity within the class (1 = default activity).
253    pub activity_id: u8,
254    /// `class_uid * 100 + activity_id` per OCSF convention.
255    pub type_uid: u64,
256    /// Epoch milliseconds.
257    pub time: u64,
258    /// ISO-8601 mirror of `time` for human consumers.
259    pub time_iso: String,
260    /// Severity (1 = informational … 6 = fatal).
261    pub severity_id: u8,
262    /// Outcome status.
263    pub status_id: u8,
264    /// Session this event belongs to.
265    pub session_uid: String,
266    /// Agent config-state identity block.
267    pub ai_agent: AgentIdentity,
268    /// Stop reason id, when applicable.
269    #[serde(skip_serializing_if = "Option::is_none")]
270    pub stop_reason_id: Option<u8>,
271    /// Stop reason text: the provider's native value when captured,
272    /// otherwise the normalized [`crate::StopReason`] caption.
273    #[serde(skip_serializing_if = "Option::is_none")]
274    pub stop_reason: Option<String>,
275    /// Class-specific payload.
276    pub payload: serde_json::Value,
277    /// Token metrics (ATIF-mirrored names).
278    #[serde(skip_serializing_if = "Option::is_none")]
279    pub metrics: Option<EventMetrics>,
280    /// Per-forward-pass inventory fingerprint (roadmap, flag-gated).
281    #[serde(skip_serializing_if = "Option::is_none")]
282    pub inventory: Option<Fingerprint>,
283    /// Previous inventory fingerprint (chained like `prev_event`).
284    #[serde(skip_serializing_if = "Option::is_none")]
285    pub prev_inventory: Option<Fingerprint>,
286    /// Unknown fields captured on inbound parse (evolution tolerance).
287    #[serde(default, skip_serializing_if = "serde_json::Map::is_empty", flatten)]
288    pub unmapped: serde_json::Map<String, serde_json::Value>,
289}
290
291/// Builder for [`OcsfEvent`] enforcing invariants at construction.
292#[derive(Debug)]
293pub struct OcsfEventBuilder {
294    class: EventClass,
295    session_uid: String,
296    ai_agent: AgentIdentity,
297    seq: u64,
298    activity_id: u8,
299    severity_id: u8,
300    status: StatusId,
301    stop_reason: Option<crate::StopReason>,
302    native_stop_reason: Option<String>,
303    payload: serde_json::Value,
304    metrics: Option<EventMetrics>,
305    inventory: Option<Fingerprint>,
306    prev_inventory: Option<Fingerprint>,
307}
308
309impl OcsfEventBuilder {
310    /// Start building an event of `class` for `session_uid` with sequence `seq`.
311    pub fn new(class: EventClass, session_uid: impl Into<String>, ai_agent: AgentIdentity, seq: u64) -> Self {
312        Self {
313            class,
314            session_uid: session_uid.into(),
315            ai_agent,
316            seq,
317            activity_id: 1,
318            severity_id: 1,
319            status: StatusId::Success,
320            stop_reason: None,
321            native_stop_reason: None,
322            payload: serde_json::Value::Null,
323            metrics: None,
324            inventory: None,
325            prev_inventory: None,
326        }
327    }
328
329    /// Set severity (default 1 = informational).
330    pub fn severity(mut self, id: u8) -> Self {
331        self.severity_id = id;
332        self
333    }
334
335    /// Set outcome status (default success).
336    pub fn status(mut self, s: StatusId) -> Self {
337        self.status = s;
338        self
339    }
340
341    /// Attach a stop reason.
342    pub fn stop_reason(mut self, r: crate::StopReason) -> Self {
343        self.stop_reason = Some(r);
344        self
345    }
346
347    /// Attach a normalized reason and the provider's source-native value.
348    pub fn stop_reason_native(mut self, reason: crate::StopReason, native: impl Into<String>) -> Self {
349        self.stop_reason = Some(reason);
350        self.native_stop_reason = Some(native.into());
351        self
352    }
353
354    /// Attach a class-specific payload.
355    pub fn payload(mut self, p: serde_json::Value) -> Self {
356        self.payload = p;
357        self
358    }
359
360    /// Attach token metrics.
361    pub fn metrics(mut self, m: EventMetrics) -> Self {
362        self.metrics = Some(m);
363        self
364    }
365
366    /// Attach inventory fingerprints (roadmap feature).
367    pub fn inventory(mut self, current: Fingerprint, previous: Option<Fingerprint>) -> Self {
368        self.inventory = Some(current);
369        self.prev_inventory = previous;
370        self
371    }
372
373    /// Build, validating JCS-safety of all counters.
374    pub fn build(self) -> Result<OcsfEvent, av_core::CoreError> {
375        check_jcs_safe(self.seq)?;
376        if let Some(m) = &self.metrics {
377            for v in [
378                m.prompt_tokens,
379                m.completion_tokens,
380                m.cached_tokens,
381                m.pruned_tokens,
382                m.pruning_ratio_millis,
383            ]
384            .into_iter()
385            .flatten()
386            {
387                check_jcs_safe(v)?;
388            }
389        }
390        let now = av_core::time::now_ms();
391        check_jcs_safe(now)?;
392        let class_uid = self.class.class_uid();
393        Ok(OcsfEvent {
394            metadata: Metadata {
395                version: OCSF_VERSION.to_owned(),
396                uid: av_core::new_event_uid(),
397                product: Product {
398                    name: PRODUCT_NAME.to_owned(),
399                    vendor_name: "AgentVisor AI".to_owned(),
400                    version: env!("CARGO_PKG_VERSION").to_owned(),
401                },
402                sequence: self.seq,
403            },
404            class_name: self.class,
405            class_uid,
406            category_uid: CATEGORY_UID,
407            activity_id: self.activity_id,
408            type_uid: u64::from(class_uid) * 100 + u64::from(self.activity_id),
409            time: now,
410            time_iso: av_core::time::iso8601_ms(now),
411            severity_id: self.severity_id,
412            status_id: self.status.id(),
413            session_uid: self.session_uid,
414            ai_agent: self.ai_agent,
415            stop_reason_id: self.stop_reason.map(crate::StopReason::id),
416            stop_reason: self
417                .native_stop_reason
418                .or_else(|| self.stop_reason.map(|reason| reason.caption().to_owned())),
419            payload: self.payload,
420            metrics: self.metrics,
421            inventory: self.inventory,
422            prev_inventory: self.prev_inventory,
423            unmapped: serde_json::Map::new(),
424        })
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    #![allow(
431        clippy::unwrap_used,
432        clippy::expect_used,
433        clippy::panic,
434        clippy::indexing_slicing
435    )]
436
437    use super::*;
438    use crate::StopReason;
439
440    fn identity() -> AgentIdentity {
441        AgentIdentity {
442            version: "2.1.0".into(),
443            charter: "billing-support".into(),
444            instance_uid: "agent-inst-42".into(),
445            ttl_remaining_s: Some(731),
446        }
447    }
448
449    #[test]
450    fn build_binds_config_state() {
451        let ev = OcsfEventBuilder::new(EventClass::StopReason, "sess-1", identity(), 7)
452            .stop_reason(StopReason::LoopDetected)
453            .status(StatusId::Failure)
454            .build()
455            .unwrap();
456        assert_eq!(ev.ai_agent.version, "2.1.0");
457        assert_eq!(ev.ai_agent.charter.name, "billing-support");
458        assert_eq!(ev.ai_agent.instance_uid, "agent-inst-42");
459        assert_eq!(ev.stop_reason_id, Some(91));
460        assert_eq!(ev.stop_reason.as_deref(), Some("Loop Detected"));
461        assert_eq!(ev.class_uid, 9902);
462        assert_eq!(ev.type_uid, 990_201);
463        assert_eq!(ev.metadata.sequence, 7);
464        assert_eq!(ev.category_uid, CATEGORY_UID);
465        assert_eq!(ev.metadata.product.name, PRODUCT_NAME);
466        assert_eq!(ev.metadata.version, OCSF_VERSION);
467    }
468
469    #[test]
470    fn serialized_shape_has_required_fields() {
471        let ev = OcsfEventBuilder::new(EventClass::ToolCall, "sess-2", identity(), 1)
472            .payload(serde_json::json!({"tool": "db_write", "allowed": false}))
473            .status(StatusId::Failure)
474            .build()
475            .unwrap();
476        let v = serde_json::to_value(&ev).unwrap();
477        for key in [
478            "metadata",
479            "class_name",
480            "class_uid",
481            "type_uid",
482            "time",
483            "time_iso",
484            "severity_id",
485            "status_id",
486            "session_uid",
487            "ai_agent",
488            "payload",
489        ] {
490            assert!(v.get(key).is_some(), "missing {key}: {v}");
491        }
492        assert_eq!(v["class_name"], "agent.tool_call");
493        assert_eq!(v["ai_agent"]["instance_uid"], "agent-inst-42");
494        // Absent optionals must be omitted, not null (schema strictness).
495        assert!(v.get("stop_reason_id").is_none());
496    }
497
498    #[test]
499    fn unsafe_counter_rejected() {
500        let m = EventMetrics {
501            prompt_tokens: Some((1 << 53) + 1),
502            ..Default::default()
503        };
504        let err = OcsfEventBuilder::new(EventClass::Compression, "s", identity(), 1)
505            .metrics(m)
506            .build();
507        assert!(err.is_err(), "2^53+1 must be rejected for JCS safety");
508    }
509
510    #[test]
511    fn unknown_inbound_fields_preserved() {
512        let ev = OcsfEventBuilder::new(EventClass::Session, "s", identity(), 1)
513            .build()
514            .unwrap();
515        let mut v = serde_json::to_value(&ev).unwrap();
516        v["future_field_from_v2"] = serde_json::json!({"x": 1});
517        let parsed: OcsfEvent = serde_json::from_value(v).unwrap();
518        assert!(
519            parsed.unmapped.contains_key("future_field_from_v2"),
520            "unknown fields must be captured: {:?}",
521            parsed.unmapped
522        );
523    }
524
525    #[test]
526    fn roundtrip_preserves_equality() {
527        let ev = OcsfEventBuilder::new(EventClass::Identity, "sess-9", identity(), 3)
528            .metrics(EventMetrics {
529                prompt_tokens: Some(120),
530                completion_tokens: Some(30),
531                cached_tokens: Some(64),
532                ..Default::default()
533            })
534            .build()
535            .unwrap();
536        let json = serde_json::to_string(&ev).unwrap();
537        let back: OcsfEvent = serde_json::from_str(&json).unwrap();
538        assert_eq!(ev, back);
539    }
540
541    #[test]
542    fn all_classes_have_unique_uids_and_topics() {
543        let classes = EventClass::all();
544        // Concrete lower bound catches an `all() -> empty slice` stub.
545        assert!(
546            classes.len() >= 6,
547            "EventClass::all shrank unexpectedly: {}",
548            classes.len()
549        );
550        let mut uids: Vec<u32> = classes.iter().map(|c| c.class_uid()).collect();
551        let mut topics: Vec<&str> = classes.iter().map(|c| c.topic()).collect();
552        uids.sort_unstable();
553        uids.dedup();
554        topics.sort_unstable();
555        topics.dedup();
556        assert_eq!(uids.len(), classes.len());
557        assert_eq!(topics.len(), classes.len());
558    }
559
560    #[test]
561    fn status_id_wire_values_are_ocsf_conformant() {
562        // OCSF: 0 unknown, 1 success, 2 failure. Concrete numeric asserts
563        // detect any `id() -> constant` stub of the mapping.
564        assert_eq!(StatusId::Unknown.id(), 0);
565        assert_eq!(StatusId::Success.id(), 1);
566        assert_eq!(StatusId::Failure.id(), 2);
567    }
568}