converge_core/gates/
validation.rs

1// Copyright 2024-2025 Aprio One AB, Sweden
2// Author: Kenneth Pernyer, kenneth@aprio.one
3// SPDX-License-Identifier: LicenseRef-Proprietary
4// All rights reserved. This source code is proprietary and confidential.
5// Unauthorized copying, modification, or distribution is strictly prohibited.
6
7//! Validation types for the Gate Pattern.
8//!
9//! This module provides:
10//! - `ValidationToken` - Private ZST for forgery prevention
11//! - `CheckResult` - Result of a single validation check
12//! - `ValidationReport` - Proof object that validation occurred
13//! - `ValidationPolicy` - Policy controlling validation behavior
14//! - `ValidationContext` - Context for running validation
15//! - `ValidationError` - Error type for validation failures
16//!
17//! # Key Invariant
18//!
19//! `ValidationReport::new()` is `pub(crate)` - external code cannot forge reports.
20//! The `ValidationToken` field ensures only validators can create reports.
21
22use serde::{Deserialize, Serialize};
23use std::collections::HashMap;
24use thiserror::Error;
25
26use crate::types::{ContentHash, ProposalId, Timestamp};
27
28// ============================================================================
29// ValidationToken - Private ZST for forgery prevention
30// ============================================================================
31
32/// Private token preventing ValidationReport forgery.
33///
34/// Only validators can create this (pub(crate)).
35/// This is a zero-sized type (ZST) that adds no runtime overhead.
36#[derive(Clone)]
37pub(crate) struct ValidationToken(());
38
39impl ValidationToken {
40    /// Create a new validation token.
41    ///
42    /// This is pub(crate) to prevent external code from creating tokens.
43    pub(crate) fn new() -> Self {
44        Self(())
45    }
46}
47
48// ============================================================================
49// CheckResult - Result of a single validation check
50// ============================================================================
51
52/// Result of a single validation check.
53///
54/// Each check has a name, pass/fail status, and optional message.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct CheckResult {
57    /// Name of the check.
58    pub name: String,
59    /// Whether the check passed.
60    pub passed: bool,
61    /// Optional message (especially useful for failures).
62    pub message: Option<String>,
63}
64
65impl CheckResult {
66    /// Create a passing check result.
67    pub fn passed(name: impl Into<String>) -> Self {
68        Self {
69            name: name.into(),
70            passed: true,
71            message: None,
72        }
73    }
74
75    /// Create a failing check result.
76    pub fn failed(name: impl Into<String>, message: impl Into<String>) -> Self {
77        Self {
78            name: name.into(),
79            passed: false,
80            message: Some(message.into()),
81        }
82    }
83
84    /// Create a passing check result with a message.
85    pub fn passed_with_message(name: impl Into<String>, message: impl Into<String>) -> Self {
86        Self {
87            name: name.into(),
88            passed: true,
89            message: Some(message.into()),
90        }
91    }
92}
93
94// ============================================================================
95// ValidationReport - Proof object that validation occurred
96// ============================================================================
97
98/// Proof object that validation occurred.
99///
100/// This type can only be created within the crate via `pub(crate) new()`.
101/// The private `_token` field ensures external code cannot construct it.
102///
103/// # Invariants
104///
105/// - Cannot be constructed outside converge-core
106/// - Contains complete validation audit trail
107/// - Immutable once created
108#[derive(Clone)]
109pub struct ValidationReport {
110    /// ID of the validated proposal.
111    proposal_id: ProposalId,
112    /// Results of all validation checks.
113    checks: Vec<CheckResult>,
114    /// Hash of the policy version used for validation.
115    policy_version: ContentHash,
116    /// When validation was performed.
117    validated_at: Timestamp,
118    /// Private token preventing external construction.
119    _token: ValidationToken,
120}
121
122impl ValidationReport {
123    /// Create a new validation report.
124    ///
125    /// This is `pub(crate)` - only callable by validators within the crate.
126    pub(crate) fn new(
127        proposal_id: ProposalId,
128        checks: Vec<CheckResult>,
129        policy_version: ContentHash,
130    ) -> Self {
131        Self {
132            proposal_id,
133            checks,
134            policy_version,
135            validated_at: Timestamp::now(),
136            _token: ValidationToken::new(),
137        }
138    }
139
140    /// Get the proposal ID.
141    pub fn proposal_id(&self) -> &ProposalId {
142        &self.proposal_id
143    }
144
145    /// Get the validation checks.
146    pub fn checks(&self) -> &[CheckResult] {
147        &self.checks
148    }
149
150    /// Get the policy version hash.
151    pub fn policy_version(&self) -> &ContentHash {
152        &self.policy_version
153    }
154
155    /// Get the validation timestamp.
156    pub fn validated_at(&self) -> &Timestamp {
157        &self.validated_at
158    }
159
160    /// Check if all validation checks passed.
161    pub fn all_passed(&self) -> bool {
162        self.checks.iter().all(|c| c.passed)
163    }
164
165    /// Get the names of failed checks.
166    pub fn failed_checks(&self) -> Vec<&str> {
167        self.checks
168            .iter()
169            .filter(|c| !c.passed)
170            .map(|c| c.name.as_str())
171            .collect()
172    }
173}
174
175// Implement Debug manually to avoid exposing ValidationToken
176impl std::fmt::Debug for ValidationReport {
177    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
178        f.debug_struct("ValidationReport")
179            .field("proposal_id", &self.proposal_id)
180            .field("checks", &self.checks)
181            .field("policy_version", &self.policy_version)
182            .field("validated_at", &self.validated_at)
183            .finish()
184    }
185}
186
187// ============================================================================
188// ValidationPolicy - Policy controlling validation behavior
189// ============================================================================
190
191/// Policy controlling validation behavior.
192///
193/// Defines which checks are required and how to handle warnings.
194#[derive(Debug, Clone, Default)]
195pub struct ValidationPolicy {
196    /// Names of required validation checks.
197    pub required_checks: Vec<String>,
198    /// Whether to allow warnings (non-blocking issues).
199    pub allow_warnings: bool,
200    /// Hash of this policy version (for audit).
201    version_hash: ContentHash,
202}
203
204impl ValidationPolicy {
205    /// Create a new validation policy.
206    pub fn new() -> Self {
207        Self {
208            required_checks: Vec::new(),
209            allow_warnings: true,
210            version_hash: ContentHash::zero(),
211        }
212    }
213
214    /// Add a required check.
215    pub fn with_required_check(mut self, check: impl Into<String>) -> Self {
216        self.required_checks.push(check.into());
217        self.update_version_hash();
218        self
219    }
220
221    /// Set whether warnings are allowed.
222    pub fn with_allow_warnings(mut self, allow: bool) -> Self {
223        self.allow_warnings = allow;
224        self.update_version_hash();
225        self
226    }
227
228    /// Get the policy version hash.
229    pub fn version_hash(&self) -> &ContentHash {
230        &self.version_hash
231    }
232
233    /// Update the version hash based on policy content.
234    fn update_version_hash(&mut self) {
235        // Simple FNV-1a based hash (deterministic, no external deps)
236        let mut hash = [0u8; 32];
237        let mut fnv: u64 = 0xcbf29ce484222325;
238
239        for check in &self.required_checks {
240            for byte in check.bytes() {
241                fnv ^= byte as u64;
242                fnv = fnv.wrapping_mul(0x100000001b3);
243            }
244        }
245
246        fnv ^= self.allow_warnings as u64;
247        fnv = fnv.wrapping_mul(0x100000001b3);
248
249        // Copy fnv into first 8 bytes
250        hash[..8].copy_from_slice(&fnv.to_le_bytes());
251        self.version_hash = ContentHash::new(hash);
252    }
253}
254
255// ============================================================================
256// ValidationContext - Context for running validation
257// ============================================================================
258
259/// Context for running validation.
260///
261/// Contains metadata about the validation environment.
262#[derive(Debug, Clone, Default)]
263pub struct ValidationContext {
264    /// Optional tenant identifier.
265    pub tenant_id: Option<String>,
266    /// Optional session identifier.
267    pub session_id: Option<String>,
268    /// Additional metadata.
269    pub metadata: HashMap<String, serde_json::Value>,
270}
271
272impl ValidationContext {
273    /// Create a new empty validation context.
274    pub fn new() -> Self {
275        Self::default()
276    }
277
278    /// Set the tenant ID.
279    pub fn with_tenant(mut self, tenant: impl Into<String>) -> Self {
280        self.tenant_id = Some(tenant.into());
281        self
282    }
283
284    /// Set the session ID.
285    pub fn with_session(mut self, session: impl Into<String>) -> Self {
286        self.session_id = Some(session.into());
287        self
288    }
289
290    /// Add metadata.
291    pub fn with_metadata(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
292        self.metadata.insert(key.into(), value);
293        self
294    }
295}
296
297// ============================================================================
298// ValidationError - Error type for validation failures
299// ============================================================================
300
301/// Error type for validation failures.
302#[derive(Debug, Clone, Error)]
303pub enum ValidationError {
304    /// A validation check failed.
305    #[error("check '{name}' failed: {reason}")]
306    CheckFailed {
307        /// Name of the failed check.
308        name: String,
309        /// Reason for failure.
310        reason: String,
311    },
312
313    /// Policy was violated.
314    #[error("policy violation: {0}")]
315    PolicyViolation(String),
316
317    /// A required check was missing.
318    #[error("missing required check: {0}")]
319    MissingCheck(String),
320
321    /// Invalid input to validation.
322    #[error("invalid input: {0}")]
323    InvalidInput(String),
324}
325
326impl ValidationError {
327    /// Create a check failed error.
328    pub fn check_failed(name: impl Into<String>, reason: impl Into<String>) -> Self {
329        Self::CheckFailed {
330            name: name.into(),
331            reason: reason.into(),
332        }
333    }
334
335    /// Create a policy violation error.
336    pub fn policy_violation(message: impl Into<String>) -> Self {
337        Self::PolicyViolation(message.into())
338    }
339
340    /// Create a missing check error.
341    pub fn missing_check(check: impl Into<String>) -> Self {
342        Self::MissingCheck(check.into())
343    }
344
345    /// Create an invalid input error.
346    pub fn invalid_input(message: impl Into<String>) -> Self {
347        Self::InvalidInput(message.into())
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354
355    #[test]
356    fn check_result_passed() {
357        let check = CheckResult::passed("schema_valid");
358        assert!(check.passed);
359        assert_eq!(check.name, "schema_valid");
360        assert!(check.message.is_none());
361    }
362
363    #[test]
364    fn check_result_failed() {
365        let check = CheckResult::failed("confidence_threshold", "confidence 0.3 below threshold 0.5");
366        assert!(!check.passed);
367        assert_eq!(check.name, "confidence_threshold");
368        assert_eq!(check.message, Some("confidence 0.3 below threshold 0.5".to_string()));
369    }
370
371    #[test]
372    fn validation_report_creation() {
373        let report = ValidationReport::new(
374            ProposalId::new("prop-001"),
375            vec![
376                CheckResult::passed("check_1"),
377                CheckResult::passed("check_2"),
378            ],
379            ContentHash::zero(),
380        );
381
382        assert_eq!(report.proposal_id().as_str(), "prop-001");
383        assert_eq!(report.checks().len(), 2);
384        assert!(report.all_passed());
385        assert!(report.failed_checks().is_empty());
386    }
387
388    #[test]
389    fn validation_report_with_failures() {
390        let report = ValidationReport::new(
391            ProposalId::new("prop-002"),
392            vec![
393                CheckResult::passed("check_1"),
394                CheckResult::failed("check_2", "too low"),
395            ],
396            ContentHash::zero(),
397        );
398
399        assert!(!report.all_passed());
400        assert_eq!(report.failed_checks(), vec!["check_2"]);
401    }
402
403    #[test]
404    fn validation_policy_builder() {
405        let policy = ValidationPolicy::new()
406            .with_required_check("schema_valid")
407            .with_required_check("confidence_threshold")
408            .with_allow_warnings(false);
409
410        assert_eq!(policy.required_checks.len(), 2);
411        assert!(!policy.allow_warnings);
412        // Version hash should be non-zero after modifications
413        assert_ne!(policy.version_hash(), &ContentHash::zero());
414    }
415
416    #[test]
417    fn validation_context_builder() {
418        let ctx = ValidationContext::new()
419            .with_tenant("tenant-123")
420            .with_session("session-456")
421            .with_metadata("custom_key", serde_json::json!({"value": 42}));
422
423        assert_eq!(ctx.tenant_id, Some("tenant-123".to_string()));
424        assert_eq!(ctx.session_id, Some("session-456".to_string()));
425        assert!(ctx.metadata.contains_key("custom_key"));
426    }
427
428    #[test]
429    fn validation_error_display() {
430        let err = ValidationError::check_failed("schema_valid", "missing required field");
431        assert_eq!(
432            err.to_string(),
433            "check 'schema_valid' failed: missing required field"
434        );
435
436        let err = ValidationError::policy_violation("too many warnings");
437        assert_eq!(err.to_string(), "policy violation: too many warnings");
438
439        let err = ValidationError::missing_check("human_review");
440        assert_eq!(err.to_string(), "missing required check: human_review");
441    }
442
443    #[test]
444    fn validation_report_debug() {
445        let report = ValidationReport::new(
446            ProposalId::new("prop-003"),
447            vec![CheckResult::passed("test")],
448            ContentHash::zero(),
449        );
450
451        // Debug should work but not expose ValidationToken
452        let debug = format!("{:?}", report);
453        assert!(debug.contains("ValidationReport"));
454        assert!(debug.contains("prop-003"));
455        assert!(!debug.contains("_token"));
456    }
457
458    // Note: ValidationReport cannot be constructed outside the crate.
459    // This is enforced at compile-time by pub(crate) visibility:
460    //
461    // // In external crate:
462    // let report = ValidationReport::new(...);
463    // // ERROR: associated function `new` is private
464}