Skip to main content

av_events/
validate.rs

1//! Structural validation for events, independent of (and cross-checked
2//! against) the shipped JSON Schema.
3
4use crate::model::OcsfEvent;
5
6/// Upper bound (inclusive) for the `time` field on emitted events,
7/// in milliseconds since the Unix epoch.
8///
9/// `253_402_300_799_999` = `9999-12-31T23:59:59.999Z`. The shipped
10/// schema pins `time_iso` to a 4-digit year via its `pattern`, so
11/// any `time` above this bound would render a 6-digit year the
12/// schema rejects. Historically the schema advertised
13/// `2^53` (`JCS_SAFE_MAX`) as the upper bound while the Rust
14/// validator applied this tighter year-10000 cap first — a
15/// split-brain the R51/R52 schema-drift pass closed by aligning the
16/// schema to the same bound. Exposed as a `pub const` so tests can
17/// pin the schema-vs-code equality (see `schema_conformance.rs`)
18/// and so future changes to one side without the other fail CI.
19pub const SCHEMA_TIME_MAX_MS: u64 = 253_402_300_799_999;
20
21/// A structural validation failure.
22#[derive(Debug, thiserror::Error, PartialEq, Eq)]
23#[non_exhaustive]
24pub enum ValidationError {
25    /// A required string field was empty.
26    #[error("required field `{0}` is empty")]
27    EmptyField(&'static str),
28    /// A string field exceeds the shipped schema's maxLength — the
29    /// event would pass Rust validation but fail the broker's schema
30    /// gate at publish (which fail-closes the session), splitting the
31    /// two validators' verdicts.
32    #[error("field `{field}` is {len} chars; schema maxLength is {max}")]
33    FieldTooLong {
34        /// Field path.
35        field: &'static str,
36        /// Observed length in chars.
37        len: usize,
38        /// Schema maxLength.
39        max: usize,
40    },
41    /// `unmapped` carries inbound-tolerance fields
42    /// that re-serialize to the top level, but the shipped schema
43    /// declares `additionalProperties: false` — such an event passes the
44    /// Rust validator yet fails the broker's schema gate at publish
45    /// (which fail-closes the session). The tolerance is INBOUND-ONLY;
46    /// republishing an event that carries unmapped fields is refused
47    /// here so both validators agree.
48    #[error("event carries {0} unmapped field(s); inbound tolerance is not publishable")]
49    UnmappedNotPublishable(usize),
50    /// `type_uid` doesn't match `class_uid * 100 + activity_id`.
51    #[error("type_uid {type_uid} != class_uid {class_uid} * 100 + activity_id {activity_id}")]
52    TypeUidMismatch {
53        /// Stated type uid.
54        type_uid: u64,
55        /// Stated class uid.
56        class_uid: u32,
57        /// Stated activity id.
58        activity_id: u8,
59    },
60    /// `class_uid` doesn't match the class enum.
61    #[error("class_uid {0} does not match class_name")]
62    ClassUidMismatch(u32),
63    /// Event is not in the OCSF Application Activity category.
64    #[error("category_uid {0} is not Application Activity (6)")]
65    BadCategory(u8),
66    /// Charter is not represented as an OCSF Regular File.
67    #[error("ai_agent.charter.type_id {0} is not Regular File (1)")]
68    BadCharterType(u8),
69    /// Severity outside 1–6.
70    #[error("severity_id {0} outside 1..=6")]
71    BadSeverity(u8),
72    /// Status outside 0–2.
73    #[error("status_id {0} outside 0..=2")]
74    BadStatus(u8),
75    /// Activity_id must be < 100 or the
76    /// `class_uid × 100 + activity_id` bijection collapses:
77    /// `class_uid=9901, activity_id=100` produces the same
78    /// `type_uid = 990200` as `class_uid=9902, activity_id=0`,
79    /// so downstream SIEM pipelines routing/aggregating by
80    /// type_uid mis-classify. Not currently reachable via the
81    /// builder (defaults 1), but `OcsfEvent`'s fields are all pub
82    /// and the struct is not `#[non_exhaustive]`, so callers can
83    /// construct or mutate events directly, bypassing the builder;
84    /// enforce the bijection
85    /// invariant at validate time.
86    #[error("activity_id {0} outside 0..=99 (would collide type_uid namespaces)")]
87    BadActivityId(u8),
88    /// stop_reason caption present without id (or vice versa).
89    #[error("stop_reason and stop_reason_id must be present together")]
90    StopReasonPairMismatch,
91    /// Stop_reason_id maps to a KNOWN `StopReason` variant,
92    /// but the accompanying caption does not match that variant's
93    /// canonical caption. The two fields split downstream analytics
94    /// (SIEM pipelines that group by id disagree with dashboards that
95    /// group by caption) — reject the disagreement so both fields
96    /// mean the same thing.
97    #[error("stop_reason_id {id} maps to {expected_caption:?} but stop_reason is {actual_caption:?}")]
98    StopReasonCaptionMismatch {
99        /// Stated stop reason id.
100        id: u8,
101        /// Canonical caption for that id.
102        expected_caption: &'static str,
103        /// Actual caption that was emitted with the id.
104        actual_caption: String,
105    },
106    /// A numeric field exceeds `av_core::error::JCS_SAFE_MAX`
107    /// (2^53) on the deserialize path. `OcsfEventBuilder::build`
108    /// already refuses values above the JCS-safe integer range, but
109    /// wire deserialization (`serde_json::from_slice::<OcsfEvent>`)
110    /// bypassed the guard — an issuer sending `prompt_tokens = 2^53+1`
111    /// would round-trip through JS-based auditors (JSON.parse) as a
112    /// silently-truncated value, losing 1 or more low bits and
113    /// breaking any receipt hash computed by JS consumers.
114    #[error("field {field} = {value} exceeds JCS-safe integer range 2^53")]
115    JcsUnsafeInteger {
116        /// Field name.
117        field: &'static str,
118        /// Offending value.
119        value: u64,
120    },
121    /// A numeric field is out of its
122    /// schema-declared range. Currently only `pruning_ratio_millis`
123    /// (permille ratio, capped at 1000) enforces this, but the
124    /// variant is variant-generic so future range-bound fields can
125    /// reuse it. Parallel to `JcsUnsafeInteger` — build() and
126    /// `validate_event` (the deserialize backstop) both enforce the
127    /// bound, closing the emitter/validator drift the wire-side path
128    /// used to have.
129    #[error("field {field} = {value} exceeds schema max {max}")]
130    OutOfSchemaRange {
131        /// Field name.
132        field: &'static str,
133        /// Offending value.
134        value: u64,
135        /// Declared inclusive max.
136        max: u64,
137    },
138    /// Timestamp is zero.
139    #[error("time is zero")]
140    ZeroTime,
141    /// Schema parity: `stop_reason_id` outside the schema enum
142    /// {0..=4, 90..=94, 99}.
143    #[error("stop_reason_id {0} is not a known reason id")]
144    UnknownStopReasonId(u8),
145    /// Schema parity: `metadata.version` must be the const "1.10.0".
146    #[error("metadata.version {0:?} is not the pinned OCSF version")]
147    BadOcsfVersion(String),
148    /// Schema parity: fingerprint constants (SHA-256/JCS/64-hex value).
149    #[error("{0} fingerprint violates the SHA-256/JCS/hex-64 shape")]
150    BadFingerprint(&'static str),
151    /// Schema parity: `payload` maxProperties 512.
152    #[error("payload carries {0} properties, above the schema max of 512")]
153    PayloadTooManyProperties(usize),
154    /// Schema parity: `time_iso`'s pattern requires a 4-digit year, so
155    /// epoch-ms beyond 9999-12-31 renders a caption the schema rejects.
156    #[error("time {0} is beyond 9999-12-31 (time_iso year would exceed 4 digits)")]
157    TimeBeyondSchemaRange(u64),
158    /// `time_iso` does not match the canonical ISO-8601 rendering of
159    /// `time`. The two fields are dual representations of one instant;
160    /// a mismatch means a signed/chained event names different times to
161    /// human auditors (caption) and machine tooling (epoch-ms). Same
162    /// class as the receipt's `issued_at`/`issued_at_iso` cross-check.
163    #[error("time_iso {time_iso:?} does not match time ({time}); expected {expected_iso:?}")]
164    TimeIsoMismatch {
165        /// Epoch-milliseconds timestamp.
166        time: u64,
167        /// Caption that was actually emitted.
168        time_iso: String,
169        /// Canonical rendering of `time`.
170        expected_iso: String,
171    },
172}
173
174/// Validate structural invariants of an event. Returns *all* violations
175/// (collect-all-errors, matching the Harbor validator philosophy).
176pub fn validate_event(ev: &OcsfEvent) -> Result<(), Vec<ValidationError>> {
177    let mut errors = Vec::new();
178    // See UnmappedNotPublishable.
179    if !ev.unmapped.is_empty() {
180        errors.push(ValidationError::UnmappedNotPublishable(ev.unmapped.len()));
181    }
182    if ev.metadata.uid.is_empty() {
183        errors.push(ValidationError::EmptyField("metadata.uid"));
184    }
185    if ev.metadata.version.is_empty() {
186        errors.push(ValidationError::EmptyField("metadata.version"));
187    }
188    if ev.metadata.product.name.is_empty() {
189        errors.push(ValidationError::EmptyField("metadata.product.name"));
190    }
191    if ev.metadata.product.vendor_name.is_empty() {
192        errors.push(ValidationError::EmptyField("metadata.product.vendor_name"));
193    }
194    if ev.metadata.product.version.is_empty() {
195        errors.push(ValidationError::EmptyField("metadata.product.version"));
196    }
197    if ev.session_uid.is_empty() {
198        errors.push(ValidationError::EmptyField("session_uid"));
199    }
200    // Schema parity: ocsf-agent-event.schema.json pins maxLength 128 on
201    // session_uid and ai_agent.instance_uid (matching
202    // av_core::SessionId/InstanceUid). Longer values pass here but fail
203    // every schema-based verifier and the broker's publish gate.
204    if ev.session_uid.chars().count() > 128 {
205        errors.push(ValidationError::FieldTooLong {
206            field: "session_uid",
207            len: ev.session_uid.chars().count(),
208            max: 128,
209        });
210    }
211    if ev.ai_agent.instance_uid.chars().count() > 128 {
212        errors.push(ValidationError::FieldTooLong {
213            field: "ai_agent.instance_uid",
214            len: ev.ai_agent.instance_uid.chars().count(),
215            max: 128,
216        });
217    }
218    if ev.ai_agent.version.is_empty() {
219        errors.push(ValidationError::EmptyField("ai_agent.version"));
220    }
221    if ev.ai_agent.charter.name.is_empty() {
222        errors.push(ValidationError::EmptyField("ai_agent.charter.name"));
223    }
224    if ev.ai_agent.charter.type_id != 1 {
225        errors.push(ValidationError::BadCharterType(ev.ai_agent.charter.type_id));
226    }
227    if ev.ai_agent.instance_uid.is_empty() {
228        errors.push(ValidationError::EmptyField("ai_agent.instance_uid"));
229    }
230    if ev.class_uid != ev.class_name.class_uid() {
231        errors.push(ValidationError::ClassUidMismatch(ev.class_uid));
232    }
233    if ev.category_uid != crate::model::CATEGORY_UID {
234        errors.push(ValidationError::BadCategory(ev.category_uid));
235    }
236    let expected_type = u64::from(ev.class_name.class_uid()) * 100 + u64::from(ev.activity_id);
237    if ev.type_uid != expected_type {
238        errors.push(ValidationError::TypeUidMismatch {
239            type_uid: ev.type_uid,
240            class_uid: ev.class_uid,
241            activity_id: ev.activity_id,
242        });
243    }
244    // The `class_uid × 100 + activity_id` invariant
245    // above is only a bijection when activity_id < 100. Enforce
246    // that at validate time so an adversarial event asserting
247    // `class_uid=9901, activity_id=100, type_uid=990200` (which
248    // is self-consistent per the mismatch check but collides
249    // with `class_uid=9902, activity_id=0`) cannot slip through
250    // and mis-route downstream SIEM aggregation by type_uid.
251    if ev.activity_id >= 100 {
252        errors.push(ValidationError::BadActivityId(ev.activity_id));
253    }
254    if !(1..=6).contains(&ev.severity_id) {
255        errors.push(ValidationError::BadSeverity(ev.severity_id));
256    }
257    if ev.status_id > 2 {
258        errors.push(ValidationError::BadStatus(ev.status_id));
259    }
260    if ev.stop_reason_id.is_some() != ev.stop_reason.is_some() {
261        errors.push(ValidationError::StopReasonPairMismatch);
262    }
263    // Schema parity (validator/schema agreement is this module's stated
264    // purpose — see the module doc): the shipped schema pins
265    // `stop_reason_id` to the enum {0..=4, 90..=94, 99}; an id like 50
266    // previously passed here yet failed the broker's schema gate.
267    if let Some(id) = ev.stop_reason_id {
268        if !matches!(id, 0..=4 | 90..=94 | 99) {
269            errors.push(ValidationError::UnknownStopReasonId(id));
270        }
271    }
272    // Schema parity: `metadata.version` is `const "1.10.0"`.
273    if ev.metadata.version != crate::model::OCSF_VERSION {
274        errors.push(ValidationError::BadOcsfVersion(ev.metadata.version.clone()));
275    }
276    // Schema parity: fingerprint constants (algorithm 3/"SHA-256",
277    // serialization 2/"JCS", 64-hex value) — previously entirely
278    // unvalidated on the wire path.
279    for (field, fingerprint) in [
280        ("inventory", ev.inventory.as_ref()),
281        ("prev_inventory", ev.prev_inventory.as_ref()),
282    ] {
283        let Some(fp) = fingerprint else { continue };
284        let hex64 = fp.value.len() == 64
285            && fp
286                .value
287                .bytes()
288                .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase());
289        if fp.algorithm_id != 3
290            || fp.algorithm != "SHA-256"
291            || fp.serialization_id != 2
292            || fp.serialization != "JCS"
293            || !hex64
294        {
295            errors.push(ValidationError::BadFingerprint(field));
296        }
297    }
298    // Schema parity: `payload` maxProperties 512.
299    if let Some(map) = ev.payload.as_object() {
300        if map.len() > 512 {
301            errors.push(ValidationError::PayloadTooManyProperties(map.len()));
302        }
303    }
304    // Match the shipped JSON schema's
305    // `"stop_reason": {"type": "string", "minLength": 1}`. Some
306    // OpenAI-compatible shims emit `finish_reason: ""` mid-stream; the
307    // parse-site filter in routes.rs::parse_provider_chunk drops that,
308    // but this defense-in-depth check ensures any other emitter path
309    // (`stop_reason_native`, direct builder use) also cannot produce
310    // an event that would pass the Rust validator here yet fail the
311    // schema validator at publish — which permanently fail-closes the
312    // session AFTER the bytes are relayed.
313    if ev.stop_reason.as_deref() == Some("") {
314        errors.push(ValidationError::EmptyField("stop_reason"));
315    }
316    // The initial
317    // fix required `StopReason::from_id(id).caption() == stop_reason`
318    // whenever id was known. That's WRONG — the field docstring at
319    // `model.rs::OcsfEvent::stop_reason` and the builder API
320    // `OcsfEventBuilder::stop_reason_native` both document
321    // `stop_reason` as "the provider's native value when captured".
322    // So `id=1, caption="stop"` (OpenAI native), `id=1,
323    // caption="end_turn"` (Anthropic native), `id=99, caption="Custom
324    // Free Text"` (Other) are ALL legitimate emissions from the
325    // official builder. The over-strict check would have rejected the
326    // vast majority of production events.
327    //
328    // Narrow to the specific cross-wiring case that started this
329    // finding: the caption is ITSELF a canonical caption for a
330    // DIFFERENT known variant. `id=93 (PolicyBlocked)` +
331    // `caption="Loop Detected"` (canonical caption of variant 91) is
332    // an unambiguous mistake — the two fields disagree on which
333    // enforcement fired. Provider-native captions like `"stop"` or
334    // `"tool_calls"` are not canonical captions of any variant, so
335    // they pass. The harness's `map_finish_reason` in routes.rs uses the
336    // lowercase provider tokens; none of those coincide with a
337    // canonical caption (which are Title-cased: `"Stop"`, `"Tool
338    // Use"`, `"Length"`, `"Content Filter"`, …).
339    if let (Some(id), Some(caption)) = (ev.stop_reason_id, ev.stop_reason.as_deref()) {
340        let expected = crate::StopReason::from_id(id).caption();
341        let is_known_id = matches!(id, 0..=4 | 90..=94 | 99);
342        let caption_is_canonical_of_other_variant = is_known_id
343            && expected != caption
344            && [
345                crate::StopReason::Unknown,
346                crate::StopReason::Stop,
347                crate::StopReason::MaxTokens,
348                crate::StopReason::ToolUse,
349                crate::StopReason::SessionClosed,
350                crate::StopReason::ContentFilter,
351                crate::StopReason::LoopDetected,
352                crate::StopReason::BudgetExceeded,
353                crate::StopReason::PolicyBlocked,
354                crate::StopReason::IdentityRejected,
355                crate::StopReason::Other,
356            ]
357            .iter()
358            .any(|other| other.caption() == caption);
359        if caption_is_canonical_of_other_variant {
360            errors.push(ValidationError::StopReasonCaptionMismatch {
361                id,
362                expected_caption: expected,
363                actual_caption: caption.to_owned(),
364            });
365        }
366    }
367    if ev.time == 0 {
368        errors.push(ValidationError::ZeroTime);
369    }
370    // Schema parity: the `time_iso` pattern pins a 4-digit year. A
371    // `time` near 2^53 is JCS-safe and consistent with its own caption
372    // (the cross-check below), but renders a 6-digit year the schema
373    // pattern rejects. `SCHEMA_TIME_MAX_MS` = 9999-12-31T23:59:59.999Z.
374    if ev.time > SCHEMA_TIME_MAX_MS {
375        errors.push(ValidationError::TimeBeyondSchemaRange(ev.time));
376    }
377    // `time_iso` is the second representation of the event's timestamp;
378    // the schema pins its exact format and `OcsfEventBuilder::build`
379    // derives it from `time`. But the wire/deserialize path never
380    // checked the two agree, so a signed/chained event could carry a
381    // `time` and a `time_iso` naming different instants (or a garbage
382    // caption) and still validate — auditors reading the caption and
383    // tooling reading the epoch field would extract contradictory facts
384    // from the same event. Same dual-representation class as the
385    // receipt's issued_at/issued_at_iso cross-check; strict equality
386    // against the canonical rendering enforces format and consistency
387    // in one step.
388    {
389        let expected_iso = av_core::time::iso8601_ms(ev.time);
390        if ev.time_iso != expected_iso {
391            errors.push(ValidationError::TimeIsoMismatch {
392                time: ev.time,
393                time_iso: ev.time_iso.clone(),
394                expected_iso,
395            });
396        }
397    }
398    // JCS-safe integer guard on deserialize path.
399    // `OcsfEventBuilder::build` already enforces this for its own
400    // outputs, but a wire event bypasses build(). Values above
401    // `av_core::error::JCS_SAFE_MAX` (2^53) round-trip through
402    // JS-based JSON parsers as silently-truncated values,
403    // breaking any receipt hash a JS auditor computes over the
404    // event. Cover the fields build() covers: `time`, `seq`, and
405    // every `EventMetrics` counter.
406    for (field, value) in [("time", ev.time), ("metadata.sequence", ev.metadata.sequence)] {
407        if av_core::error::check_jcs_safe(value).is_err() {
408            errors.push(ValidationError::JcsUnsafeInteger { field, value });
409        }
410    }
411    // `ai_agent.ttl_remaining_s` is a schema-typed
412    // u64 with no maximum in the shipped JSON schema. Any value above
413    // 2^53 silently truncates in JS-based auditors and hard-fails
414    // `av_receipts::canonicalize`. `OcsfEventBuilder::build`'s
415    // check_jcs_safe loop already covers the fields listed above; add
416    // ttl_remaining_s here so the wire/deserialize path also refuses.
417    // (`OcsfEvent.ai_agent.ttl_remaining_s` was omitted from the
418    // original JCS-safety sweep.)
419    if let Some(ttl) = ev.ai_agent.ttl_remaining_s {
420        if av_core::error::check_jcs_safe(ttl).is_err() {
421            errors.push(ValidationError::JcsUnsafeInteger {
422                field: "ai_agent.ttl_remaining_s",
423                value: ttl,
424            });
425        }
426    }
427    if let Some(m) = &ev.metrics {
428        for (field, value) in [
429            ("metrics.prompt_tokens", m.prompt_tokens),
430            ("metrics.completion_tokens", m.completion_tokens),
431            ("metrics.cached_tokens", m.cached_tokens),
432            ("metrics.pruned_tokens", m.pruned_tokens),
433            ("metrics.pruning_ratio_millis", m.pruning_ratio_millis),
434        ] {
435            if let Some(v) = value {
436                if av_core::error::check_jcs_safe(v).is_err() {
437                    errors.push(ValidationError::JcsUnsafeInteger { field, value: v });
438                }
439            }
440        }
441        // Mirror the `> 1000` guard from
442        // `OcsfEventBuilder::build`. The schema declares
443        // `pruning_ratio_millis` in `[0, 1000]`; this validator was
444        // introduced as the wire-side backstop for
445        // build's checks, and build later gained the range check too. Adding it here closes the emitter/validator drift
446        // — an event with `pruning_ratio_millis = 5000` now fails
447        // both paths, not just build.
448        if let Some(v) = m.pruning_ratio_millis {
449            if v > 1000 {
450                errors.push(ValidationError::OutOfSchemaRange {
451                    field: "metrics.pruning_ratio_millis",
452                    value: v,
453                    max: 1000,
454                });
455            }
456        }
457    }
458    if errors.is_empty() {
459        Ok(())
460    } else {
461        Err(errors)
462    }
463}
464
465#[cfg(test)]
466mod tests {
467    #![allow(
468        clippy::unwrap_used,
469        clippy::expect_used,
470        clippy::panic,
471        clippy::indexing_slicing
472    )]
473
474    use super::*;
475    use crate::model::{AgentIdentity, EventClass, OcsfEventBuilder};
476    use crate::StopReason;
477
478    fn valid_event() -> OcsfEvent {
479        OcsfEventBuilder::new(
480            EventClass::ToolCall,
481            "sess",
482            AgentIdentity {
483                version: "1".into(),
484                charter: "c".into(),
485                instance_uid: "i".into(),
486                ttl_remaining_s: None,
487            },
488            1,
489        )
490        .stop_reason(StopReason::PolicyBlocked)
491        .build()
492        .unwrap()
493    }
494
495    #[test]
496    fn valid_event_passes() {
497        assert!(validate_event(&valid_event()).is_ok());
498    }
499
500    /// Pin the fingerprint schema-parity block (previously entirely
501    /// unexercised — every mutation of it survived): a canonical
502    /// SHA-256/JCS fingerprint passes, and each individual violation
503    /// (wrong algorithm id/caption, wrong serialization id/caption,
504    /// non-64-hex value, uppercase hex) is refused on both the
505    /// `inventory` and `prev_inventory` fields.
506    #[test]
507    fn fingerprint_constants_are_enforced_per_field() {
508        use crate::model::Fingerprint;
509        let good = || Fingerprint::sha256_jcs("ab".repeat(32));
510        let mut ev = valid_event();
511        ev.inventory = Some(good());
512        ev.prev_inventory = Some(good());
513        assert!(validate_event(&ev).is_ok(), "canonical fingerprints must pass");
514
515        type Violation = Box<dyn Fn(&mut Fingerprint)>;
516        let violations: Vec<Violation> = vec![
517            Box::new(|f| f.algorithm_id = 2),
518            Box::new(|f| f.algorithm = "SHA-512".into()),
519            Box::new(|f| f.serialization_id = 1),
520            Box::new(|f| f.serialization = "CBOR".into()),
521            Box::new(|f| f.value = "ab".repeat(31)),
522            Box::new(|f| f.value = "zz".repeat(32)),
523            Box::new(|f| f.value = "AB".repeat(32)),
524        ];
525        for (index, violate) in violations.iter().enumerate() {
526            let mut ev = valid_event();
527            let mut fp = good();
528            violate(&mut fp);
529            ev.inventory = Some(fp);
530            let errors = validate_event(&ev).unwrap_err();
531            assert!(
532                errors
533                    .iter()
534                    .any(|e| matches!(e, ValidationError::BadFingerprint("inventory"))),
535                "violation #{index} on inventory must be refused: {errors:?}"
536            );
537            let mut ev = valid_event();
538            let mut fp = good();
539            violate(&mut fp);
540            ev.prev_inventory = Some(fp);
541            let errors = validate_event(&ev).unwrap_err();
542            assert!(
543                errors
544                    .iter()
545                    .any(|e| matches!(e, ValidationError::BadFingerprint("prev_inventory"))),
546                "violation #{index} on prev_inventory must be refused: {errors:?}"
547            );
548        }
549    }
550
551    /// Pin the payload maxProperties and time-range schema-parity caps
552    /// at their exact boundaries.
553    #[test]
554    fn payload_property_and_time_range_boundaries() {
555        let mut ev = valid_event();
556        let mut payload = serde_json::Map::new();
557        for index in 0..512 {
558            payload.insert(format!("k{index}"), serde_json::Value::Null);
559        }
560        ev.payload = serde_json::Value::Object(payload.clone());
561        assert!(validate_event(&ev).is_ok(), "512 properties is the inclusive max");
562        payload.insert("k512".into(), serde_json::Value::Null);
563        ev.payload = serde_json::Value::Object(payload);
564        assert!(
565            validate_event(&ev)
566                .unwrap_err()
567                .iter()
568                .any(|e| matches!(e, ValidationError::PayloadTooManyProperties(513))),
569            "513 properties must be refused"
570        );
571
572        let mut ev = valid_event();
573        ev.time = 253_402_300_799_999; // 9999-12-31T23:59:59.999Z
574        ev.time_iso = av_core::time::iso8601_ms(ev.time);
575        assert!(validate_event(&ev).is_ok(), "last 4-digit-year instant must pass");
576        let mut ev = valid_event();
577        ev.time = 253_402_300_800_000; // year 10000
578        ev.time_iso = av_core::time::iso8601_ms(ev.time);
579        assert!(
580            validate_event(&ev)
581                .unwrap_err()
582                .iter()
583                .any(|e| matches!(e, ValidationError::TimeBeyondSchemaRange(_))),
584            "5-digit-year instants must be refused"
585        );
586    }
587
588    #[test]
589    fn oversize_uids_fail_schema_parity() {
590        // Schema pins maxLength 128 on session_uid and
591        // ai_agent.instance_uid; the Rust validator must agree or the
592        // broker's schema gate refuses events this validator passed.
593        let mut ev = valid_event();
594        ev.session_uid = "s".repeat(129);
595        assert!(
596            validate_event(&ev).unwrap_err().iter().any(|e| matches!(
597                e,
598                ValidationError::FieldTooLong {
599                    field: "session_uid",
600                    ..
601                }
602            )),
603            "129-char session_uid must be refused"
604        );
605        let mut ev = valid_event();
606        ev.ai_agent.instance_uid = "i".repeat(129);
607        assert!(
608            validate_event(&ev).unwrap_err().iter().any(|e| matches!(
609                e,
610                ValidationError::FieldTooLong {
611                    field: "ai_agent.instance_uid",
612                    ..
613                }
614            )),
615            "129-char instance_uid must be refused"
616        );
617        let mut ev = valid_event();
618        ev.session_uid = "s".repeat(128);
619        ev.ai_agent.instance_uid = "i".repeat(128);
620        assert!(validate_event(&ev).is_ok(), "exactly-128-char uids must pass");
621    }
622
623    #[test]
624    fn empty_identity_fields_all_reported() {
625        let mut ev = valid_event();
626        ev.ai_agent.version.clear();
627        ev.ai_agent.charter.name.clear();
628        ev.ai_agent.instance_uid.clear();
629        let errs = validate_event(&ev).unwrap_err();
630        assert_eq!(errs.len(), 3, "collect-all: {errs:?}");
631    }
632
633    #[test]
634    fn tampered_type_uid_detected() {
635        let mut ev = valid_event();
636        ev.type_uid += 1;
637        assert!(matches!(
638            validate_event(&ev).unwrap_err().first(),
639            Some(ValidationError::TypeUidMismatch { .. })
640        ));
641    }
642
643    #[test]
644    fn orphan_stop_reason_caption_detected() {
645        let mut ev = valid_event();
646        ev.stop_reason_id = None; // caption still set
647        assert!(validate_event(&ev)
648            .unwrap_err()
649            .contains(&ValidationError::StopReasonPairMismatch));
650    }
651
652    /// `stop_reason` carries the provider's
653    /// NATIVE finish_reason token (documented at
654    /// `model.rs::OcsfEvent::stop_reason` and the builder API
655    /// `OcsfEventBuilder::stop_reason_native`). Provider-native
656    /// captions (OpenAI `"stop"`, Anthropic `"end_turn"`, `"length"`,
657    /// `"tool_calls"`, `"content_filter"`, `"function_call"`) plus
658    /// `Other`-family free text MUST NOT be flagged as a caption
659    /// mismatch even though they differ from the canonical caption.
660    #[test]
661    fn stop_reason_provider_native_captions_are_accepted() {
662        for (id, native) in [
663            (1u8, "stop"),
664            (1, "end_turn"),
665            (1, "stop_sequence"),
666            (2, "length"),
667            (2, "max_tokens"),
668            (3, "tool_calls"),
669            (3, "function_call"),
670            (3, "tool_use"),
671            (90, "content_filter"),
672            (99, "provider_specific_free_text"),
673        ] {
674            let mut ev = valid_event();
675            ev.stop_reason_id = Some(id);
676            ev.stop_reason = Some(native.to_owned());
677            let errs = validate_event(&ev).map(|_| Vec::new()).unwrap_or_else(|e| e);
678            assert!(
679                !errs
680                    .iter()
681                    .any(|e| matches!(e, ValidationError::StopReasonCaptionMismatch { .. })),
682                "provider-native caption ({id}, {native:?}) must not be flagged, got {errs:?}"
683            );
684        }
685    }
686
687    /// The one shape the check DOES flag: the caption is itself a
688    /// canonical caption for a DIFFERENT known variant. `id=93` +
689    /// `caption="Loop Detected"` (canonical for id=91) is the
690    /// unambiguous cross-wiring case.
691    #[test]
692    fn stop_reason_cross_wired_captions_are_flagged() {
693        let mut ev = valid_event();
694        ev.stop_reason_id = Some(93);
695        ev.stop_reason = Some("Loop Detected".into());
696        let errs = validate_event(&ev).unwrap_err();
697        assert!(
698            errs.iter()
699                .any(|e| matches!(e, ValidationError::StopReasonCaptionMismatch { id: 93, .. })),
700            "cross-wired caption must be flagged: {errs:?}"
701        );
702    }
703
704    #[test]
705    fn bad_severity_detected() {
706        let mut ev = valid_event();
707        ev.severity_id = 0;
708        assert!(validate_event(&ev)
709            .unwrap_err()
710            .contains(&ValidationError::BadSeverity(0)));
711        ev.severity_id = 7;
712        assert!(validate_event(&ev)
713            .unwrap_err()
714            .contains(&ValidationError::BadSeverity(7)));
715    }
716
717    #[test]
718    fn status_id_boundary_accepts_up_to_2_rejects_3() {
719        // Catches `> 2` vs `== 2` / `>= 2` mutations: status_id=2 (Failure)
720        // is VALID and must not be flagged; 3 is invalid and must be flagged.
721        let mut ev = valid_event();
722        for valid in [0u8, 1, 2] {
723            ev.status_id = valid;
724            let errs = validate_event(&ev).map(|_| Vec::new()).unwrap_or_else(|e| e);
725            assert!(
726                !errs.iter().any(|e| matches!(e, ValidationError::BadStatus(_))),
727                "status_id={valid} incorrectly flagged: {errs:?}"
728            );
729        }
730        ev.status_id = 3;
731        assert!(validate_event(&ev)
732            .unwrap_err()
733            .contains(&ValidationError::BadStatus(3)));
734    }
735
736    /// Regression guard: the deserialize path bypasses
737    /// `OcsfEventBuilder::build`'s JCS-safe integer check, so a
738    /// wire event with a token counter above 2^53 would round-trip
739    /// through JS-based JSON parsers as silently-truncated,
740    /// breaking any receipt hash JS consumers compute over the
741    /// event. `validate_event` must flag it on every JCS-safe
742    /// carrier field: `time`, `metadata.sequence`, and every
743    /// `EventMetrics.*_tokens`.
744    #[test]
745    fn jcs_unsafe_integer_flagged_on_every_deserialize_carrier() {
746        let unsafe_value = av_core::error::JCS_SAFE_MAX + 1;
747
748        // time
749        let mut ev = valid_event();
750        ev.time = unsafe_value;
751        let errs = validate_event(&ev).unwrap_err();
752        assert!(
753            errs.iter()
754                .any(|e| matches!(e, ValidationError::JcsUnsafeInteger { field: "time", .. })),
755            "time above JCS_SAFE_MAX must be flagged, got {errs:?}"
756        );
757
758        // metadata.sequence
759        let mut ev = valid_event();
760        ev.metadata.sequence = unsafe_value;
761        let errs = validate_event(&ev).unwrap_err();
762        assert!(
763            errs.iter().any(|e| matches!(
764                e,
765                ValidationError::JcsUnsafeInteger {
766                    field: "metadata.sequence",
767                    ..
768                }
769            )),
770            "sequence above JCS_SAFE_MAX must be flagged, got {errs:?}"
771        );
772
773        // EventMetrics counters
774        for field in [
775            "metrics.prompt_tokens",
776            "metrics.completion_tokens",
777            "metrics.cached_tokens",
778            "metrics.pruned_tokens",
779            "metrics.pruning_ratio_millis",
780        ] {
781            let mut ev = valid_event();
782            let mut metrics = crate::model::EventMetrics::default();
783            match field {
784                "metrics.prompt_tokens" => metrics.prompt_tokens = Some(unsafe_value),
785                "metrics.completion_tokens" => metrics.completion_tokens = Some(unsafe_value),
786                "metrics.cached_tokens" => metrics.cached_tokens = Some(unsafe_value),
787                "metrics.pruned_tokens" => metrics.pruned_tokens = Some(unsafe_value),
788                "metrics.pruning_ratio_millis" => metrics.pruning_ratio_millis = Some(unsafe_value),
789                _ => unreachable!(),
790            }
791            ev.metrics = Some(metrics);
792            let errs = validate_event(&ev).unwrap_err();
793            assert!(
794                errs.iter().any(|e| matches!(
795                    e,
796                    ValidationError::JcsUnsafeInteger { field: f, .. } if *f == field
797                )),
798                "{field} above JCS_SAFE_MAX must be flagged, got {errs:?}"
799            );
800        }
801    }
802
803    /// `validate_event` must
804    /// mirror `OcsfEventBuilder::build`'s `pruning_ratio_millis <=
805    /// 1000` guard. This validator exists
806    /// specifically as the wire-side backstop for build's guards;
807    /// leaving the range check only on build left an
808    /// emitter/validator drift where an event with a 500%
809    /// compression ratio would parse-and-validate but fail the
810    /// external JSON schema check downstream.
811    #[test]
812    fn out_of_schema_pruning_ratio_is_flagged_on_validate() {
813        let mut ev = valid_event();
814        ev.metrics = Some(crate::model::EventMetrics {
815            pruning_ratio_millis: Some(5000),
816            ..crate::model::EventMetrics::default()
817        });
818        let errs = validate_event(&ev).unwrap_err();
819        assert!(
820            errs.iter().any(|e| matches!(
821                e,
822                ValidationError::OutOfSchemaRange {
823                    field: "metrics.pruning_ratio_millis",
824                    value: 5000,
825                    max: 1000,
826                }
827            )),
828            "pruning_ratio_millis=5000 must be flagged, got {errs:?}"
829        );
830        // Boundary: 1000 is accepted.
831        ev.metrics = Some(crate::model::EventMetrics {
832            pruning_ratio_millis: Some(1000),
833            ..crate::model::EventMetrics::default()
834        });
835        let errs = validate_event(&ev).map(|_| Vec::new()).unwrap_or_else(|e| e);
836        assert!(
837            !errs
838                .iter()
839                .any(|e| matches!(e, ValidationError::OutOfSchemaRange { .. })),
840            "boundary 1000 must not be flagged, got {errs:?}"
841        );
842    }
843
844    /// Activity_id must be < 100 so
845    /// `class_uid × 100 + activity_id` is a bijection. Without this
846    /// check, `class_uid=9901, activity_id=100` produces the same
847    /// type_uid (990200) as `class_uid=9902, activity_id=0`, so
848    /// downstream SIEM pipelines routing by type_uid mis-classify.
849    /// Not currently reachable via the builder (defaults 1), but the
850    /// all-pub, non-`#[non_exhaustive]` struct is mutable directly
851    /// (as this test does below), bypassing the builder.
852    #[test]
853    fn activity_id_100_is_rejected_even_when_type_uid_matches() {
854        let mut ev = valid_event();
855        ev.activity_id = 100;
856        // Keep type_uid self-consistent so the mismatch check
857        // doesn't fire — this test isolates the bijection guard.
858        ev.type_uid = u64::from(ev.class_name.class_uid()) * 100 + 100;
859        let errs = validate_event(&ev).unwrap_err();
860        assert!(
861            errs.contains(&ValidationError::BadActivityId(100)),
862            "activity_id=100 must be flagged even when type_uid matches; got {errs:?}"
863        );
864        // Boundary: 99 must NOT be flagged.
865        ev.activity_id = 99;
866        ev.type_uid = u64::from(ev.class_name.class_uid()) * 100 + 99;
867        let errs = validate_event(&ev).map(|_| Vec::new()).unwrap_or_else(|e| e);
868        assert!(
869            !errs
870                .iter()
871                .any(|e| matches!(e, ValidationError::BadActivityId(_))),
872            "activity_id=99 must be valid; got {errs:?}"
873        );
874    }
875}