1use 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}