use serde::{Deserialize, Serialize};
use crate::flow::{Atom, AtomId};
use crate::value::VmValue;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct InvariantResult {
pub verdict: Verdict,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub evidence: Vec<EvidenceItem>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub remediation: Option<Remediation>,
pub confidence: f64,
}
impl Eq for InvariantResult {}
impl InvariantResult {
pub fn allow() -> Self {
Self {
verdict: Verdict::Allow,
evidence: Vec::new(),
remediation: None,
confidence: 1.0,
}
}
pub fn warn(reason: impl Into<String>) -> Self {
Self {
verdict: Verdict::Warn {
reason: reason.into(),
},
evidence: Vec::new(),
remediation: None,
confidence: 1.0,
}
}
pub fn block(error: InvariantBlockError) -> Self {
Self {
verdict: Verdict::Block { error },
evidence: Vec::new(),
remediation: None,
confidence: 1.0,
}
}
pub fn require_approval(approver: Approver) -> Self {
Self {
verdict: Verdict::RequireApproval { approver },
evidence: Vec::new(),
remediation: None,
confidence: 1.0,
}
}
pub fn with_evidence(mut self, evidence: Vec<EvidenceItem>) -> Self {
self.evidence = evidence;
self
}
pub fn with_remediation(mut self, remediation: Remediation) -> Self {
self.remediation = Some(remediation);
self
}
pub fn with_confidence(mut self, confidence: f64) -> Self {
self.confidence = confidence.clamp(0.0, 1.0);
self
}
pub fn is_blocking(&self) -> bool {
matches!(self.verdict, Verdict::Block { .. })
}
pub fn requires_approval(&self) -> bool {
matches!(self.verdict, Verdict::RequireApproval { .. })
}
pub fn block_error(&self) -> Option<&InvariantBlockError> {
match &self.verdict {
Verdict::Block { error } => Some(error),
_ => None,
}
}
pub fn to_vm_value(&self) -> VmValue {
let json = serde_json::to_value(self).unwrap_or(serde_json::Value::Null);
crate::stdlib::json_to_vm_value(&json)
}
pub fn from_vm_value(value: &VmValue) -> Result<Self, String> {
let json = vm_value_to_json(value);
serde_json::from_value(json).map_err(|error| format!("invalid InvariantResult: {error}"))
}
}
fn vm_value_to_json(value: &VmValue) -> serde_json::Value {
match value {
VmValue::Nil => serde_json::Value::Null,
VmValue::Bool(b) => serde_json::Value::Bool(*b),
VmValue::Int(n) => serde_json::Value::from(*n),
VmValue::Float(n) => serde_json::Number::from_f64(*n)
.map(serde_json::Value::Number)
.unwrap_or(serde_json::Value::Null),
VmValue::String(s) => serde_json::Value::String(s.to_string()),
VmValue::List(items) => {
serde_json::Value::Array(items.iter().map(vm_value_to_json).collect())
}
VmValue::Dict(map) => {
let mut object = serde_json::Map::new();
for (key, item) in map.iter() {
object.insert(key.clone(), vm_value_to_json(item));
}
serde_json::Value::Object(object)
}
other => serde_json::Value::String(other.display()),
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Verdict {
Allow,
Warn { reason: String },
Block { error: InvariantBlockError },
RequireApproval { approver: Approver },
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Approver {
Principal { id: String },
Role { name: String },
}
impl Approver {
pub fn principal(id: impl Into<String>) -> Self {
Self::Principal { id: id.into() }
}
pub fn role(name: impl Into<String>) -> Self {
Self::Role { name: name.into() }
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct InvariantBlockError {
pub code: String,
pub message: String,
}
impl InvariantBlockError {
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
}
}
pub fn budget_exceeded(message: impl Into<String>) -> Self {
Self::new("budget_exceeded", message)
}
pub fn nondeterministic_drift(message: impl Into<String>) -> Self {
Self::new("nondeterministic_drift", message)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum EvidenceItem {
AtomPointer { atom: AtomId, diff_span: ByteSpan },
MetadataPath {
directory: String,
namespace: String,
key: String,
},
TranscriptExcerpt {
transcript_id: String,
span: ByteSpan,
},
ExternalCitation {
url: String,
quote: String,
fetched_at: String,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ByteSpan {
pub start: u64,
pub end: u64,
}
impl ByteSpan {
pub fn new(start: u64, end: u64) -> Self {
Self { start, end }
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Remediation {
pub description: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub suggested_atoms: Option<Vec<Atom>>,
}
impl Remediation {
pub fn describe(description: impl Into<String>) -> Self {
Self {
description: description.into(),
suggested_atoms: None,
}
}
pub fn with_suggested_atoms(mut self, atoms: Vec<Atom>) -> Self {
self.suggested_atoms = Some(atoms);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn allow_round_trips_through_json_and_vm_value() {
let original = InvariantResult::allow();
let json = serde_json::to_value(&original).unwrap();
let decoded: InvariantResult = serde_json::from_value(json).unwrap();
assert_eq!(decoded, original);
let vm_value = original.to_vm_value();
let from_vm = InvariantResult::from_vm_value(&vm_value).unwrap();
assert_eq!(from_vm, original);
}
#[test]
fn warn_carries_reason() {
let result = InvariantResult::warn("unused import in stdlib");
assert!(
matches!(result.verdict, Verdict::Warn { ref reason } if reason == "unused import in stdlib")
);
assert!(!result.is_blocking());
}
#[test]
fn block_marks_blocking() {
let result = InvariantResult::block(InvariantBlockError::new(
"missing_test",
"no test covers this atom",
));
assert!(result.is_blocking());
assert_eq!(result.block_error().unwrap().code, "missing_test");
}
#[test]
fn require_approval_routes_to_principal_or_role() {
let principal = InvariantResult::require_approval(Approver::principal("user:alice"));
let role = InvariantResult::require_approval(Approver::role("security-reviewer"));
assert!(principal.requires_approval());
assert!(role.requires_approval());
match principal.verdict {
Verdict::RequireApproval {
approver: Approver::Principal { id },
} => {
assert_eq!(id, "user:alice");
}
other => panic!("expected principal approver, got {other:?}"),
}
match role.verdict {
Verdict::RequireApproval {
approver: Approver::Role { name },
} => {
assert_eq!(name, "security-reviewer");
}
other => panic!("expected role approver, got {other:?}"),
}
}
#[test]
fn confidence_clamps_to_unit_interval() {
let low = InvariantResult::warn("low signal").with_confidence(-0.5);
let high = InvariantResult::warn("over-confident").with_confidence(2.0);
let mid = InvariantResult::warn("calibrated").with_confidence(0.42);
assert_eq!(low.confidence, 0.0);
assert_eq!(high.confidence, 1.0);
assert!((mid.confidence - 0.42).abs() < f64::EPSILON);
}
#[test]
fn evidence_items_serialize_with_kind_tag() {
let evidence = vec![
EvidenceItem::AtomPointer {
atom: AtomId([1; 32]),
diff_span: ByteSpan::new(0, 64),
},
EvidenceItem::MetadataPath {
directory: "src/auth".to_string(),
namespace: "policy".to_string(),
key: "min_review_count".to_string(),
},
EvidenceItem::TranscriptExcerpt {
transcript_id: "transcript-0001".to_string(),
span: ByteSpan::new(128, 256),
},
EvidenceItem::ExternalCitation {
url: "https://harnlang.com/spec".to_string(),
quote: "verdicts may grade as Allow, Warn, Block, RequireApproval".to_string(),
fetched_at: "2026-04-26T00:00:00Z".to_string(),
},
];
let result = InvariantResult::warn("see evidence").with_evidence(evidence.clone());
let json = serde_json::to_value(&result).unwrap();
let decoded: InvariantResult = serde_json::from_value(json).unwrap();
assert_eq!(decoded.evidence, evidence);
}
#[test]
fn remediation_attaches_without_suggested_atoms() {
let result =
InvariantResult::block(InvariantBlockError::new("style", "trailing whitespace"))
.with_remediation(Remediation::describe("strip trailing whitespace"));
assert_eq!(
result.remediation.as_ref().unwrap().description,
"strip trailing whitespace"
);
assert!(result
.remediation
.as_ref()
.unwrap()
.suggested_atoms
.is_none());
}
}