converge_core/traits/validator.rs
1// Copyright 2024-2026 Reflective Labs
2// SPDX-License-Identifier: MIT
3
4//! # Validator Capability Boundary Trait
5//!
6//! This module defines the capability boundary trait for proposal validation.
7//! Validators examine `Proposal<Draft>` and produce `ValidationReport` evidence
8//! that validation occurred.
9//!
10//! ## Design Philosophy
11//!
12//! - **Type-state enforcement:** Works with `Proposal<Draft>` from the type-state
13//! pattern established in Phase 4. Validators only accept draft proposals.
14//!
15//! - **Proof production:** Validators produce `ValidationReport` which serves as
16//! cryptographic proof that validation occurred. Reports cannot be forged.
17//!
18//! - **GAT async pattern:** Uses generic associated types for zero-cost async
19//! without proc macros or `async_trait`. Keeps core dependency-free.
20//!
21//! - **Split from promotion:** Validation and promotion are separate capabilities.
22//! A validator validates; a promoter promotes. This allows different authorization
23//! boundaries and audit trails.
24//!
25//! ## Integration with Gate Pattern
26//!
27//! The `Validator` trait abstracts the validation capability that `PromotionGate`
28//! uses internally. This allows:
29//! - Swapping validation implementations (rule-based, ML-based, hybrid)
30//! - Testing with mock validators
31//! - Distributed validation across services
32//!
33//! ## Error Handling
34//!
35//! [`ValidatorError`] implements [`CapabilityError`](super::error::CapabilityError)
36//! for uniform error classification, enabling generic retry/circuit breaker logic.
37
38use super::error::{CapabilityError, ErrorCategory};
39use std::future::Future;
40use std::pin::Pin;
41use std::time::Duration;
42
43use crate::gates::validation::{ValidationPolicy, ValidationReport};
44use crate::types::{Draft, Proposal};
45
46/// Boxed future type for dyn-safe trait variant.
47pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
48
49// ============================================================================
50// Error Type
51// ============================================================================
52
53/// Error type for validation operations.
54///
55/// Implements [`CapabilityError`] for uniform error classification.
56#[derive(Debug, Clone)]
57pub enum ValidatorError {
58 /// Validation check failed.
59 CheckFailed {
60 /// Name of the failed check.
61 check_name: String,
62 /// Reason for failure.
63 reason: String,
64 },
65 /// Policy violation detected.
66 PolicyViolation {
67 /// Policy that was violated.
68 policy: String,
69 /// Description of violation.
70 message: String,
71 },
72 /// Required evidence missing.
73 MissingEvidence {
74 /// What evidence was expected.
75 expected: String,
76 },
77 /// Validator service unavailable.
78 Unavailable {
79 /// Error message.
80 message: String,
81 },
82 /// Operation timed out.
83 Timeout {
84 /// Time elapsed before timeout.
85 elapsed: Duration,
86 /// Configured deadline.
87 deadline: Duration,
88 },
89 /// Internal validator error.
90 Internal {
91 /// Error message.
92 message: String,
93 },
94}
95
96impl std::fmt::Display for ValidatorError {
97 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98 match self {
99 Self::CheckFailed { check_name, reason } => {
100 write!(f, "validation check '{}' failed: {}", check_name, reason)
101 }
102 Self::PolicyViolation { policy, message } => {
103 write!(f, "policy '{}' violated: {}", policy, message)
104 }
105 Self::MissingEvidence { expected } => {
106 write!(f, "missing required evidence: {}", expected)
107 }
108 Self::Unavailable { message } => write!(f, "validator unavailable: {}", message),
109 Self::Timeout { elapsed, deadline } => {
110 write!(
111 f,
112 "validation timeout after {:?} (deadline: {:?})",
113 elapsed, deadline
114 )
115 }
116 Self::Internal { message } => write!(f, "internal validator error: {}", message),
117 }
118 }
119}
120
121impl std::error::Error for ValidatorError {}
122
123impl CapabilityError for ValidatorError {
124 fn category(&self) -> ErrorCategory {
125 match self {
126 Self::CheckFailed { .. } => ErrorCategory::InvalidInput,
127 Self::PolicyViolation { .. } => ErrorCategory::InvalidInput,
128 Self::MissingEvidence { .. } => ErrorCategory::InvalidInput,
129 Self::Unavailable { .. } => ErrorCategory::Unavailable,
130 Self::Timeout { .. } => ErrorCategory::Timeout,
131 Self::Internal { .. } => ErrorCategory::Internal,
132 }
133 }
134
135 fn is_transient(&self) -> bool {
136 matches!(self, Self::Unavailable { .. } | Self::Timeout { .. })
137 }
138
139 fn is_retryable(&self) -> bool {
140 // Transient errors are retryable
141 // Internal errors may also be retryable (temporary service issues)
142 self.is_transient() || matches!(self, Self::Internal { .. })
143 }
144
145 fn retry_after(&self) -> Option<Duration> {
146 // No specific retry-after for validation errors
147 None
148 }
149}
150
151// ============================================================================
152// Static Dispatch Trait (GAT Async Pattern)
153// ============================================================================
154
155/// Proposal validation capability.
156///
157/// Validates `Proposal<Draft>` and produces `ValidationReport` as proof.
158/// This trait uses the GAT async pattern for zero-cost static dispatch.
159///
160/// # Type-State Integration
161///
162/// Works with the type-state pattern established in Phase 4:
163/// - Input: `Proposal<Draft>` - publicly constructible
164/// - Output: `ValidationReport` - proof that validation occurred
165///
166/// The report can then be used by a `Promoter` to create `Proposal<Validated>`.
167///
168/// # Example Implementation
169///
170/// ```ignore
171/// struct RuleBasedValidator {
172/// rules: Vec<ValidationRule>,
173/// }
174///
175/// impl Validator for RuleBasedValidator {
176/// type ValidateFut<'a> = impl Future<Output = Result<ValidationReport, ValidatorError>> + Send + 'a
177/// where
178/// Self: 'a;
179///
180/// fn validate<'a>(
181/// &'a self,
182/// proposal: &'a Proposal<Draft>,
183/// policy: &'a ValidationPolicy,
184/// ) -> Self::ValidateFut<'a> {
185/// async move {
186/// // Run rules against proposal...
187/// Ok(report)
188/// }
189/// }
190/// }
191/// ```
192pub trait Validator: Send + Sync {
193 /// Associated future type for validation.
194 ///
195 /// Must be `Send` to work with multi-threaded runtimes.
196 type ValidateFut<'a>: Future<Output = Result<ValidationReport, ValidatorError>> + Send + 'a
197 where
198 Self: 'a;
199
200 /// Validate a draft proposal against the given policy.
201 ///
202 /// # Arguments
203 ///
204 /// * `proposal` - The draft proposal to validate.
205 /// * `policy` - The validation policy to apply.
206 ///
207 /// # Returns
208 ///
209 /// A future that resolves to the validation report or an error.
210 /// The report serves as proof that validation occurred.
211 fn validate<'a>(
212 &'a self,
213 proposal: &'a Proposal<Draft>,
214 policy: &'a ValidationPolicy,
215 ) -> Self::ValidateFut<'a>;
216}
217
218// ============================================================================
219// Dyn-Safe Wrapper (Runtime Polymorphism)
220// ============================================================================
221
222/// Dyn-safe validator for runtime polymorphism.
223///
224/// Use this trait when you need `dyn Trait` compatibility, such as:
225/// - Storing multiple validator types in a collection
226/// - Runtime routing between different validation strategies
227/// - Plugin systems with dynamic loading
228///
229/// For static dispatch (better performance, no allocation), use [`Validator`].
230///
231/// # Blanket Implementation
232///
233/// Any type implementing [`Validator`] automatically implements [`DynValidator`]
234/// via a blanket impl that boxes the future.
235pub trait DynValidator: Send + Sync {
236 /// Validate a draft proposal against the given policy.
237 ///
238 /// Returns a boxed future for dyn-safety.
239 fn validate<'a>(
240 &'a self,
241 proposal: &'a Proposal<Draft>,
242 policy: &'a ValidationPolicy,
243 ) -> BoxFuture<'a, Result<ValidationReport, ValidatorError>>;
244}
245
246// Blanket implementation: Validator -> DynValidator
247impl<T: Validator> DynValidator for T {
248 fn validate<'a>(
249 &'a self,
250 proposal: &'a Proposal<Draft>,
251 policy: &'a ValidationPolicy,
252 ) -> BoxFuture<'a, Result<ValidationReport, ValidatorError>> {
253 Box::pin(Validator::validate(self, proposal, policy))
254 }
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260 use crate::traits::error::{CapabilityError, ErrorCategory};
261
262 // ── ValidatorError Display ────────────────────────────────────────────────
263
264 #[test]
265 fn display_check_failed() {
266 let e = ValidatorError::CheckFailed {
267 check_name: "schema".into(),
268 reason: "missing required field".into(),
269 };
270 let s = e.to_string();
271 assert!(s.contains("schema"));
272 assert!(s.contains("missing required field"));
273 }
274
275 #[test]
276 fn display_policy_violation() {
277 let e = ValidatorError::PolicyViolation {
278 policy: "no-pii".into(),
279 message: "SSN detected".into(),
280 };
281 let s = e.to_string();
282 assert!(s.contains("no-pii"));
283 assert!(s.contains("SSN detected"));
284 }
285
286 #[test]
287 fn display_missing_evidence() {
288 let e = ValidatorError::MissingEvidence {
289 expected: "receipt attachment".into(),
290 };
291 assert!(e.to_string().contains("receipt attachment"));
292 }
293
294 #[test]
295 fn display_unavailable() {
296 let e = ValidatorError::Unavailable {
297 message: "connection refused".into(),
298 };
299 assert!(e.to_string().contains("connection refused"));
300 }
301
302 #[test]
303 fn display_timeout() {
304 let e = ValidatorError::Timeout {
305 elapsed: Duration::from_secs(5),
306 deadline: Duration::from_secs(3),
307 };
308 let s = e.to_string();
309 assert!(s.contains("5s"));
310 assert!(s.contains("3s"));
311 }
312
313 #[test]
314 fn display_internal() {
315 let e = ValidatorError::Internal {
316 message: "null pointer".into(),
317 };
318 assert!(e.to_string().contains("null pointer"));
319 }
320
321 // ── CapabilityError classification ───────────────────────────────────────
322
323 #[test]
324 fn category_check_failed_is_invalid_input() {
325 let e = ValidatorError::CheckFailed {
326 check_name: "x".into(),
327 reason: "y".into(),
328 };
329 assert_eq!(e.category(), ErrorCategory::InvalidInput);
330 assert!(!e.is_transient());
331 assert!(!e.is_retryable());
332 }
333
334 #[test]
335 fn category_policy_violation_is_invalid_input() {
336 let e = ValidatorError::PolicyViolation {
337 policy: "x".into(),
338 message: "y".into(),
339 };
340 assert_eq!(e.category(), ErrorCategory::InvalidInput);
341 assert!(!e.is_transient());
342 }
343
344 #[test]
345 fn category_missing_evidence_is_invalid_input() {
346 let e = ValidatorError::MissingEvidence {
347 expected: "x".into(),
348 };
349 assert_eq!(e.category(), ErrorCategory::InvalidInput);
350 }
351
352 #[test]
353 fn category_unavailable_is_transient_and_retryable() {
354 let e = ValidatorError::Unavailable {
355 message: "down".into(),
356 };
357 assert_eq!(e.category(), ErrorCategory::Unavailable);
358 assert!(e.is_transient());
359 assert!(e.is_retryable());
360 }
361
362 #[test]
363 fn category_timeout_is_transient_and_retryable() {
364 let e = ValidatorError::Timeout {
365 elapsed: Duration::from_secs(1),
366 deadline: Duration::from_secs(1),
367 };
368 assert_eq!(e.category(), ErrorCategory::Timeout);
369 assert!(e.is_transient());
370 assert!(e.is_retryable());
371 }
372
373 #[test]
374 fn category_internal_is_retryable_but_not_transient() {
375 let e = ValidatorError::Internal {
376 message: "oom".into(),
377 };
378 assert_eq!(e.category(), ErrorCategory::Internal);
379 assert!(!e.is_transient());
380 assert!(e.is_retryable());
381 }
382
383 #[test]
384 fn retry_after_always_none() {
385 let errors: Vec<ValidatorError> = vec![
386 ValidatorError::CheckFailed {
387 check_name: "x".into(),
388 reason: "y".into(),
389 },
390 ValidatorError::Unavailable {
391 message: "x".into(),
392 },
393 ValidatorError::Timeout {
394 elapsed: Duration::from_secs(1),
395 deadline: Duration::from_secs(1),
396 },
397 ];
398 for e in &errors {
399 assert!(e.retry_after().is_none());
400 }
401 }
402
403 // ── std::error::Error ────────────────────────────────────────────────────
404
405 #[test]
406 fn validator_error_is_std_error() {
407 let e: Box<dyn std::error::Error> = Box::new(ValidatorError::Internal {
408 message: "test".into(),
409 });
410 assert!(e.to_string().contains("test"));
411 }
412}