Skip to main content

harn_vm/flow/predicates/
result.rs

1//! Graded `InvariantResult` and surrounding evidence/remediation machinery.
2//!
3//! See issue #581 for the type contract. Predicates produce an
4//! [`InvariantResult`] whose `verdict` may `Allow`, `Warn`, `Block`, or
5//! `RequireApproval` (routing to a specific principal or role). Structured
6//! evidence pointers (`AtomPointer`, `MetadataPath`, `TranscriptExcerpt`,
7//! `ExternalCitation`) explain the verdict; an optional [`Remediation`] is
8//! consumed by the Fixer persona (#587), never auto-applied by the executor.
9//!
10//! The Rust types are kept faithful to the spec. The Harn-side stdlib
11//! constructors live in `crates/harn-vm/src/stdlib/flow.rs` and produce values
12//! that round-trip through [`InvariantResult::to_vm_value`] /
13//! [`InvariantResult::from_vm_value`].
14
15use serde::{Deserialize, Serialize};
16
17use crate::flow::{Atom, AtomId};
18use crate::value::VmValue;
19
20/// Predicate result returned by every invariant evaluation attempt.
21#[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    /// Allow verdict with full confidence, no evidence, no remediation.
35    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    /// Warn verdict — predicate has concerns but does not block shipment.
45    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    /// Block verdict — predicate refuses to ship the slice as-is.
57    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    /// `RequireApproval` verdict — predicate wants a specific principal or
67    /// role to co-sign before shipment.
68    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    /// Attach evidence items to this result (replaces any existing evidence).
78    pub fn with_evidence(mut self, evidence: Vec<EvidenceItem>) -> Self {
79        self.evidence = evidence;
80        self
81    }
82
83    /// Attach a remediation suggestion to this result.
84    pub fn with_remediation(mut self, remediation: Remediation) -> Self {
85        self.remediation = Some(remediation);
86        self
87    }
88
89    /// Override the confidence scalar (clamped to `[0.0, 1.0]`).
90    pub fn with_confidence(mut self, confidence: f64) -> Self {
91        self.confidence = confidence.clamp(0.0, 1.0);
92        self
93    }
94
95    /// Returns true when the verdict halts shipment.
96    pub fn is_blocking(&self) -> bool {
97        matches!(self.verdict, Verdict::Block { .. })
98    }
99
100    /// Returns true when the verdict requires explicit cosigner routing.
101    pub fn requires_approval(&self) -> bool {
102        matches!(self.verdict, Verdict::RequireApproval { .. })
103    }
104
105    /// Return the underlying [`InvariantBlockError`] when the verdict is
106    /// `Block`. Convenience for migration sites that previously matched on the
107    /// old `InvariantResult::Blocked { error }` variant.
108    pub fn block_error(&self) -> Option<&InvariantBlockError> {
109        match &self.verdict {
110            Verdict::Block { error } => Some(error),
111            _ => None,
112        }
113    }
114
115    /// Encode this result as a Harn-visible [`VmValue`]. Used by the stdlib
116    /// flow builtins so predicate authors receive idiomatic record values.
117    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    /// Decode an `InvariantResult` from a [`VmValue`] produced by the stdlib
123    /// flow builders (or by any other path that emits a structurally valid
124    /// dict). Returns `Err` with a human-readable message on shape mismatch.
125    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/// Graded predicate verdict.
155#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
156#[serde(tag = "kind", rename_all = "snake_case")]
157pub enum Verdict {
158    /// Predicate passed.
159    Allow,
160    /// Predicate raised concerns but does not block shipment.
161    Warn { reason: String },
162    /// Predicate refuses to ship the slice as-is.
163    Block { error: InvariantBlockError },
164    /// Predicate wants a specific principal or role to co-sign before
165    /// shipment. The critical mechanism for "predicate says fine but wants a
166    /// co-signer".
167    RequireApproval { approver: Approver },
168}
169
170/// Routing target for a `RequireApproval` verdict.
171#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
172#[serde(tag = "kind", rename_all = "snake_case")]
173pub enum Approver {
174    /// A specific human or system principal (e.g. `user:alice`).
175    Principal { id: String },
176    /// Any holder of the named role (e.g. `role:security-reviewer`).
177    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/// Structured hard-block reason carried by `Verdict::Block`. Code is a stable
191/// machine identifier; message is operator-facing.
192#[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/// Pointer to a piece of evidence justifying the verdict.
216#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
217#[serde(tag = "kind", rename_all = "snake_case")]
218pub enum EvidenceItem {
219    /// A specific atom and a byte span within its diff.
220    AtomPointer { atom: AtomId, diff_span: ByteSpan },
221    /// A path through the hierarchical `DirectoryMetadata` tree.
222    MetadataPath {
223        directory: String,
224        namespace: String,
225        key: String,
226    },
227    /// A range within a transcript captured during the run that produced the
228    /// slice.
229    TranscriptExcerpt {
230        transcript_id: String,
231        span: ByteSpan,
232    },
233    /// An external citation fetched by an Archivist-authored predicate.
234    ExternalCitation {
235        url: String,
236        quote: String,
237        /// RFC3339 timestamp captured at fetch time.
238        fetched_at: String,
239    },
240}
241
242/// Inclusive-start, exclusive-end byte span. Used by both atom diff pointers
243/// and transcript excerpts.
244#[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/// Inert remediation suggestion. Consumed by the Fixer persona (#587), never
257/// auto-applied by the executor.
258#[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}