Skip to main content

traverse_runtime/events/
validation.rs

1//! Deterministic runtime validation for governed event-product envelopes.
2//!
3//! Governed by approved spec `534-ecca-event-products` (FR-012 through FR-014).
4
5use semver::Version;
6
7use super::TraverseEvent;
8
9/// Runtime policy used at producer and consumer boundaries.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum EventValidationMode {
12    /// Record violations without rejecting legacy traffic.
13    Migration,
14    /// Reject traffic that does not satisfy the governed envelope profile.
15    Enforcement,
16}
17
18/// Stable, machine-readable diagnostic for an invalid event envelope.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct EventValidationDiagnostic {
21    pub code: &'static str,
22    pub path: &'static str,
23    pub severity: &'static str,
24    pub remediation: &'static str,
25    pub contract_id: String,
26    pub version: String,
27}
28
29/// Result of applying the portable event-product envelope profile.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct EventValidationResult {
32    pub accepted: bool,
33    pub diagnostics: Vec<EventValidationDiagnostic>,
34}
35
36/// Sanitized quarantine evidence for a rejected event.
37///
38/// It retains only contract identity and deterministic diagnostics. Event data,
39/// credentials, and other envelope values are deliberately excluded.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct EventValidationEvidence {
42    pub contract_id: String,
43    pub version: String,
44    pub diagnostics: Vec<EventValidationDiagnostic>,
45}
46
47impl EventValidationEvidence {
48    #[must_use]
49    pub fn from_result(result: &EventValidationResult) -> Option<Self> {
50        let first = result.diagnostics.first()?;
51        Some(Self {
52            contract_id: first.contract_id.clone(),
53            version: first.version.clone(),
54            diagnostics: result.diagnostics.clone(),
55        })
56    }
57}
58
59impl EventValidationResult {
60    #[must_use]
61    pub fn is_valid(&self) -> bool {
62        self.diagnostics.is_empty()
63    }
64}
65
66/// Validate a `TraverseEvent` without host or transport dependencies.
67///
68/// Migration mode preserves delivery while surfacing deterministic evidence;
69/// enforcement mode rejects the same invalid envelope.
70#[must_use]
71pub fn validate_event(event: &TraverseEvent, mode: EventValidationMode) -> EventValidationResult {
72    let mut diagnostics = Vec::new();
73    validate_non_empty(&mut diagnostics, "EVP-001", "/id", "id", &event.id, event);
74    validate_non_empty(
75        &mut diagnostics,
76        "EVP-002",
77        "/source",
78        "source",
79        &event.source,
80        event,
81    );
82    validate_non_empty(
83        &mut diagnostics,
84        "EVP-003",
85        "/datacontenttype",
86        "datacontenttype",
87        &event.datacontenttype,
88        event,
89    );
90    validate_non_empty(
91        &mut diagnostics,
92        "EVP-004",
93        "/time",
94        "time",
95        &event.time,
96        event,
97    );
98    validate_non_empty(
99        &mut diagnostics,
100        "EVP-005",
101        "/owner",
102        "owner",
103        &event.owner,
104        event,
105    );
106    validate_optional_non_empty(
107        &mut diagnostics,
108        "EVP-008",
109        "/deduplicationid",
110        "deduplication identity",
111        event.deduplication_id.as_deref(),
112        event,
113    );
114    validate_optional_non_empty(
115        &mut diagnostics,
116        "EVP-009",
117        "/orderingscope",
118        "ordering scope",
119        event.ordering_scope.as_deref(),
120        event,
121    );
122    validate_optional_non_empty(
123        &mut diagnostics,
124        "EVP-010",
125        "/correlationid",
126        "correlation id",
127        event.correlation_id.as_deref(),
128        event,
129    );
130    validate_optional_non_empty(
131        &mut diagnostics,
132        "EVP-011",
133        "/causationid",
134        "causation id",
135        event.causation_id.as_deref(),
136        event,
137    );
138    if Version::parse(&event.version).is_err() {
139        diagnostics.push(diagnostic(
140            "EVP-006",
141            "/version",
142            "version must be semantic",
143            event,
144        ));
145    }
146    if !is_fact_type(&event.event_type) {
147        diagnostics.push(diagnostic(
148            "EVP-007",
149            "/type",
150            "type must use a dotted past-tense fact name",
151            event,
152        ));
153    }
154    let accepted = mode == EventValidationMode::Migration || diagnostics.is_empty();
155    EventValidationResult {
156        accepted,
157        diagnostics,
158    }
159}
160
161fn validate_non_empty(
162    diagnostics: &mut Vec<EventValidationDiagnostic>,
163    code: &'static str,
164    path: &'static str,
165    field: &'static str,
166    value: &str,
167    event: &TraverseEvent,
168) {
169    if value.trim().is_empty() {
170        diagnostics.push(diagnostic(code, path, field, event));
171    }
172}
173
174fn validate_optional_non_empty(
175    diagnostics: &mut Vec<EventValidationDiagnostic>,
176    code: &'static str,
177    path: &'static str,
178    field: &'static str,
179    value: Option<&str>,
180    event: &TraverseEvent,
181) {
182    if value.is_none_or(|value| value.trim().is_empty()) {
183        diagnostics.push(diagnostic(code, path, field, event));
184    }
185}
186
187fn diagnostic(
188    code: &'static str,
189    path: &'static str,
190    remediation: &'static str,
191    event: &TraverseEvent,
192) -> EventValidationDiagnostic {
193    EventValidationDiagnostic {
194        code,
195        path,
196        severity: "error",
197        remediation,
198        contract_id: event.event_type.clone(),
199        version: event.version.clone(),
200    }
201}
202
203fn is_fact_type(value: &str) -> bool {
204    value.contains('.')
205        && value
206            .rsplit('.')
207            .next()
208            .is_some_and(|last| last.ends_with("ed") && !last.is_empty())
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use crate::events::LifecycleStatus;
215
216    fn valid_event() -> TraverseEvent {
217        TraverseEvent {
218            id: "evt-1".to_string(),
219            source: "capability/orders".to_string(),
220            event_type: "orders.order.created".to_string(),
221            datacontenttype: "application/json".to_string(),
222            time: "2026-07-30T00:00:00Z".to_string(),
223            data: serde_json::json!({"order_id":"1"}),
224            owner: "orders".to_string(),
225            version: "1.0.0".to_string(),
226            lifecycle_status: LifecycleStatus::Active,
227            deduplication_id: Some("evt-1".to_string()),
228            ordering_scope: Some("order/1".to_string()),
229            correlation_id: Some("correlation-1".to_string()),
230            causation_id: Some("command-1".to_string()),
231            subject_id: None,
232            actor_id: None,
233        }
234    }
235
236    #[test]
237    fn enforcement_rejects_invalid_envelope_with_stable_diagnostic() {
238        let mut event = valid_event();
239        event.owner.clear();
240        event.version = "not-semver".to_string();
241        let result = validate_event(&event, EventValidationMode::Enforcement);
242        assert!(!result.accepted);
243        assert_eq!(result.diagnostics[0].code, "EVP-005");
244        assert_eq!(result.diagnostics[0].severity, "error");
245        assert_eq!(result.diagnostics[1].code, "EVP-006");
246    }
247
248    #[test]
249    fn migration_mode_reports_but_does_not_reject_legacy_gap() {
250        let mut event = valid_event();
251        event.event_type = "orders.order.create".to_string();
252        let result = validate_event(&event, EventValidationMode::Migration);
253        assert!(result.accepted);
254        assert_eq!(result.diagnostics[0].code, "EVP-007");
255    }
256
257    #[test]
258    fn enforcement_rejects_missing_delivery_identity() {
259        let mut event = valid_event();
260        event.deduplication_id = None;
261
262        let result = validate_event(&event, EventValidationMode::Enforcement);
263
264        assert!(!result.accepted);
265        assert_eq!(result.diagnostics[0].code, "EVP-008");
266        assert_eq!(result.diagnostics[0].path, "/deduplicationid");
267    }
268
269    #[test]
270    fn valid_result_reports_validity() {
271        let result = validate_event(&valid_event(), EventValidationMode::Enforcement);
272        assert!(result.is_valid());
273    }
274}