Skip to main content

pe_core/
validation.rs

1//! Execution validation — guardrail checks and write governance enforcement.
2//!
3//! These types carry only primitive data (no external deps) so pe-core can
4//! validate execution results without depending on pe-runtime or pe-tools.
5//! Higher crates construct `ExecutionMetrics` and `WriteRecord` from their
6//! own result types and pass them here for validation.
7
8use crate::boundaries::{Guardrail, WriteAccess, WriteGovernance};
9use crate::error::PeError;
10use serde::{Deserialize, Serialize};
11
12/// Lightweight execution summary for guardrail validation.
13///
14/// Constructed by pe-runtime from `ExecutionResult`. Contains only
15/// the metrics that guardrails check against — no `Arc<dyn Tool>`,
16/// no state generics, no external crate types.
17///
18/// # Example
19///
20/// ```
21/// use pe_core::validation::ExecutionMetrics;
22///
23/// let metrics = ExecutionMetrics {
24///     output_tokens: 1500,
25///     tool_calls_made: 3,
26///     output_text: "Some output".into(),
27/// };
28/// ```
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct ExecutionMetrics {
31    pub output_tokens: u32,
32    pub tool_calls_made: u32,
33    pub output_text: String,
34}
35
36/// A write operation that needs governance validation.
37///
38/// Represents a single write the agent attempted during execution.
39/// The runtime collects these and passes them to
40/// [`WriteGovernance::validate_writes`] after execution completes.
41///
42/// # Example
43///
44/// ```
45/// use pe_core::validation::WriteRecord;
46///
47/// let write = WriteRecord {
48///     destination: "collective".into(),
49///     key: "project_notes".into(),
50///     has_grant: false,
51/// };
52/// ```
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct WriteRecord {
55    /// Target store: "own_memory", "collective", "vault", "task_store".
56    pub destination: String,
57    /// Key being written.
58    pub key: String,
59    /// Whether a DataGrant was obtained for this write.
60    pub has_grant: bool,
61}
62
63impl Guardrail {
64    /// Validate execution metrics against this guardrail.
65    ///
66    /// Returns `Ok(())` if the guardrail is satisfied, or
67    /// `Err(PeError::GuardrailViolation)` with details.
68    ///
69    /// Content-analysis guardrails (MustCiteSources, NoCodeExecution)
70    /// are deferred to Plan 014 — they return Ok(()) for now.
71    #[must_use = "this returns a Result that must be checked"]
72    pub fn validate(&self, metrics: &ExecutionMetrics) -> Result<(), PeError> {
73        match self {
74            Self::MaxOutputTokens(max) => {
75                if metrics.output_tokens > *max {
76                    return Err(PeError::GuardrailViolation {
77                        guardrail: format!("MaxOutputTokens({})", max),
78                        details: format!("output was {} tokens", metrics.output_tokens),
79                    });
80                }
81            }
82            Self::MaxToolCallsPerTurn(max) => {
83                if metrics.tool_calls_made > *max {
84                    return Err(PeError::GuardrailViolation {
85                        guardrail: format!("MaxToolCallsPerTurn({})", max),
86                        details: format!("{} calls made", metrics.tool_calls_made),
87                    });
88                }
89            }
90            // Content analysis guardrails — deferred to Plan 014
91            Self::MustCiteSources | Self::NoCodeExecution => {}
92            // Custom guardrails need a user-provided validation fn (Plan 014)
93            Self::Custom { .. } => {}
94        }
95        Ok(())
96    }
97}
98
99impl WriteGovernance {
100    /// Validate that all writes respect this governance policy.
101    ///
102    /// Checks each write's destination against the access level.
103    /// ReadOnly destinations reject all writes. RequiresGrant
104    /// destinations reject writes without a grant.
105    #[must_use = "this returns a Result that must be checked"]
106    pub fn validate_writes(&self, writes: &[WriteRecord]) -> Result<(), PeError> {
107        for write in writes {
108            let access = match write.destination.as_str() {
109                "own_memory" => &self.own_memory,
110                "collective" => &self.collective,
111                "vault" => &self.vault,
112                "task_store" => &self.task_store,
113                _ => continue, // Unknown destinations are not governed
114            };
115            match access {
116                WriteAccess::ReadOnly => {
117                    return Err(PeError::WriteGovernanceViolation {
118                        destination: write.destination.clone(),
119                        reason: "ReadOnly — no writes permitted".into(),
120                    });
121                }
122                WriteAccess::RequiresGrant if !write.has_grant => {
123                    return Err(PeError::WriteGovernanceViolation {
124                        destination: write.destination.clone(),
125                        reason: "RequiresGrant — no grant obtained".into(),
126                    });
127                }
128                _ => {} // Free or Attributed — always permitted
129            }
130        }
131        Ok(())
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use crate::boundaries::WriteGovernance;
139
140    fn metrics(output_tokens: u32, tool_calls: u32) -> ExecutionMetrics {
141        ExecutionMetrics {
142            output_tokens,
143            tool_calls_made: tool_calls,
144            output_text: String::new(),
145        }
146    }
147
148    #[test]
149    fn max_output_tokens_within_limit() {
150        let g = Guardrail::MaxOutputTokens(2000);
151        assert!(g.validate(&metrics(1500, 0)).is_ok());
152    }
153
154    #[test]
155    fn max_output_tokens_violation() {
156        let g = Guardrail::MaxOutputTokens(2000);
157        let err = g.validate(&metrics(2500, 0)).unwrap_err();
158        assert!(matches!(err, PeError::GuardrailViolation { .. }));
159    }
160
161    #[test]
162    fn max_tool_calls_within_limit() {
163        let g = Guardrail::MaxToolCallsPerTurn(5);
164        assert!(g.validate(&metrics(0, 3)).is_ok());
165    }
166
167    #[test]
168    fn max_tool_calls_violation() {
169        let g = Guardrail::MaxToolCallsPerTurn(5);
170        let err = g.validate(&metrics(0, 8)).unwrap_err();
171        assert!(matches!(err, PeError::GuardrailViolation { .. }));
172    }
173
174    #[test]
175    fn write_governance_free_allows_all() {
176        let gov = WriteGovernance::default(); // all Free
177        let writes = vec![WriteRecord {
178            destination: "own_memory".into(),
179            key: "test".into(),
180            has_grant: false,
181        }];
182        assert!(gov.validate_writes(&writes).is_ok());
183    }
184
185    #[test]
186    fn write_governance_read_only_rejects() {
187        let gov = WriteGovernance {
188            vault: WriteAccess::ReadOnly,
189            ..Default::default()
190        };
191        let writes = vec![WriteRecord {
192            destination: "vault".into(),
193            key: "secret".into(),
194            has_grant: false,
195        }];
196        let err = gov.validate_writes(&writes).unwrap_err();
197        assert!(matches!(err, PeError::WriteGovernanceViolation { .. }));
198    }
199
200    #[test]
201    fn write_governance_requires_grant_without_grant_rejects() {
202        let gov = WriteGovernance {
203            collective: WriteAccess::RequiresGrant,
204            ..Default::default()
205        };
206        let writes = vec![WriteRecord {
207            destination: "collective".into(),
208            key: "notes".into(),
209            has_grant: false,
210        }];
211        assert!(gov.validate_writes(&writes).is_err());
212
213        // With grant — should succeed
214        let writes_granted = vec![WriteRecord {
215            destination: "collective".into(),
216            key: "notes".into(),
217            has_grant: true,
218        }];
219        assert!(gov.validate_writes(&writes_granted).is_ok());
220    }
221}