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}