Skip to main content

fd_policy/
harness.rs

1//! Eval-driven harness / policy suggestions — the trace→delta half of the
2//! HarnessX loop.
3//!
4//! fd-evals derives a *proposed* adjustment to an agent's harness — a tighter
5//! per-call budget cap, a tool removed from the allowlist, a policy tweak —
6//! from the aggregate signal across an eval run's trace ("tool X exceeded its
7//! budget on 7/10 runs → propose a lower cap"). The suggestion is written to
8//! the control plane as a **proposal only**: deny-by-default and
9//! human-in-the-loop are preserved — nothing here mutates a live policy,
10//! allowlist, or budget. An operator reviews it in the dashboard and approves
11//! or rejects; *applying* an approved change is a separate, explicit step that
12//! this module deliberately does not perform.
13//!
14//! Storage mirrors the champion-challenger [`crate::promotion`] gate exactly:
15//! the structured record is content-hashed and written to the immutable
16//! `audit_events` trail (no parallel store), with `to_audit_details` /
17//! `from_audit_details` for the round-trip and a stable `content_hash` for
18//! tamper-evidence. The lifecycle (proposed → approved | rejected) is a chain
19//! of append-only audit events folded back into a [`SuggestionStatus`] on the
20//! read path via [`fold_status`].
21
22use chrono::{DateTime, Utc};
23use serde::{Deserialize, Serialize};
24use sha2::{Digest, Sha256};
25
26/// Stable anchor recorded on every suggestion so audit consumers can cite the
27/// methodology root without re-reading docstrings.
28pub const HARNESS_ANCHOR: &str = "harnessx-trace-to-delta";
29
30/// The kind of harness adjustment a suggestion proposes.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33pub enum SuggestionKind {
34    /// Narrow (or widen) the per-agent tool allowlist.
35    ToolScope,
36    /// Adjust a budget cap (per-call / per-run).
37    Budget,
38    /// A policy-rule tweak (e.g. require approval for a tool).
39    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/// Lifecycle of a suggestion.
53///
54/// Default is [`SuggestionStatus::Proposed`] — deny-by-default: a proposal is
55/// never applied, only recorded, until an operator explicitly acts on it.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
57#[serde(rename_all = "snake_case")]
58pub enum SuggestionStatus {
59    /// Recorded, awaiting human review. The deny-by-default state.
60    #[default]
61    Proposed,
62    /// An operator approved the proposal (recorded only — not auto-applied).
63    Approved,
64    /// An operator rejected the proposal.
65    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/// One piece of trace-derived evidence behind a suggestion, e.g.
79/// "tool `http_post` exceeded its 50¢ cap on 7/10 runs".
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
81pub struct SuggestionEvidence {
82    /// Short machine code, e.g. `budget_breach_rate`, `tool_unused`.
83    pub code: String,
84    /// Human-readable detail.
85    pub detail: String,
86    /// Optional observed scalar (e.g. a breach rate of `0.7`).
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub observed: Option<f64>,
89}
90
91/// A proposed, NOT-auto-applied harness / policy change derived from eval
92/// traces. Immutable once written; the `content_hash` lets an auditor verify
93/// the proposal content was not tampered after the fact.
94///
95/// The hash covers only the **immutable proposal content** — not `status`,
96/// which advances over the lifecycle and is folded from the append-only audit
97/// chain on the read path — so [`verify_hash`](Self::verify_hash) holds in
98/// every lifecycle state.
99#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
100pub struct HarnessSuggestion {
101    /// `hns_*` ULID; unique per record.
102    pub id: String,
103    /// Agent the suggestion targets.
104    pub agent_id: String,
105    /// The eval run that produced it, if any.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub source_eval_run_id: Option<String>,
108    /// What kind of adjustment is proposed.
109    pub kind: SuggestionKind,
110    /// The current state being changed (a snapshot for the reviewer's diff).
111    pub current: serde_json::Value,
112    /// The proposed replacement.
113    pub proposed: serde_json::Value,
114    /// Operator-readable explanation of why the eval proposes this.
115    pub reason: String,
116    /// Trace-derived evidence supporting the proposal.
117    pub evidence: Vec<SuggestionEvidence>,
118    /// Confidence in `[0, 1]` reported by the deriving eval.
119    pub confidence: f64,
120    /// Lifecycle status. On the create path this is always
121    /// [`SuggestionStatus::Proposed`]; the read path overlays the folded
122    /// status from the resolution chain.
123    pub status: SuggestionStatus,
124    /// SHA-256 over a stable projection of the immutable proposal fields.
125    pub content_hash: String,
126    /// Server-generated wall-clock UTC instant the proposal was recorded.
127    pub created_at: DateTime<Utc>,
128    /// Stable methodology anchor.
129    #[serde(default = "default_anchor")]
130    pub anchor: String,
131}
132
133fn default_anchor() -> String {
134    HARNESS_ANCHOR.to_string()
135}
136
137impl HarnessSuggestion {
138    /// Build a fresh proposal. The caller supplies the `id` (`hns_*` ULID) and
139    /// `created_at`; the status is always [`SuggestionStatus::Proposed`] and
140    /// the content hash is computed over the immutable proposal fields.
141    #[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            &current,
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    /// Serialize into the JSON shape that goes into `audit_events.details`.
184    pub fn to_audit_details(&self) -> serde_json::Value {
185        serde_json::to_value(self).expect("HarnessSuggestion must always serialise")
186    }
187
188    /// Parse back out of an `audit_events.details` blob. Used by the gateway
189    /// read endpoint.
190    pub fn from_audit_details(value: &serde_json::Value) -> Result<Self, serde_json::Error> {
191        serde_json::from_value(value.clone())
192    }
193
194    /// Return a copy with the lifecycle status overlaid (used by the read
195    /// path after folding the resolution chain). Does not touch the content
196    /// hash — status is not part of the hashed proposal content.
197    pub fn with_status(mut self, status: SuggestionStatus) -> Self {
198        self.status = status;
199        self
200    }
201
202    /// True iff the stored `content_hash` still matches a re-computation from
203    /// the immutable proposal fields. Holds in every lifecycle state.
204    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
218/// Fold a proposal's resolution chain into its current status. `resolutions`
219/// are `approved` booleans in chronological order — the last one wins. An
220/// empty chain means the proposal is still [`SuggestionStatus::Proposed`]
221/// (deny-by-default: no resolution ⇒ not approved).
222pub 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/// Deterministic SHA-256 over a stable projection of the immutable proposal
231/// content. Single source so any future replay regression catches drift.
232#[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        // Rewrite the proposed change without recomputing the hash.
319        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        // Folding a lifecycle status onto the read path must not invalidate
326        // the tamper-evidence over the immutable proposal content.
327        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        // Last write wins (re-approved after a rejection).
338        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}