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 via `prev_inventory`.
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/// The tolerance is **inbound-only** — an event
227/// whose `unmapped` is non-empty is refused by `validate_event` (and
228/// would be refused by the broker's schema gate, which declares
229/// `additionalProperties: false`). Consumers may parse newer-node
230/// events; they may not republish them carrying unknown fields.
231///
232/// # Additive tolerance is TOP-LEVEL ONLY
233///
234/// The `deny_unknown_fields` attribute on every nested struct
235/// ([`Metadata`], [`AgentIdentity`], [`Product`], [`CharterFile`],
236/// [`EventMetrics`], [`Fingerprint`]) means an unknown field INSIDE
237/// one of those objects fails deserialization for the whole event —
238/// it does NOT flow into `unmapped`. Cross-version replay of a
239/// mixed-fleet stream during a rolling upgrade therefore requires
240/// that nested-object shape additions bump `config_version` in
241/// lockstep with the schema; only newly-added TOP-LEVEL fields are
242/// safe to deploy incrementally. This asymmetry is deliberate:
243/// nested types are the audit-trail schema surface consumers commit
244/// to (SIEM ingestion pipelines depend on their exact shape), while
245/// the top-level union tolerates additive OCSF evolution so
246/// consumers can continue to parse events emitted by newer nodes
247/// during a rolling deploy of the harness itself.
248#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
249pub struct OcsfEvent {
250    /// Event metadata.
251    pub metadata: Metadata,
252    /// Class enum (serialized as `agent.<class>`).
253    pub class_name: EventClass,
254    /// Numeric class uid.
255    pub class_uid: u32,
256    /// OCSF Application Activity category uid.
257    pub category_uid: u8,
258    /// Activity within the class (1 = default activity).
259    pub activity_id: u8,
260    /// `class_uid * 100 + activity_id` per OCSF convention.
261    pub type_uid: u64,
262    /// Epoch milliseconds.
263    pub time: u64,
264    /// ISO-8601 mirror of `time` for human consumers.
265    pub time_iso: String,
266    /// Severity (1 = informational … 6 = fatal).
267    pub severity_id: u8,
268    /// Outcome status.
269    pub status_id: u8,
270    /// Session this event belongs to.
271    pub session_uid: String,
272    /// Agent config-state identity block.
273    pub ai_agent: AgentIdentity,
274    /// Stop reason id, when applicable.
275    #[serde(skip_serializing_if = "Option::is_none")]
276    pub stop_reason_id: Option<u8>,
277    /// Stop reason text: the provider's native value when captured,
278    /// otherwise the normalized [`crate::StopReason`] caption.
279    #[serde(skip_serializing_if = "Option::is_none")]
280    pub stop_reason: Option<String>,
281    /// Class-specific payload.
282    pub payload: serde_json::Value,
283    /// Token metrics (ATIF-mirrored names).
284    #[serde(skip_serializing_if = "Option::is_none")]
285    pub metrics: Option<EventMetrics>,
286    /// Per-forward-pass inventory fingerprint (roadmap, flag-gated).
287    #[serde(skip_serializing_if = "Option::is_none")]
288    pub inventory: Option<Fingerprint>,
289    /// Previous inventory fingerprint in the chain (see `inventory`).
290    #[serde(skip_serializing_if = "Option::is_none")]
291    pub prev_inventory: Option<Fingerprint>,
292    /// Unknown fields captured on inbound parse (evolution tolerance).
293    #[serde(default, skip_serializing_if = "serde_json::Map::is_empty", flatten)]
294    pub unmapped: serde_json::Map<String, serde_json::Value>,
295}
296
297/// Builder for [`OcsfEvent`] enforcing invariants at construction.
298#[derive(Debug)]
299pub struct OcsfEventBuilder {
300    class: EventClass,
301    session_uid: String,
302    ai_agent: AgentIdentity,
303    seq: u64,
304    activity_id: u8,
305    severity_id: u8,
306    status: StatusId,
307    stop_reason: Option<crate::StopReason>,
308    native_stop_reason: Option<String>,
309    payload: serde_json::Value,
310    metrics: Option<EventMetrics>,
311    inventory: Option<Fingerprint>,
312    prev_inventory: Option<Fingerprint>,
313}
314
315impl OcsfEventBuilder {
316    /// Start building an event of `class` for `session_uid` with sequence `seq`.
317    pub fn new(class: EventClass, session_uid: impl Into<String>, ai_agent: AgentIdentity, seq: u64) -> Self {
318        Self {
319            class,
320            session_uid: session_uid.into(),
321            ai_agent,
322            seq,
323            activity_id: 1,
324            severity_id: 1,
325            status: StatusId::Success,
326            stop_reason: None,
327            native_stop_reason: None,
328            payload: serde_json::Value::Null,
329            metrics: None,
330            inventory: None,
331            prev_inventory: None,
332        }
333    }
334
335    /// Set severity (default 1 = informational).
336    pub fn severity(mut self, id: u8) -> Self {
337        self.severity_id = id;
338        self
339    }
340
341    /// Set outcome status (default success).
342    pub fn status(mut self, s: StatusId) -> Self {
343        self.status = s;
344        self
345    }
346
347    /// Attach a stop reason.
348    ///
349    /// Clears any provider-native text a prior `stop_reason_native`
350    /// call attached: the pair travels together, and re-normalizing
351    /// without re-sourcing would otherwise emit a contradictory
352    /// `(stop_reason_id, native text)` combination that validation
353    /// cannot catch (native text is free-form).
354    pub fn stop_reason(mut self, r: crate::StopReason) -> Self {
355        self.stop_reason = Some(r);
356        self.native_stop_reason = None;
357        self
358    }
359
360    /// Attach a normalized reason and the provider's source-native value.
361    pub fn stop_reason_native(mut self, reason: crate::StopReason, native: impl Into<String>) -> Self {
362        self.stop_reason = Some(reason);
363        self.native_stop_reason = Some(native.into());
364        self
365    }
366
367    /// Attach a class-specific payload.
368    pub fn payload(mut self, p: serde_json::Value) -> Self {
369        self.payload = p;
370        self
371    }
372
373    /// Attach token metrics.
374    pub fn metrics(mut self, m: EventMetrics) -> Self {
375        self.metrics = Some(m);
376        self
377    }
378
379    /// Attach inventory fingerprints (roadmap feature).
380    pub fn inventory(mut self, current: Fingerprint, previous: Option<Fingerprint>) -> Self {
381        self.inventory = Some(current);
382        self.prev_inventory = previous;
383        self
384    }
385
386    /// Build, validating JCS-safety of all counters.
387    pub fn build(self) -> Result<OcsfEvent, av_core::CoreError> {
388        check_jcs_safe(self.seq)?;
389        if let Some(m) = &self.metrics {
390            for v in [
391                m.prompt_tokens,
392                m.completion_tokens,
393                m.cached_tokens,
394                m.pruned_tokens,
395                m.pruning_ratio_millis,
396            ]
397            .into_iter()
398            .flatten()
399            {
400                check_jcs_safe(v)?;
401            }
402            // Schema declares
403            // `pruning_ratio_millis` as an integer in `[0, 1000]`
404            // (0.0%–100.0%). The prior build path only enforced
405            // JCS-safety, so a caller could construct an event
406            // with `pruning_ratio_millis: 5000` (500%) that later
407            // failed strict schema validation on ingest. Cap here
408            // so both emitter and validator agree at build time.
409            if let Some(ratio) = m.pruning_ratio_millis {
410                if ratio > 1000 {
411                    // The schema's declared max is 1000 (100.0 %);
412                    // reuse the JCS-unsafe error variant here since
413                    // both classes represent "out-of-declared-range
414                    // integer that would silently misrepresent at
415                    // the wire boundary."
416                    return Err(av_core::CoreError::UnsafeInteger(ratio));
417                }
418            }
419        }
420        // Mirror the JCS-safety guard for
421        // ai_agent.ttl_remaining_s at build time so signed emits from
422        // this crate can never produce an event that the wire-side
423        // validator would refuse.
424        if let Some(ttl) = self.ai_agent.ttl_remaining_s {
425            check_jcs_safe(ttl)?;
426        }
427        // The JSON schema (`schemas/ocsf-agent-event.schema.json`)
428        // and the Rust validator both declare `severity_id` in 1..=6.
429        // The builder previously accepted any u8, so a caller passing
430        // `.severity(0)` or `.severity(7)` produced a signed/journaled
431        // event that then failed the embedded broker's schema validator
432        // at publish — surfacing as an opaque Backend error and marking
433        // the session capture-failed. Refuse at build time, matching
434        // the pruning_ratio_millis fix pattern above.
435        if !(1..=6).contains(&self.severity_id) {
436            return Err(av_core::CoreError::UnsafeInteger(u64::from(self.severity_id)));
437        }
438        let now = av_core::time::now_ms();
439        check_jcs_safe(now)?;
440        let class_uid = self.class.class_uid();
441        Ok(OcsfEvent {
442            metadata: Metadata {
443                version: OCSF_VERSION.to_owned(),
444                uid: av_core::new_event_uid(),
445                product: Product {
446                    name: PRODUCT_NAME.to_owned(),
447                    vendor_name: "AgentVisor AI".to_owned(),
448                    version: env!("CARGO_PKG_VERSION").to_owned(),
449                },
450                sequence: self.seq,
451            },
452            class_name: self.class,
453            class_uid,
454            category_uid: CATEGORY_UID,
455            activity_id: self.activity_id,
456            type_uid: u64::from(class_uid) * 100 + u64::from(self.activity_id),
457            time: now,
458            time_iso: av_core::time::iso8601_ms(now),
459            severity_id: self.severity_id,
460            status_id: self.status.id(),
461            session_uid: self.session_uid,
462            ai_agent: self.ai_agent,
463            stop_reason_id: self.stop_reason.map(crate::StopReason::id),
464            stop_reason: self
465                .native_stop_reason
466                .or_else(|| self.stop_reason.map(|reason| reason.caption().to_owned())),
467            payload: self.payload,
468            metrics: self.metrics,
469            inventory: self.inventory,
470            prev_inventory: self.prev_inventory,
471            unmapped: serde_json::Map::new(),
472        })
473    }
474}
475
476#[cfg(test)]
477mod tests {
478    #![allow(
479        clippy::unwrap_used,
480        clippy::expect_used,
481        clippy::panic,
482        clippy::indexing_slicing
483    )]
484
485    use super::*;
486    use crate::StopReason;
487
488    fn identity() -> AgentIdentity {
489        AgentIdentity {
490            version: "2.1.0".into(),
491            charter: "billing-support".into(),
492            instance_uid: "agent-inst-42".into(),
493            ttl_remaining_s: Some(731),
494        }
495    }
496
497    /// Re-normalizing via `stop_reason` after `stop_reason_native` must
498    /// drop the stale provider-native text: the pair travels together,
499    /// and validation cannot catch a contradictory `(id, native)`
500    /// combination because native text is free-form.
501    #[test]
502    fn stop_reason_rebind_clears_stale_native_text() {
503        let ev = OcsfEventBuilder::new(EventClass::StopReason, "sess-1", identity(), 7)
504            .stop_reason_native(StopReason::Stop, "stop")
505            .stop_reason(StopReason::PolicyBlocked)
506            .build()
507            .unwrap();
508        assert_eq!(ev.stop_reason_id, Some(StopReason::PolicyBlocked.id()));
509        assert_eq!(
510            ev.stop_reason.as_deref(),
511            Some(StopReason::PolicyBlocked.caption()),
512            "caption must re-derive from the rebound reason, not the stale native text"
513        );
514    }
515
516    #[test]
517    fn build_binds_config_state() {
518        let ev = OcsfEventBuilder::new(EventClass::StopReason, "sess-1", identity(), 7)
519            .stop_reason(StopReason::LoopDetected)
520            .status(StatusId::Failure)
521            .build()
522            .unwrap();
523        assert_eq!(ev.ai_agent.version, "2.1.0");
524        assert_eq!(ev.ai_agent.charter.name, "billing-support");
525        assert_eq!(ev.ai_agent.instance_uid, "agent-inst-42");
526        assert_eq!(ev.stop_reason_id, Some(91));
527        assert_eq!(ev.stop_reason.as_deref(), Some("Loop Detected"));
528        assert_eq!(ev.class_uid, 9902);
529        assert_eq!(ev.type_uid, 990_201);
530        assert_eq!(ev.metadata.sequence, 7);
531        assert_eq!(ev.category_uid, CATEGORY_UID);
532        assert_eq!(ev.metadata.product.name, PRODUCT_NAME);
533        assert_eq!(ev.metadata.version, OCSF_VERSION);
534    }
535
536    /// `pruning_ratio_millis` is a permille ratio
537    /// (0..=1000 → 0.0%..=100.0%). The schema declares
538    /// the max, but the builder used to only enforce JCS-safety,
539    /// so `Some(5000)` (500%) built successfully and only failed
540    /// at strict-validate time downstream. Now caught at build.
541    #[test]
542    fn build_refuses_pruning_ratio_millis_above_1000() {
543        let outcome = OcsfEventBuilder::new(EventClass::ToolCall, "sess-x", identity(), 1)
544            .metrics(EventMetrics {
545                prompt_tokens: Some(10),
546                completion_tokens: Some(10),
547                cached_tokens: Some(0),
548                pruned_tokens: Some(5),
549                pruning_ratio_millis: Some(5000),
550            })
551            .build();
552        assert!(outcome.is_err(), "over-cap ratio must fail build");
553        // At the boundary, 1000 (100.0 %) still builds.
554        let outcome = OcsfEventBuilder::new(EventClass::ToolCall, "sess-x", identity(), 1)
555            .metrics(EventMetrics {
556                pruning_ratio_millis: Some(1000),
557                ..EventMetrics::default()
558            })
559            .build();
560        assert!(outcome.is_ok(), "boundary ratio 1000 must still build");
561    }
562
563    #[test]
564    fn serialized_shape_has_required_fields() {
565        let ev = OcsfEventBuilder::new(EventClass::ToolCall, "sess-2", identity(), 1)
566            .payload(serde_json::json!({"tool": "db_write", "allowed": false}))
567            .status(StatusId::Failure)
568            .build()
569            .unwrap();
570        let v = serde_json::to_value(&ev).unwrap();
571        for key in [
572            "metadata",
573            "class_name",
574            "class_uid",
575            "type_uid",
576            "time",
577            "time_iso",
578            "severity_id",
579            "status_id",
580            "session_uid",
581            "ai_agent",
582            "payload",
583        ] {
584            assert!(v.get(key).is_some(), "missing {key}: {v}");
585        }
586        assert_eq!(v["class_name"], "agent.tool_call");
587        assert_eq!(v["ai_agent"]["instance_uid"], "agent-inst-42");
588        // Absent optionals must be omitted, not null (schema strictness).
589        assert!(v.get("stop_reason_id").is_none());
590    }
591
592    #[test]
593    fn unsafe_counter_rejected() {
594        let m = EventMetrics {
595            prompt_tokens: Some((1 << 53) + 1),
596            ..Default::default()
597        };
598        let err = OcsfEventBuilder::new(EventClass::Compression, "s", identity(), 1)
599            .metrics(m)
600            .build();
601        assert!(err.is_err(), "2^53+1 must be rejected for JCS safety");
602    }
603
604    #[test]
605    fn unknown_inbound_fields_preserved() {
606        let ev = OcsfEventBuilder::new(EventClass::Session, "s", identity(), 1)
607            .build()
608            .unwrap();
609        let mut v = serde_json::to_value(&ev).unwrap();
610        v["future_field_from_v2"] = serde_json::json!({"x": 1});
611        let parsed: OcsfEvent = serde_json::from_value(v).unwrap();
612        assert!(
613            parsed.unmapped.contains_key("future_field_from_v2"),
614            "unknown fields must be captured: {:?}",
615            parsed.unmapped
616        );
617    }
618
619    #[test]
620    fn roundtrip_preserves_equality() {
621        let ev = OcsfEventBuilder::new(EventClass::Identity, "sess-9", identity(), 3)
622            .metrics(EventMetrics {
623                prompt_tokens: Some(120),
624                completion_tokens: Some(30),
625                cached_tokens: Some(64),
626                ..Default::default()
627            })
628            .build()
629            .unwrap();
630        let json = serde_json::to_string(&ev).unwrap();
631        let back: OcsfEvent = serde_json::from_str(&json).unwrap();
632        assert_eq!(ev, back);
633    }
634
635    #[test]
636    fn all_classes_have_unique_uids_and_topics() {
637        let classes = EventClass::all();
638        // Concrete lower bound catches an `all() -> empty slice` stub.
639        assert!(
640            classes.len() >= 6,
641            "EventClass::all shrank unexpectedly: {}",
642            classes.len()
643        );
644        let mut uids: Vec<u32> = classes.iter().map(|c| c.class_uid()).collect();
645        let mut topics: Vec<&str> = classes.iter().map(|c| c.topic()).collect();
646        uids.sort_unstable();
647        uids.dedup();
648        topics.sort_unstable();
649        topics.dedup();
650        assert_eq!(uids.len(), classes.len());
651        assert_eq!(topics.len(), classes.len());
652    }
653
654    #[test]
655    fn status_id_wire_values_are_ocsf_conformant() {
656        // OCSF: 0 unknown, 1 success, 2 failure. Concrete numeric asserts
657        // detect any `id() -> constant` stub of the mapping.
658        assert_eq!(StatusId::Unknown.id(), 0);
659        assert_eq!(StatusId::Success.id(), 1);
660        assert_eq!(StatusId::Failure.id(), 2);
661    }
662}