1use chrono::{DateTime, Utc};
23use serde::{Deserialize, Serialize};
24use sha2::{Digest, Sha256};
25
26pub const HARNESS_ANCHOR: &str = "harnessx-trace-to-delta";
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33pub enum SuggestionKind {
34 ToolScope,
36 Budget,
38 Policy,
40}
41
42impl SuggestionKind {
43 pub fn as_str(self) -> &'static str {
44 match self {
45 SuggestionKind::ToolScope => "tool_scope",
46 SuggestionKind::Budget => "budget",
47 SuggestionKind::Policy => "policy",
48 }
49 }
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
57#[serde(rename_all = "snake_case")]
58pub enum SuggestionStatus {
59 #[default]
61 Proposed,
62 Approved,
64 Rejected,
66}
67
68impl SuggestionStatus {
69 pub fn as_str(self) -> &'static str {
70 match self {
71 SuggestionStatus::Proposed => "proposed",
72 SuggestionStatus::Approved => "approved",
73 SuggestionStatus::Rejected => "rejected",
74 }
75 }
76}
77
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
81pub struct SuggestionEvidence {
82 pub code: String,
84 pub detail: String,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub observed: Option<f64>,
89}
90
91#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
100pub struct HarnessSuggestion {
101 pub id: String,
103 pub agent_id: String,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub source_eval_run_id: Option<String>,
108 pub kind: SuggestionKind,
110 pub current: serde_json::Value,
112 pub proposed: serde_json::Value,
114 pub reason: String,
116 pub evidence: Vec<SuggestionEvidence>,
118 pub confidence: f64,
120 pub status: SuggestionStatus,
124 pub content_hash: String,
126 pub created_at: DateTime<Utc>,
128 #[serde(default = "default_anchor")]
130 pub anchor: String,
131}
132
133fn default_anchor() -> String {
134 HARNESS_ANCHOR.to_string()
135}
136
137impl HarnessSuggestion {
138 #[allow(clippy::too_many_arguments)]
142 pub fn propose(
143 id: impl Into<String>,
144 agent_id: impl Into<String>,
145 source_eval_run_id: Option<String>,
146 kind: SuggestionKind,
147 current: serde_json::Value,
148 proposed: serde_json::Value,
149 reason: impl Into<String>,
150 evidence: Vec<SuggestionEvidence>,
151 confidence: f64,
152 created_at: DateTime<Utc>,
153 ) -> Self {
154 let agent_id = agent_id.into();
155 let reason = reason.into();
156 let content_hash = compute_content_hash(
157 &agent_id,
158 source_eval_run_id.as_deref(),
159 kind,
160 ¤t,
161 &proposed,
162 &reason,
163 &evidence,
164 confidence,
165 );
166 Self {
167 id: id.into(),
168 agent_id,
169 source_eval_run_id,
170 kind,
171 current,
172 proposed,
173 reason,
174 evidence,
175 confidence,
176 status: SuggestionStatus::Proposed,
177 content_hash,
178 created_at,
179 anchor: HARNESS_ANCHOR.to_string(),
180 }
181 }
182
183 pub fn to_audit_details(&self) -> serde_json::Value {
185 serde_json::to_value(self).expect("HarnessSuggestion must always serialise")
186 }
187
188 pub fn from_audit_details(value: &serde_json::Value) -> Result<Self, serde_json::Error> {
191 serde_json::from_value(value.clone())
192 }
193
194 pub fn with_status(mut self, status: SuggestionStatus) -> Self {
198 self.status = status;
199 self
200 }
201
202 pub fn verify_hash(&self) -> bool {
205 compute_content_hash(
206 &self.agent_id,
207 self.source_eval_run_id.as_deref(),
208 self.kind,
209 &self.current,
210 &self.proposed,
211 &self.reason,
212 &self.evidence,
213 self.confidence,
214 ) == self.content_hash
215 }
216}
217
218pub fn fold_status(resolutions: &[bool]) -> SuggestionStatus {
223 match resolutions.last() {
224 Some(true) => SuggestionStatus::Approved,
225 Some(false) => SuggestionStatus::Rejected,
226 None => SuggestionStatus::Proposed,
227 }
228}
229
230#[allow(clippy::too_many_arguments)]
233fn compute_content_hash(
234 agent_id: &str,
235 source_eval_run_id: Option<&str>,
236 kind: SuggestionKind,
237 current: &serde_json::Value,
238 proposed: &serde_json::Value,
239 reason: &str,
240 evidence: &[SuggestionEvidence],
241 confidence: f64,
242) -> String {
243 #[derive(Serialize)]
244 struct HashableProjection<'a> {
245 agent_id: &'a str,
246 source_eval_run_id: Option<&'a str>,
247 kind: &'a str,
248 current: &'a serde_json::Value,
249 proposed: &'a serde_json::Value,
250 reason: &'a str,
251 evidence: &'a [SuggestionEvidence],
252 confidence: f64,
253 }
254 let projection = HashableProjection {
255 agent_id,
256 source_eval_run_id,
257 kind: kind.as_str(),
258 current,
259 proposed,
260 reason,
261 evidence,
262 confidence,
263 };
264 let bytes = serde_json::to_vec(&projection).expect("projection must serialise");
265 let digest = Sha256::digest(&bytes);
266 hex::encode(digest)
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272 use serde_json::json;
273
274 fn ts() -> DateTime<Utc> {
275 DateTime::<Utc>::from_timestamp(1_700_000_000, 0).expect("valid timestamp")
276 }
277
278 fn sample() -> HarnessSuggestion {
279 HarnessSuggestion::propose(
280 "hns_test_001",
281 "agt_demo",
282 Some("eval_run_42".to_string()),
283 SuggestionKind::Budget,
284 json!({"per_call_cap_cents": 100}),
285 json!({"per_call_cap_cents": 50}),
286 "tool http_post exceeded its cap on 7/10 runs",
287 vec![SuggestionEvidence {
288 code: "budget_breach_rate".to_string(),
289 detail: "http_post over cap in 7 of 10 runs".to_string(),
290 observed: Some(0.7),
291 }],
292 0.82,
293 ts(),
294 )
295 }
296
297 #[test]
298 fn propose_sets_proposed_status_and_hash() {
299 let s = sample();
300 assert_eq!(s.status, SuggestionStatus::Proposed);
301 assert_eq!(s.content_hash.len(), 64);
302 assert!(s.verify_hash());
303 assert_eq!(s.anchor, HARNESS_ANCHOR);
304 }
305
306 #[test]
307 fn audit_details_round_trip_preserves_record() {
308 let s = sample();
309 let details = s.to_audit_details();
310 let parsed = HarnessSuggestion::from_audit_details(&details).expect("round-trip");
311 assert_eq!(parsed, s);
312 assert!(parsed.verify_hash());
313 }
314
315 #[test]
316 fn tampering_with_proposed_value_breaks_hash() {
317 let mut s = sample();
318 s.proposed = json!({"per_call_cap_cents": 1});
320 assert!(!s.verify_hash());
321 }
322
323 #[test]
324 fn status_overlay_does_not_break_hash() {
325 let s = sample().with_status(SuggestionStatus::Approved);
328 assert_eq!(s.status, SuggestionStatus::Approved);
329 assert!(s.verify_hash());
330 }
331
332 #[test]
333 fn fold_status_takes_last_resolution() {
334 assert_eq!(fold_status(&[]), SuggestionStatus::Proposed);
335 assert_eq!(fold_status(&[true]), SuggestionStatus::Approved);
336 assert_eq!(fold_status(&[false]), SuggestionStatus::Rejected);
337 assert_eq!(fold_status(&[false, true]), SuggestionStatus::Approved);
339 assert_eq!(fold_status(&[true, false]), SuggestionStatus::Rejected);
340 }
341
342 #[test]
343 fn kind_and_status_serialise_snake_case() {
344 assert_eq!(
345 serde_json::to_string(&SuggestionKind::ToolScope).unwrap(),
346 "\"tool_scope\""
347 );
348 assert_eq!(
349 serde_json::to_string(&SuggestionKind::Budget).unwrap(),
350 "\"budget\""
351 );
352 assert_eq!(
353 serde_json::to_string(&SuggestionKind::Policy).unwrap(),
354 "\"policy\""
355 );
356 for (status, expected) in [
357 (SuggestionStatus::Proposed, "\"proposed\""),
358 (SuggestionStatus::Approved, "\"approved\""),
359 (SuggestionStatus::Rejected, "\"rejected\""),
360 ] {
361 assert_eq!(serde_json::to_string(&status).unwrap(), expected);
362 }
363 }
364
365 #[test]
366 fn default_status_is_proposed() {
367 assert_eq!(SuggestionStatus::default(), SuggestionStatus::Proposed);
368 }
369
370 #[test]
371 fn hash_is_stable_across_identical_proposals() {
372 assert_eq!(sample().content_hash, sample().content_hash);
373 }
374}