use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
pub const HARNESS_ANCHOR: &str = "harnessx-trace-to-delta";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SuggestionKind {
ToolScope,
Budget,
Policy,
}
impl SuggestionKind {
pub fn as_str(self) -> &'static str {
match self {
SuggestionKind::ToolScope => "tool_scope",
SuggestionKind::Budget => "budget",
SuggestionKind::Policy => "policy",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum SuggestionStatus {
#[default]
Proposed,
Approved,
Rejected,
}
impl SuggestionStatus {
pub fn as_str(self) -> &'static str {
match self {
SuggestionStatus::Proposed => "proposed",
SuggestionStatus::Approved => "approved",
SuggestionStatus::Rejected => "rejected",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SuggestionEvidence {
pub code: String,
pub detail: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub observed: Option<f64>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HarnessSuggestion {
pub id: String,
pub agent_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_eval_run_id: Option<String>,
pub kind: SuggestionKind,
pub current: serde_json::Value,
pub proposed: serde_json::Value,
pub reason: String,
pub evidence: Vec<SuggestionEvidence>,
pub confidence: f64,
pub status: SuggestionStatus,
pub content_hash: String,
pub created_at: DateTime<Utc>,
#[serde(default = "default_anchor")]
pub anchor: String,
}
fn default_anchor() -> String {
HARNESS_ANCHOR.to_string()
}
impl HarnessSuggestion {
#[allow(clippy::too_many_arguments)]
pub fn propose(
id: impl Into<String>,
agent_id: impl Into<String>,
source_eval_run_id: Option<String>,
kind: SuggestionKind,
current: serde_json::Value,
proposed: serde_json::Value,
reason: impl Into<String>,
evidence: Vec<SuggestionEvidence>,
confidence: f64,
created_at: DateTime<Utc>,
) -> Self {
let agent_id = agent_id.into();
let reason = reason.into();
let content_hash = compute_content_hash(
&agent_id,
source_eval_run_id.as_deref(),
kind,
¤t,
&proposed,
&reason,
&evidence,
confidence,
);
Self {
id: id.into(),
agent_id,
source_eval_run_id,
kind,
current,
proposed,
reason,
evidence,
confidence,
status: SuggestionStatus::Proposed,
content_hash,
created_at,
anchor: HARNESS_ANCHOR.to_string(),
}
}
pub fn to_audit_details(&self) -> serde_json::Value {
serde_json::to_value(self).expect("HarnessSuggestion must always serialise")
}
pub fn from_audit_details(value: &serde_json::Value) -> Result<Self, serde_json::Error> {
serde_json::from_value(value.clone())
}
pub fn with_status(mut self, status: SuggestionStatus) -> Self {
self.status = status;
self
}
pub fn verify_hash(&self) -> bool {
compute_content_hash(
&self.agent_id,
self.source_eval_run_id.as_deref(),
self.kind,
&self.current,
&self.proposed,
&self.reason,
&self.evidence,
self.confidence,
) == self.content_hash
}
}
pub fn fold_status(resolutions: &[bool]) -> SuggestionStatus {
match resolutions.last() {
Some(true) => SuggestionStatus::Approved,
Some(false) => SuggestionStatus::Rejected,
None => SuggestionStatus::Proposed,
}
}
#[allow(clippy::too_many_arguments)]
fn compute_content_hash(
agent_id: &str,
source_eval_run_id: Option<&str>,
kind: SuggestionKind,
current: &serde_json::Value,
proposed: &serde_json::Value,
reason: &str,
evidence: &[SuggestionEvidence],
confidence: f64,
) -> String {
#[derive(Serialize)]
struct HashableProjection<'a> {
agent_id: &'a str,
source_eval_run_id: Option<&'a str>,
kind: &'a str,
current: &'a serde_json::Value,
proposed: &'a serde_json::Value,
reason: &'a str,
evidence: &'a [SuggestionEvidence],
confidence: f64,
}
let projection = HashableProjection {
agent_id,
source_eval_run_id,
kind: kind.as_str(),
current,
proposed,
reason,
evidence,
confidence,
};
let bytes = serde_json::to_vec(&projection).expect("projection must serialise");
let digest = Sha256::digest(&bytes);
hex::encode(digest)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn ts() -> DateTime<Utc> {
DateTime::<Utc>::from_timestamp(1_700_000_000, 0).expect("valid timestamp")
}
fn sample() -> HarnessSuggestion {
HarnessSuggestion::propose(
"hns_test_001",
"agt_demo",
Some("eval_run_42".to_string()),
SuggestionKind::Budget,
json!({"per_call_cap_cents": 100}),
json!({"per_call_cap_cents": 50}),
"tool http_post exceeded its cap on 7/10 runs",
vec![SuggestionEvidence {
code: "budget_breach_rate".to_string(),
detail: "http_post over cap in 7 of 10 runs".to_string(),
observed: Some(0.7),
}],
0.82,
ts(),
)
}
#[test]
fn propose_sets_proposed_status_and_hash() {
let s = sample();
assert_eq!(s.status, SuggestionStatus::Proposed);
assert_eq!(s.content_hash.len(), 64);
assert!(s.verify_hash());
assert_eq!(s.anchor, HARNESS_ANCHOR);
}
#[test]
fn audit_details_round_trip_preserves_record() {
let s = sample();
let details = s.to_audit_details();
let parsed = HarnessSuggestion::from_audit_details(&details).expect("round-trip");
assert_eq!(parsed, s);
assert!(parsed.verify_hash());
}
#[test]
fn tampering_with_proposed_value_breaks_hash() {
let mut s = sample();
s.proposed = json!({"per_call_cap_cents": 1});
assert!(!s.verify_hash());
}
#[test]
fn status_overlay_does_not_break_hash() {
let s = sample().with_status(SuggestionStatus::Approved);
assert_eq!(s.status, SuggestionStatus::Approved);
assert!(s.verify_hash());
}
#[test]
fn fold_status_takes_last_resolution() {
assert_eq!(fold_status(&[]), SuggestionStatus::Proposed);
assert_eq!(fold_status(&[true]), SuggestionStatus::Approved);
assert_eq!(fold_status(&[false]), SuggestionStatus::Rejected);
assert_eq!(fold_status(&[false, true]), SuggestionStatus::Approved);
assert_eq!(fold_status(&[true, false]), SuggestionStatus::Rejected);
}
#[test]
fn kind_and_status_serialise_snake_case() {
assert_eq!(
serde_json::to_string(&SuggestionKind::ToolScope).unwrap(),
"\"tool_scope\""
);
assert_eq!(
serde_json::to_string(&SuggestionKind::Budget).unwrap(),
"\"budget\""
);
assert_eq!(
serde_json::to_string(&SuggestionKind::Policy).unwrap(),
"\"policy\""
);
for (status, expected) in [
(SuggestionStatus::Proposed, "\"proposed\""),
(SuggestionStatus::Approved, "\"approved\""),
(SuggestionStatus::Rejected, "\"rejected\""),
] {
assert_eq!(serde_json::to_string(&status).unwrap(), expected);
}
}
#[test]
fn default_status_is_proposed() {
assert_eq!(SuggestionStatus::default(), SuggestionStatus::Proposed);
}
#[test]
fn hash_is_stable_across_identical_proposals() {
assert_eq!(sample().content_hash, sample().content_hash);
}
}