Skip to main content

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}