1use semver::Version;
6
7use super::TraverseEvent;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum EventValidationMode {
12 Migration,
14 Enforcement,
16}
17
18#[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#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct EventValidationResult {
32 pub accepted: bool,
33 pub diagnostics: Vec<EventValidationDiagnostic>,
34}
35
36#[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#[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}