Skip to main content

harn_vm/flow/
fixer.rs

1//! Fixer persona helpers for materializing Flow remediation suggestions.
2//!
3//! Predicate remediation is inert data: Fixer re-signs suggested atom
4//! templates as a separate follow-up slice so the repair remains auditable as
5//! its own shipping event.
6
7use std::collections::{BTreeMap, BTreeSet};
8use std::fmt;
9
10use ed25519_dalek::SigningKey;
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13use time::OffsetDateTime;
14
15use super::{
16    derive_slice, Approval, Atom, AtomId, CoverageMap, IntentId, InvariantResult, Provenance,
17    Remediation, Slice, SliceDerivationError, SliceId,
18};
19
20pub const FIXER_PERSONA_NAME: &str = "fixer";
21pub const FIXER_TRIGGER: &str = "invariant.blocked_with_remediation";
22
23const MAX_REMEDIATION_DESCRIPTION_CHARS: usize = 200;
24
25pub struct FixerSigningContext<'a> {
26    pub principal_id: String,
27    pub persona_id: String,
28    pub agent_run_id: String,
29    pub trace_id: String,
30    pub transcript_ref: String,
31    pub timestamp: OffsetDateTime,
32    pub principal_key: &'a SigningKey,
33    pub persona_key: &'a SigningKey,
34    pub tool_call_id: Option<String>,
35    pub original_author_cosignature: bool,
36}
37
38impl<'a> FixerSigningContext<'a> {
39    pub fn new(
40        principal_id: impl Into<String>,
41        agent_run_id: impl Into<String>,
42        trace_id: impl Into<String>,
43        transcript_ref: impl Into<String>,
44        principal_key: &'a SigningKey,
45        persona_key: &'a SigningKey,
46    ) -> Self {
47        Self {
48            principal_id: principal_id.into(),
49            persona_id: FIXER_PERSONA_NAME.to_string(),
50            agent_run_id: agent_run_id.into(),
51            trace_id: trace_id.into(),
52            transcript_ref: transcript_ref.into(),
53            timestamp: OffsetDateTime::now_utc(),
54            principal_key,
55            persona_key,
56            tool_call_id: None,
57            original_author_cosignature: false,
58        }
59    }
60}
61
62pub struct FixerProposalInput<'a> {
63    pub blocked_slice: &'a Slice,
64    pub remediation: &'a Remediation,
65    pub atom_index: &'a BTreeMap<AtomId, Atom>,
66    pub coverage: &'a CoverageMap,
67    pub invariants_applied: Vec<(super::PredicateHash, InvariantResult)>,
68    pub approval_chain: Vec<Approval>,
69    pub base_ref: Option<AtomId>,
70    pub signing: FixerSigningContext<'a>,
71}
72
73#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
74pub struct FixerFollowUpProposal {
75    pub slice: Slice,
76    pub intent: IntentId,
77    pub remediation_atoms: Vec<Atom>,
78    pub receipt: FixerReceipt,
79}
80
81#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
82pub struct FixerReceipt {
83    pub trigger: String,
84    pub blocked_slice_id: SliceId,
85    pub follow_up_slice_id: SliceId,
86    pub remediation_atom_ids: Vec<AtomId>,
87    pub principal: String,
88    pub persona: String,
89    pub original_author_cosignature: bool,
90    pub description: String,
91}
92
93#[derive(Debug)]
94pub enum FixerError {
95    InvalidRemediation(String),
96    MissingBlockedAtom(AtomId),
97    DuplicateRemediationAtom(AtomId),
98    Atom(super::AtomError),
99    Slice(SliceDerivationError),
100}
101
102impl fmt::Display for FixerError {
103    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104        match self {
105            Self::InvalidRemediation(message) => write!(f, "invalid remediation: {message}"),
106            Self::MissingBlockedAtom(atom) => {
107                write!(
108                    f,
109                    "blocked slice atom {atom} is missing from the atom index"
110                )
111            }
112            Self::DuplicateRemediationAtom(atom) => {
113                write!(f, "duplicate remediation atom {atom}")
114            }
115            Self::Atom(error) => write!(f, "{error}"),
116            Self::Slice(error) => write!(f, "{error}"),
117        }
118    }
119}
120
121impl std::error::Error for FixerError {}
122
123impl From<super::AtomError> for FixerError {
124    fn from(error: super::AtomError) -> Self {
125        Self::Atom(error)
126    }
127}
128
129impl From<SliceDerivationError> for FixerError {
130    fn from(error: SliceDerivationError) -> Self {
131        Self::Slice(error)
132    }
133}
134
135pub fn propose_follow_up_slice(
136    input: FixerProposalInput<'_>,
137) -> Result<FixerFollowUpProposal, FixerError> {
138    validate_remediation(input.remediation)?;
139    for atom in &input.blocked_slice.atoms {
140        if !input.atom_index.contains_key(atom) {
141            return Err(FixerError::MissingBlockedAtom(*atom));
142        }
143    }
144
145    let remediation_atoms =
146        materialize_remediation_atoms(input.blocked_slice, input.remediation, &input.signing)?;
147    let mut atoms = input.atom_index.clone();
148    let mut remediation_atom_ids = Vec::with_capacity(remediation_atoms.len());
149    for atom in &remediation_atoms {
150        atom.verify()?;
151        if atoms.insert(atom.id, atom.clone()).is_some() {
152            return Err(FixerError::DuplicateRemediationAtom(atom.id));
153        }
154        remediation_atom_ids.push(atom.id);
155    }
156
157    let intent = follow_up_intent_id(input.blocked_slice.id, &remediation_atom_ids);
158    let mut intent_atoms = input.blocked_slice.atoms.clone();
159    intent_atoms.extend(remediation_atom_ids.iter().copied());
160    let intents = BTreeMap::from([(intent, intent_atoms)]);
161    let slice = derive_slice(super::SliceDerivationInput {
162        atoms: &atoms,
163        intents: &intents,
164        candidate_intents: vec![intent],
165        coverage: input.coverage,
166        invariants_applied: input.invariants_applied,
167        approval_chain: input.approval_chain,
168        base_ref: input.base_ref.unwrap_or(input.blocked_slice.base_ref),
169    })?;
170
171    let receipt_persona = normalized_fixer_persona(&input.signing.persona_id).to_string();
172    Ok(FixerFollowUpProposal {
173        receipt: FixerReceipt {
174            trigger: FIXER_TRIGGER.to_string(),
175            blocked_slice_id: input.blocked_slice.id,
176            follow_up_slice_id: slice.id,
177            remediation_atom_ids,
178            principal: input.signing.principal_id,
179            persona: receipt_persona,
180            original_author_cosignature: input.signing.original_author_cosignature,
181            description: input.remediation.description.clone(),
182        },
183        slice,
184        intent,
185        remediation_atoms,
186    })
187}
188
189fn materialize_remediation_atoms(
190    blocked_slice: &Slice,
191    remediation: &Remediation,
192    signing: &FixerSigningContext<'_>,
193) -> Result<Vec<Atom>, FixerError> {
194    let suggested_atoms = remediation_atoms(remediation)?;
195    let mut atoms = Vec::with_capacity(suggested_atoms.len());
196    for template in suggested_atoms {
197        let parents = blocked_slice.atoms.iter().copied().collect::<BTreeSet<_>>();
198        let provenance = Provenance {
199            principal: signing.principal_id.clone(),
200            persona: normalized_fixer_persona(&signing.persona_id).to_string(),
201            agent_run_id: signing.agent_run_id.clone(),
202            tool_call_id: signing.tool_call_id.clone(),
203            trace_id: signing.trace_id.clone(),
204            transcript_ref: signing.transcript_ref.clone(),
205            timestamp: signing.timestamp,
206        };
207        atoms.push(Atom::sign(
208            template.ops.clone(),
209            parents.into_iter().collect(),
210            provenance,
211            template.inverse_of,
212            signing.principal_key,
213            signing.persona_key,
214        )?);
215    }
216    Ok(atoms)
217}
218
219fn validate_remediation(remediation: &Remediation) -> Result<(), FixerError> {
220    if remediation.description.trim().is_empty() {
221        return Err(FixerError::InvalidRemediation(
222            "remediation description is required".to_string(),
223        ));
224    }
225    if remediation.description.chars().count() > MAX_REMEDIATION_DESCRIPTION_CHARS {
226        return Err(FixerError::InvalidRemediation(format!(
227            "remediation description must be <= {MAX_REMEDIATION_DESCRIPTION_CHARS} chars"
228        )));
229    }
230    remediation_atoms(remediation)?;
231    Ok(())
232}
233
234fn remediation_atoms(remediation: &Remediation) -> Result<&[Atom], FixerError> {
235    let atoms = remediation.suggested_atoms.as_deref().ok_or_else(|| {
236        FixerError::InvalidRemediation(
237            "remediation requires at least one suggested atom".to_string(),
238        )
239    })?;
240    if atoms.is_empty() {
241        return Err(FixerError::InvalidRemediation(
242            "remediation requires at least one suggested atom".to_string(),
243        ));
244    }
245    Ok(atoms)
246}
247
248fn normalized_fixer_persona(persona: &str) -> &str {
249    if persona.trim().is_empty() {
250        FIXER_PERSONA_NAME
251    } else {
252        persona
253    }
254}
255
256fn follow_up_intent_id(blocked_slice: SliceId, remediation_atoms: &[AtomId]) -> IntentId {
257    let mut hasher = Sha256::new();
258    hasher.update(b"harn.flow.fixer.follow_up.v0");
259    hasher.update(blocked_slice.0);
260    for atom in remediation_atoms {
261        hasher.update(atom.0);
262    }
263    IntentId(hasher.finalize().into())
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use crate::flow::{AtomSignature, InvariantBlockError, PredicateHash, SliceStatus, TextOp};
270
271    fn key(seed: u8) -> SigningKey {
272        SigningKey::from_bytes(&[seed; 32])
273    }
274
275    fn provenance(persona: &str) -> Provenance {
276        Provenance {
277            principal: "user:alice".to_string(),
278            persona: persona.to_string(),
279            agent_run_id: "run-1".to_string(),
280            tool_call_id: None,
281            trace_id: "trace-1".to_string(),
282            transcript_ref: "transcript-1".to_string(),
283            timestamp: OffsetDateTime::from_unix_timestamp(0).unwrap(),
284        }
285    }
286
287    fn signed_atom(index: u8, ops: Vec<TextOp>, parents: Vec<AtomId>, persona: &str) -> Atom {
288        Atom::sign(
289            ops,
290            parents,
291            provenance(persona),
292            None,
293            &key(index),
294            &key(index + 1),
295        )
296        .unwrap()
297    }
298
299    fn predicate_result(slice: &Slice, atoms: &BTreeMap<AtomId, Atom>) -> InvariantResult {
300        let mut document = Vec::<u8>::new();
301        for atom_id in &slice.atoms {
302            atoms.get(atom_id).unwrap().apply(&mut document).unwrap();
303        }
304        if String::from_utf8(document).unwrap().contains("fixed") {
305            InvariantResult::allow()
306        } else {
307            InvariantResult::block(InvariantBlockError::new(
308                "needs_fix",
309                "slice needs the suggested remediation",
310            ))
311            .with_remediation(
312                Remediation::describe("Append the missing fixed marker.").with_suggested_atoms(
313                    vec![signed_atom(
314                        20,
315                        vec![TextOp::Insert {
316                            offset: 3,
317                            content: " fixed".to_string(),
318                        }],
319                        vec![],
320                        "predicate",
321                    )],
322                ),
323            )
324        }
325    }
326
327    fn blocked_remediation(result: InvariantResult) -> (InvariantBlockError, Remediation) {
328        let error = result
329            .block_error()
330            .expect("expected blocking result")
331            .clone();
332        let remediation = result.remediation.expect("expected remediation suggestion");
333        (error, remediation)
334    }
335
336    #[test]
337    fn fixer_materializes_remediation_as_follow_up_slice_that_passes_predicate() {
338        let original = signed_atom(
339            1,
340            vec![TextOp::Insert {
341                offset: 0,
342                content: "bad".to_string(),
343            }],
344            vec![],
345            "ship-captain",
346        );
347        let blocked_slice = Slice {
348            id: SliceId([9; 32]),
349            atoms: vec![original.id],
350            intents: Vec::new(),
351            invariants_applied: Vec::new(),
352            required_tests: Vec::new(),
353            approval_chain: Vec::new(),
354            base_ref: original.id,
355            status: SliceStatus::Ready,
356        };
357        let atom_index = BTreeMap::from([(original.id, original.clone())]);
358        let (error, remediation) =
359            blocked_remediation(predicate_result(&blocked_slice, &atom_index));
360        let principal_key = key(50);
361        let persona_key = key(51);
362        let mut signing = FixerSigningContext::new(
363            "user:alice",
364            "fixer-run-1",
365            "trace-fix",
366            "transcript-fix",
367            &principal_key,
368            &persona_key,
369        );
370        signing.timestamp = OffsetDateTime::from_unix_timestamp(1).unwrap();
371        signing.original_author_cosignature = true;
372
373        let proposal = propose_follow_up_slice(FixerProposalInput {
374            blocked_slice: &blocked_slice,
375            remediation: &remediation,
376            atom_index: &atom_index,
377            coverage: &CoverageMap::new(),
378            invariants_applied: vec![(
379                PredicateHash::new("predicate:v1"),
380                InvariantResult::block(error).with_remediation(remediation.clone()),
381            )],
382            approval_chain: Vec::new(),
383            base_ref: None,
384            signing,
385        })
386        .unwrap();
387
388        assert_eq!(proposal.slice.atoms.len(), 2);
389        assert!(proposal.slice.atoms.contains(&original.id));
390        let remediation_atom = &proposal.remediation_atoms[0];
391        assert_eq!(remediation_atom.provenance.principal, "user:alice");
392        assert_eq!(remediation_atom.provenance.persona, FIXER_PERSONA_NAME);
393        assert_eq!(
394            remediation_atom.signature.principal_key,
395            principal_key.verifying_key().to_bytes()
396        );
397        assert_eq!(
398            remediation_atom.signature.persona_key,
399            persona_key.verifying_key().to_bytes()
400        );
401        assert!(remediation_atom.parents.contains(&original.id));
402        assert_eq!(proposal.receipt.trigger, FIXER_TRIGGER);
403        assert!(proposal.receipt.original_author_cosignature);
404
405        let mut follow_up_atoms = atom_index;
406        follow_up_atoms.insert(remediation_atom.id, remediation_atom.clone());
407        assert_eq!(
408            predicate_result(&proposal.slice, &follow_up_atoms),
409            InvariantResult::allow()
410        );
411    }
412
413    #[test]
414    fn remediation_rejects_empty_suggestions() {
415        let remediation = Remediation::describe("Do something").with_suggested_atoms(Vec::new());
416        let error = validate_remediation(&remediation).unwrap_err();
417        assert!(matches!(error, FixerError::InvalidRemediation(_)));
418    }
419
420    #[test]
421    fn materialized_atom_signature_verifies() {
422        let template = Atom {
423            id: AtomId([7; 32]),
424            ops: vec![TextOp::Insert {
425                offset: 0,
426                content: "fixed".to_string(),
427            }],
428            parents: Vec::new(),
429            provenance: provenance("predicate"),
430            signature: AtomSignature {
431                principal_key: [0; 32],
432                principal_sig: [0; 64],
433                persona_key: [0; 32],
434                persona_sig: [0; 64],
435            },
436            inverse_of: None,
437        };
438        let original = signed_atom(1, Vec::new(), Vec::new(), "ship-captain");
439        let remediation =
440            Remediation::describe("Apply the fix").with_suggested_atoms(vec![template]);
441        let principal_key = key(60);
442        let persona_key = key(61);
443        let signing = FixerSigningContext::new(
444            "fixer-service",
445            "fixer-run-2",
446            "trace-fix",
447            "transcript-fix",
448            &principal_key,
449            &persona_key,
450        );
451
452        let atoms = materialize_remediation_atoms(
453            &Slice {
454                id: SliceId([1; 32]),
455                atoms: vec![original.id],
456                intents: Vec::new(),
457                invariants_applied: Vec::new(),
458                required_tests: Vec::new(),
459                approval_chain: Vec::new(),
460                base_ref: original.id,
461                status: SliceStatus::Ready,
462            },
463            &remediation,
464            &signing,
465        )
466        .unwrap();
467
468        atoms[0].verify().unwrap();
469        assert_eq!(atoms[0].provenance.persona, FIXER_PERSONA_NAME);
470    }
471}