1use serde::{Deserialize, Serialize};
16
17use crate::flow::{Atom, AtomId};
18use crate::value::VmValue;
19
20#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
22pub struct InvariantResult {
23 pub verdict: Verdict,
24 #[serde(default, skip_serializing_if = "Vec::is_empty")]
25 pub evidence: Vec<EvidenceItem>,
26 #[serde(default, skip_serializing_if = "Option::is_none")]
27 pub remediation: Option<Remediation>,
28 pub confidence: f64,
29}
30
31impl Eq for InvariantResult {}
32
33impl InvariantResult {
34 pub fn allow() -> Self {
36 Self {
37 verdict: Verdict::Allow,
38 evidence: Vec::new(),
39 remediation: None,
40 confidence: 1.0,
41 }
42 }
43
44 pub fn warn(reason: impl Into<String>) -> Self {
46 Self {
47 verdict: Verdict::Warn {
48 reason: reason.into(),
49 },
50 evidence: Vec::new(),
51 remediation: None,
52 confidence: 1.0,
53 }
54 }
55
56 pub fn block(error: InvariantBlockError) -> Self {
58 Self {
59 verdict: Verdict::Block { error },
60 evidence: Vec::new(),
61 remediation: None,
62 confidence: 1.0,
63 }
64 }
65
66 pub fn require_approval(approver: Approver) -> Self {
69 Self {
70 verdict: Verdict::RequireApproval { approver },
71 evidence: Vec::new(),
72 remediation: None,
73 confidence: 1.0,
74 }
75 }
76
77 pub fn with_evidence(mut self, evidence: Vec<EvidenceItem>) -> Self {
79 self.evidence = evidence;
80 self
81 }
82
83 pub fn with_remediation(mut self, remediation: Remediation) -> Self {
85 self.remediation = Some(remediation);
86 self
87 }
88
89 pub fn with_confidence(mut self, confidence: f64) -> Self {
91 self.confidence = confidence.clamp(0.0, 1.0);
92 self
93 }
94
95 pub fn is_blocking(&self) -> bool {
97 matches!(self.verdict, Verdict::Block { .. })
98 }
99
100 pub fn requires_approval(&self) -> bool {
102 matches!(self.verdict, Verdict::RequireApproval { .. })
103 }
104
105 pub fn block_error(&self) -> Option<&InvariantBlockError> {
109 match &self.verdict {
110 Verdict::Block { error } => Some(error),
111 _ => None,
112 }
113 }
114
115 pub fn to_vm_value(&self) -> VmValue {
118 let json = serde_json::to_value(self).unwrap_or(serde_json::Value::Null);
119 crate::stdlib::json_to_vm_value(&json)
120 }
121
122 pub fn from_vm_value(value: &VmValue) -> Result<Self, String> {
126 let json = vm_value_to_json(value);
127 serde_json::from_value(json).map_err(|error| format!("invalid InvariantResult: {error}"))
128 }
129}
130
131fn vm_value_to_json(value: &VmValue) -> serde_json::Value {
132 match value {
133 VmValue::Nil => serde_json::Value::Null,
134 VmValue::Bool(b) => serde_json::Value::Bool(*b),
135 VmValue::Int(n) => serde_json::Value::from(*n),
136 VmValue::Float(n) => serde_json::Number::from_f64(*n)
137 .map(serde_json::Value::Number)
138 .unwrap_or(serde_json::Value::Null),
139 VmValue::String(s) => serde_json::Value::String(s.to_string()),
140 VmValue::List(items) => {
141 serde_json::Value::Array(items.iter().map(vm_value_to_json).collect())
142 }
143 VmValue::Dict(map) => {
144 let mut object = serde_json::Map::new();
145 for (key, item) in map.iter() {
146 object.insert(key.clone(), vm_value_to_json(item));
147 }
148 serde_json::Value::Object(object)
149 }
150 other => serde_json::Value::String(other.display()),
151 }
152}
153
154#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
156#[serde(tag = "kind", rename_all = "snake_case")]
157pub enum Verdict {
158 Allow,
160 Warn { reason: String },
162 Block { error: InvariantBlockError },
164 RequireApproval { approver: Approver },
168}
169
170#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
172#[serde(tag = "kind", rename_all = "snake_case")]
173pub enum Approver {
174 Principal { id: String },
176 Role { name: String },
178}
179
180impl Approver {
181 pub fn principal(id: impl Into<String>) -> Self {
182 Self::Principal { id: id.into() }
183 }
184
185 pub fn role(name: impl Into<String>) -> Self {
186 Self::Role { name: name.into() }
187 }
188}
189
190#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
193pub struct InvariantBlockError {
194 pub code: String,
195 pub message: String,
196}
197
198impl InvariantBlockError {
199 pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
200 Self {
201 code: code.into(),
202 message: message.into(),
203 }
204 }
205
206 pub fn budget_exceeded(message: impl Into<String>) -> Self {
207 Self::new("budget_exceeded", message)
208 }
209
210 pub fn nondeterministic_drift(message: impl Into<String>) -> Self {
211 Self::new("nondeterministic_drift", message)
212 }
213}
214
215#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
217#[serde(tag = "kind", rename_all = "snake_case")]
218pub enum EvidenceItem {
219 AtomPointer { atom: AtomId, diff_span: ByteSpan },
221 MetadataPath {
223 directory: String,
224 namespace: String,
225 key: String,
226 },
227 TranscriptExcerpt {
230 transcript_id: String,
231 span: ByteSpan,
232 },
233 ExternalCitation {
235 url: String,
236 quote: String,
237 fetched_at: String,
239 },
240}
241
242#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
245pub struct ByteSpan {
246 pub start: u64,
247 pub end: u64,
248}
249
250impl ByteSpan {
251 pub fn new(start: u64, end: u64) -> Self {
252 Self { start, end }
253 }
254}
255
256#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
259pub struct Remediation {
260 pub description: String,
261 #[serde(default, skip_serializing_if = "Option::is_none")]
262 pub suggested_atoms: Option<Vec<Atom>>,
263}
264
265impl Remediation {
266 pub fn describe(description: impl Into<String>) -> Self {
267 Self {
268 description: description.into(),
269 suggested_atoms: None,
270 }
271 }
272
273 pub fn with_suggested_atoms(mut self, atoms: Vec<Atom>) -> Self {
274 self.suggested_atoms = Some(atoms);
275 self
276 }
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282
283 #[test]
284 fn allow_round_trips_through_json_and_vm_value() {
285 let original = InvariantResult::allow();
286 let json = serde_json::to_value(&original).unwrap();
287 let decoded: InvariantResult = serde_json::from_value(json).unwrap();
288 assert_eq!(decoded, original);
289
290 let vm_value = original.to_vm_value();
291 let from_vm = InvariantResult::from_vm_value(&vm_value).unwrap();
292 assert_eq!(from_vm, original);
293 }
294
295 #[test]
296 fn warn_carries_reason() {
297 let result = InvariantResult::warn("unused import in stdlib");
298 assert!(
299 matches!(result.verdict, Verdict::Warn { ref reason } if reason == "unused import in stdlib")
300 );
301 assert!(!result.is_blocking());
302 }
303
304 #[test]
305 fn block_marks_blocking() {
306 let result = InvariantResult::block(InvariantBlockError::new(
307 "missing_test",
308 "no test covers this atom",
309 ));
310 assert!(result.is_blocking());
311 assert_eq!(result.block_error().unwrap().code, "missing_test");
312 }
313
314 #[test]
315 fn require_approval_routes_to_principal_or_role() {
316 let principal = InvariantResult::require_approval(Approver::principal("user:alice"));
317 let role = InvariantResult::require_approval(Approver::role("security-reviewer"));
318 assert!(principal.requires_approval());
319 assert!(role.requires_approval());
320 match principal.verdict {
321 Verdict::RequireApproval {
322 approver: Approver::Principal { id },
323 } => {
324 assert_eq!(id, "user:alice");
325 }
326 other => panic!("expected principal approver, got {other:?}"),
327 }
328 match role.verdict {
329 Verdict::RequireApproval {
330 approver: Approver::Role { name },
331 } => {
332 assert_eq!(name, "security-reviewer");
333 }
334 other => panic!("expected role approver, got {other:?}"),
335 }
336 }
337
338 #[test]
339 fn confidence_clamps_to_unit_interval() {
340 let low = InvariantResult::warn("low signal").with_confidence(-0.5);
341 let high = InvariantResult::warn("over-confident").with_confidence(2.0);
342 let mid = InvariantResult::warn("calibrated").with_confidence(0.42);
343 assert_eq!(low.confidence, 0.0);
344 assert_eq!(high.confidence, 1.0);
345 assert!((mid.confidence - 0.42).abs() < f64::EPSILON);
346 }
347
348 #[test]
349 fn evidence_items_serialize_with_kind_tag() {
350 let evidence = vec![
351 EvidenceItem::AtomPointer {
352 atom: AtomId([1; 32]),
353 diff_span: ByteSpan::new(0, 64),
354 },
355 EvidenceItem::MetadataPath {
356 directory: "src/auth".to_string(),
357 namespace: "policy".to_string(),
358 key: "min_review_count".to_string(),
359 },
360 EvidenceItem::TranscriptExcerpt {
361 transcript_id: "transcript-0001".to_string(),
362 span: ByteSpan::new(128, 256),
363 },
364 EvidenceItem::ExternalCitation {
365 url: "https://harnlang.com/spec".to_string(),
366 quote: "verdicts may grade as Allow, Warn, Block, RequireApproval".to_string(),
367 fetched_at: "2026-04-26T00:00:00Z".to_string(),
368 },
369 ];
370 let result = InvariantResult::warn("see evidence").with_evidence(evidence.clone());
371 let json = serde_json::to_value(&result).unwrap();
372 let decoded: InvariantResult = serde_json::from_value(json).unwrap();
373 assert_eq!(decoded.evidence, evidence);
374 }
375
376 #[test]
377 fn remediation_attaches_without_suggested_atoms() {
378 let result =
379 InvariantResult::block(InvariantBlockError::new("style", "trailing whitespace"))
380 .with_remediation(Remediation::describe("strip trailing whitespace"));
381 assert_eq!(
382 result.remediation.as_ref().unwrap().description,
383 "strip trailing whitespace"
384 );
385 assert!(result
386 .remediation
387 .as_ref()
388 .unwrap()
389 .suggested_atoms
390 .is_none());
391 }
392}