Skip to main content

harn_vm/orchestration/
friction.rs

1//! Friction events, context-pack manifests, and suggestion generation.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use serde::{Deserialize, Serialize};
6
7use super::{new_id, now_rfc3339, parse_json_payload};
8use crate::llm::vm_value_to_json;
9use crate::redact::{RedactionPolicy, REDACTED_PLACEHOLDER};
10use crate::value::{VmError, VmValue};
11
12pub const FRICTION_SCHEMA_VERSION: u32 = 1;
13pub const CONTEXT_PACK_MANIFEST_VERSION: u32 = 1;
14
15const FRICTION_KINDS: &[&str] = &[
16    "repeated_query",
17    "repeated_clarification",
18    "approval_stall",
19    "missing_context",
20    "manual_handoff",
21    "tool_gap",
22    "failed_assumption",
23    "expensive_model_used_for_deterministic_step",
24    "human_hypothesis",
25];
26
27#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
28#[serde(default)]
29pub struct FrictionEvent {
30    pub schema_version: u32,
31    pub id: String,
32    pub kind: String,
33    pub source: Option<String>,
34    pub actor: Option<String>,
35    pub tenant_id: Option<String>,
36    pub task_id: Option<String>,
37    pub run_id: Option<String>,
38    pub workflow_id: Option<String>,
39    pub tool: Option<String>,
40    pub provider: Option<String>,
41    pub redacted_summary: String,
42    pub estimated_cost_usd: Option<f64>,
43    pub estimated_time_ms: Option<i64>,
44    pub recurrence_hints: Vec<String>,
45    pub trace_id: Option<String>,
46    pub span_id: Option<String>,
47    pub links: Vec<FrictionLink>,
48    pub human_hypothesis: Option<HumanHypothesis>,
49    pub metadata: BTreeMap<String, serde_json::Value>,
50    pub timestamp: String,
51}
52
53#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
54#[serde(default)]
55pub struct FrictionLink {
56    pub label: Option<String>,
57    pub url: Option<String>,
58    pub trace_id: Option<String>,
59}
60
61#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
62#[serde(default)]
63pub struct HumanHypothesis {
64    pub note: String,
65    pub confidence: Option<f64>,
66    pub expires_at: Option<String>,
67    pub checkback_at: Option<String>,
68    pub suggested_verification_tools: Vec<String>,
69    pub status: Option<String>,
70    pub evidence_outcome: Option<String>,
71}
72
73#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
74#[serde(default)]
75pub struct ContextPackManifest {
76    pub version: u32,
77    pub id: String,
78    pub name: String,
79    pub description: Option<String>,
80    pub owner: String,
81    pub triggers: Vec<ContextPackTrigger>,
82    pub inputs: Vec<ContextPackInput>,
83    pub included_queries: Vec<ContextPackQuery>,
84    pub included_docs: Vec<ContextPackDoc>,
85    pub included_tools: Vec<ContextPackTool>,
86    pub refresh_policy: ContextPackRefreshPolicy,
87    pub secrets: Vec<ContextPackSecretRef>,
88    pub capabilities: Vec<String>,
89    pub output_slots: Vec<ContextPackOutputSlot>,
90    pub fallback_instructions: Option<String>,
91    pub review: Option<ContextPackReviewPolicy>,
92    pub metadata: BTreeMap<String, serde_json::Value>,
93}
94
95#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
96#[serde(default)]
97pub struct ContextPackTrigger {
98    pub kind: String,
99    pub source: Option<String>,
100    pub match_hint: Option<String>,
101}
102
103#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
104#[serde(default)]
105pub struct ContextPackInput {
106    pub name: String,
107    pub description: Option<String>,
108    pub required: bool,
109    pub source: Option<String>,
110}
111
112#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
113#[serde(default)]
114pub struct ContextPackQuery {
115    pub id: String,
116    pub label: Option<String>,
117    pub provider: Option<String>,
118    pub query: String,
119    pub filters: BTreeMap<String, String>,
120    pub output_slot: Option<String>,
121}
122
123#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
124#[serde(default)]
125pub struct ContextPackDoc {
126    pub id: String,
127    pub title: Option<String>,
128    pub url: Option<String>,
129    pub path: Option<String>,
130    pub freshness: Option<String>,
131}
132
133#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
134#[serde(default)]
135pub struct ContextPackTool {
136    pub name: String,
137    pub capability: Option<String>,
138    pub purpose: Option<String>,
139    pub deterministic: bool,
140}
141
142#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
143#[serde(default)]
144pub struct ContextPackRefreshPolicy {
145    pub mode: String,
146    pub interval: Option<String>,
147    pub stale_after: Option<String>,
148}
149
150impl Default for ContextPackRefreshPolicy {
151    fn default() -> Self {
152        Self {
153            mode: "on_demand".to_string(),
154            interval: None,
155            stale_after: None,
156        }
157    }
158}
159
160#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
161#[serde(default)]
162pub struct ContextPackSecretRef {
163    pub name: String,
164    pub capability: Option<String>,
165    pub required: bool,
166}
167
168#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
169#[serde(default)]
170pub struct ContextPackOutputSlot {
171    pub name: String,
172    pub description: Option<String>,
173    pub artifact_kind: Option<String>,
174}
175
176#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
177#[serde(default)]
178pub struct ContextPackReviewPolicy {
179    pub owner: Option<String>,
180    pub approval_required: bool,
181    pub privacy_notes: Vec<String>,
182}
183
184#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
185#[serde(default)]
186pub struct ContextPackSuggestion {
187    #[serde(rename = "_type")]
188    pub type_name: String,
189    pub id: String,
190    pub title: String,
191    pub recommended_artifact: String,
192    pub confidence: f64,
193    pub candidate_manifest: ContextPackManifest,
194    pub evidence: Vec<ContextPackSuggestionEvidence>,
195    pub examples: Vec<String>,
196    pub estimated_savings: ContextPackEstimatedSavings,
197    pub risk_privacy_notes: Vec<String>,
198    pub source_event_ids: Vec<String>,
199    pub created_at: String,
200    pub metadata: BTreeMap<String, serde_json::Value>,
201}
202
203#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
204#[serde(default)]
205pub struct ContextPackSuggestionEvidence {
206    pub event_id: String,
207    pub kind: String,
208    pub source: Option<String>,
209    pub tool: Option<String>,
210    pub provider: Option<String>,
211    pub redacted_summary: String,
212    pub run_id: Option<String>,
213    pub trace_id: Option<String>,
214    pub estimated_cost_usd: Option<f64>,
215    pub estimated_time_ms: Option<i64>,
216}
217
218#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
219#[serde(default)]
220pub struct ContextPackEstimatedSavings {
221    pub occurrences: usize,
222    pub estimated_time_saved_ms: i64,
223    pub estimated_cost_saved_usd: f64,
224}
225
226#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
227#[serde(default)]
228pub struct ContextPackSuggestionOptions {
229    pub min_occurrences: usize,
230    pub owner: Option<String>,
231}
232
233#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
234#[serde(default)]
235pub struct ContextPackSuggestionExpectation {
236    pub min_suggestions: Option<usize>,
237    pub recommended_artifact: Option<String>,
238    pub title_contains: Option<String>,
239    pub manifest_name_contains: Option<String>,
240    pub required_capability: Option<String>,
241    pub required_output_slot: Option<String>,
242}
243
244pub fn friction_kind_allowed(kind: &str) -> bool {
245    FRICTION_KINDS.contains(&kind)
246}
247
248pub fn normalize_friction_event(value: &VmValue) -> Result<FrictionEvent, VmError> {
249    let mut json = vm_value_to_json(value);
250    if let Some(map) = json.as_object_mut() {
251        if !map.contains_key("redacted_summary") {
252            if let Some(summary) = map
253                .get("summary")
254                .and_then(|value| value.as_str())
255                .or_else(|| map.get("message").and_then(|value| value.as_str()))
256            {
257                map.insert(
258                    "redacted_summary".to_string(),
259                    serde_json::Value::String(redact_text(summary)),
260                );
261            }
262        }
263        map.remove("summary");
264        map.remove("message");
265        map.remove("raw_content");
266        map.remove("raw_prompt");
267        if let Some(metadata) = map.get_mut("metadata") {
268            redact_json_value(metadata);
269        }
270    }
271    normalize_friction_event_json(json)
272}
273
274pub fn normalize_friction_event_json(json: serde_json::Value) -> Result<FrictionEvent, VmError> {
275    let mut event: FrictionEvent = parse_json_payload(json, "friction_event")?;
276    if event.schema_version == 0 {
277        event.schema_version = FRICTION_SCHEMA_VERSION;
278    }
279    if event.id.is_empty() {
280        event.id = new_id("friction");
281    }
282    if event.timestamp.is_empty() {
283        event.timestamp = now_rfc3339();
284    }
285    event.kind = event.kind.trim().to_ascii_lowercase();
286    if event.kind.is_empty() {
287        return Err(VmError::Runtime("friction_event: missing kind".to_string()));
288    }
289    if !friction_kind_allowed(&event.kind) {
290        return Err(VmError::Runtime(format!(
291            "friction_event: unsupported kind '{}' (expected one of {})",
292            event.kind,
293            FRICTION_KINDS.join(", ")
294        )));
295    }
296    event.redacted_summary = redact_text(&event.redacted_summary);
297    if event.redacted_summary.trim().is_empty() {
298        return Err(VmError::Runtime(
299            "friction_event: missing redacted_summary".to_string(),
300        ));
301    }
302    redact_metadata_map(&mut event.metadata);
303    Ok(event)
304}
305
306pub fn normalize_context_pack_manifest(value: &VmValue) -> Result<ContextPackManifest, VmError> {
307    normalize_context_pack_manifest_json(vm_value_to_json(value))
308}
309
310pub fn normalize_context_pack_manifest_json(
311    json: serde_json::Value,
312) -> Result<ContextPackManifest, VmError> {
313    let mut manifest: ContextPackManifest = parse_json_payload(json, "context_pack_manifest")?;
314    normalize_context_pack_manifest_record(&mut manifest)?;
315    Ok(manifest)
316}
317
318pub fn parse_context_pack_manifest_src(src: &str) -> Result<ContextPackManifest, VmError> {
319    let trimmed = src.trim_start();
320    let mut manifest: ContextPackManifest = if trimmed.starts_with('{') {
321        serde_json::from_str(src).map_err(|e| {
322            VmError::Runtime(format!("context_pack_manifest_parse: invalid JSON: {e}"))
323        })?
324    } else {
325        toml::from_str(src).map_err(|e| {
326            VmError::Runtime(format!("context_pack_manifest_parse: invalid TOML: {e}"))
327        })?
328    };
329    normalize_context_pack_manifest_record(&mut manifest)?;
330    Ok(manifest)
331}
332
333pub fn generate_context_pack_suggestions(
334    events: &[FrictionEvent],
335    options: &ContextPackSuggestionOptions,
336) -> Vec<ContextPackSuggestion> {
337    let min_occurrences = options.min_occurrences.max(2);
338    let mut groups: BTreeMap<String, Vec<FrictionEvent>> = BTreeMap::new();
339    for event in events {
340        groups
341            .entry(friction_group_key(event))
342            .or_default()
343            .push(event.clone());
344    }
345
346    groups
347        .into_values()
348        .filter(|group| group.len() >= min_occurrences)
349        .map(|group| build_suggestion(group, options))
350        .collect()
351}
352
353pub fn evaluate_context_pack_suggestion_expectations(
354    suggestions: &[ContextPackSuggestion],
355    expectations: &[ContextPackSuggestionExpectation],
356) -> Vec<String> {
357    let mut failures = Vec::new();
358    for expectation in expectations {
359        if let Some(min) = expectation.min_suggestions {
360            if suggestions.len() < min {
361                failures.push(format!(
362                    "expected at least {min} context-pack suggestion(s), got {}",
363                    suggestions.len()
364                ));
365                continue;
366            }
367        }
368        if expectation_has_match(expectation, suggestions) {
369            continue;
370        }
371        failures.push(format!(
372            "no context-pack suggestion matched expectation {expectation:?}"
373        ));
374    }
375    failures
376}
377
378pub fn normalize_friction_events_json(
379    value: serde_json::Value,
380) -> Result<Vec<FrictionEvent>, VmError> {
381    let items = if let Some(events) = value.get("events").and_then(|events| events.as_array()) {
382        events.clone()
383    } else if let Some(array) = value.as_array() {
384        array.clone()
385    } else {
386        return Err(VmError::Runtime(
387            "friction events fixture must be an array or {events: [...]}".to_string(),
388        ));
389    };
390    items
391        .into_iter()
392        .map(normalize_friction_event_json)
393        .collect()
394}
395
396pub fn parse_friction_events_value(value: &VmValue) -> Result<Vec<FrictionEvent>, VmError> {
397    normalize_friction_events_json(vm_value_to_json(value))
398}
399
400fn normalize_context_pack_manifest_record(
401    manifest: &mut ContextPackManifest,
402) -> Result<(), VmError> {
403    if manifest.version == 0 {
404        manifest.version = CONTEXT_PACK_MANIFEST_VERSION;
405    }
406    if manifest.name.trim().is_empty() {
407        return Err(VmError::Runtime(
408            "context_pack_manifest: missing name".to_string(),
409        ));
410    }
411    if manifest.id.trim().is_empty() {
412        manifest.id = slugify(&manifest.name);
413    }
414    if manifest.owner.trim().is_empty() {
415        return Err(VmError::Runtime(
416            "context_pack_manifest: missing owner".to_string(),
417        ));
418    }
419    for secret in &manifest.secrets {
420        if value_looks_secret(&secret.name)
421            || value_looks_secret(secret.capability.as_deref().unwrap_or(""))
422        {
423            return Err(VmError::Runtime(
424                "context_pack_manifest: secrets must be capability references, not raw secret values"
425                    .to_string(),
426            ));
427        }
428    }
429    for query in &manifest.included_queries {
430        if query.id.trim().is_empty() || query.query.trim().is_empty() {
431            return Err(VmError::Runtime(
432                "context_pack_manifest: included queries require id and query".to_string(),
433            ));
434        }
435    }
436    Ok(())
437}
438
439fn build_suggestion(
440    mut group: Vec<FrictionEvent>,
441    options: &ContextPackSuggestionOptions,
442) -> ContextPackSuggestion {
443    group.sort_by(|left, right| {
444        left.timestamp
445            .cmp(&right.timestamp)
446            .then(left.id.cmp(&right.id))
447    });
448    let first = group.first().expect("filtered non-empty group");
449    let title = suggestion_title(first);
450    let recommended_artifact = recommended_artifact_for_kind(&first.kind).to_string();
451    let evidence = group
452        .iter()
453        .map(|event| ContextPackSuggestionEvidence {
454            event_id: event.id.clone(),
455            kind: event.kind.clone(),
456            source: event.source.clone(),
457            tool: event.tool.clone(),
458            provider: event.provider.clone(),
459            redacted_summary: event.redacted_summary.clone(),
460            run_id: event.run_id.clone(),
461            trace_id: event.trace_id.clone(),
462            estimated_cost_usd: event.estimated_cost_usd,
463            estimated_time_ms: event.estimated_time_ms,
464        })
465        .collect::<Vec<_>>();
466    let examples = group
467        .iter()
468        .map(|event| event.redacted_summary.clone())
469        .collect::<BTreeSet<_>>()
470        .into_iter()
471        .take(3)
472        .collect::<Vec<_>>();
473    let candidate_manifest =
474        candidate_manifest_for_group(&title, &recommended_artifact, &group, options);
475    let occurrences = group.len();
476    let estimated_time_saved_ms = group
477        .iter()
478        .skip(1)
479        .filter_map(|event| event.estimated_time_ms)
480        .sum();
481    let estimated_cost_saved_usd = group
482        .iter()
483        .skip(1)
484        .filter_map(|event| event.estimated_cost_usd)
485        .sum();
486    let source_event_ids = group.iter().map(|event| event.id.clone()).collect();
487    ContextPackSuggestion {
488        type_name: "context_pack_suggestion".to_string(),
489        id: new_id("context_pack_suggestion"),
490        title,
491        recommended_artifact,
492        confidence: confidence_for_occurrences(occurrences),
493        candidate_manifest,
494        evidence,
495        examples,
496        estimated_savings: ContextPackEstimatedSavings {
497            occurrences,
498            estimated_time_saved_ms,
499            estimated_cost_saved_usd,
500        },
501        risk_privacy_notes: vec![
502            "Evidence uses redacted summaries; raw prompts, raw content, and secret-looking metadata are not retained.".to_string(),
503            "Review required before enabling this context pack for future runs.".to_string(),
504        ],
505        source_event_ids,
506        created_at: now_rfc3339(),
507        metadata: BTreeMap::new(),
508    }
509}
510
511fn candidate_manifest_for_group(
512    title: &str,
513    recommended_artifact: &str,
514    group: &[FrictionEvent],
515    options: &ContextPackSuggestionOptions,
516) -> ContextPackManifest {
517    let first = group.first().expect("non-empty suggestion group");
518    let mut included_queries = Vec::new();
519    let mut included_docs = Vec::new();
520    let mut included_tools = Vec::new();
521    let mut capabilities = BTreeSet::new();
522    let mut secrets = BTreeMap::<String, ContextPackSecretRef>::new();
523    let mut output_slots = BTreeMap::<String, ContextPackOutputSlot>::new();
524
525    for event in group {
526        if let Some(query) = metadata_string(event, "query")
527            .or_else(|| metadata_string(event, "deterministic_query"))
528        {
529            let id = format!("query_{}", included_queries.len() + 1);
530            included_queries.push(ContextPackQuery {
531                id: id.clone(),
532                label: metadata_string(event, "query_label").or_else(|| event.tool.clone()),
533                provider: event.provider.clone().or_else(|| event.tool.clone()),
534                query,
535                filters: metadata_string_map(event, "filters"),
536                output_slot: metadata_string(event, "output_slot")
537                    .or_else(|| Some("primary_context".to_string())),
538            });
539        }
540        if let Some(doc) =
541            metadata_string(event, "doc_url").or_else(|| metadata_string(event, "document_url"))
542        {
543            included_docs.push(ContextPackDoc {
544                id: format!("doc_{}", included_docs.len() + 1),
545                title: metadata_string(event, "doc_title"),
546                url: Some(doc),
547                path: None,
548                freshness: metadata_string(event, "freshness"),
549            });
550        }
551        if let Some(path) =
552            metadata_string(event, "doc_path").or_else(|| metadata_string(event, "document_path"))
553        {
554            included_docs.push(ContextPackDoc {
555                id: format!("doc_{}", included_docs.len() + 1),
556                title: metadata_string(event, "doc_title"),
557                url: None,
558                path: Some(path),
559                freshness: metadata_string(event, "freshness"),
560            });
561        }
562        if let Some(tool) = event
563            .tool
564            .clone()
565            .or_else(|| metadata_string(event, "tool"))
566        {
567            included_tools.push(ContextPackTool {
568                name: tool.clone(),
569                capability: metadata_string(event, "capability"),
570                purpose: Some(event.redacted_summary.clone()),
571                deterministic: matches!(event.kind.as_str(), "repeated_query" | "missing_context"),
572            });
573        }
574        if let Some(capability) = metadata_string(event, "capability") {
575            capabilities.insert(capability);
576        }
577        if let Some(secret_ref) = metadata_string(event, "secret_ref") {
578            secrets
579                .entry(secret_ref.clone())
580                .or_insert(ContextPackSecretRef {
581                    name: secret_ref,
582                    capability: metadata_string(event, "capability"),
583                    required: true,
584                });
585        }
586        let slot =
587            metadata_string(event, "output_slot").unwrap_or_else(|| "primary_context".to_string());
588        output_slots
589            .entry(slot.clone())
590            .or_insert(ContextPackOutputSlot {
591                name: slot,
592                description: Some("Context gathered before the agent starts work".to_string()),
593                artifact_kind: Some("context".to_string()),
594            });
595    }
596
597    if included_queries.is_empty()
598        && matches!(first.kind.as_str(), "repeated_query" | "missing_context")
599    {
600        included_queries.push(ContextPackQuery {
601            id: "query_1".to_string(),
602            label: first.tool.clone().or_else(|| first.provider.clone()),
603            provider: first.provider.clone().or_else(|| first.tool.clone()),
604            query: first.redacted_summary.clone(),
605            filters: BTreeMap::new(),
606            output_slot: Some("primary_context".to_string()),
607        });
608    }
609
610    ContextPackManifest {
611        version: CONTEXT_PACK_MANIFEST_VERSION,
612        id: slugify(title),
613        name: title.to_string(),
614        description: Some(format!(
615            "Candidate generated from repeated {} friction; review before promotion.",
616            first.kind
617        )),
618        owner: options
619            .owner
620            .clone()
621            .or_else(|| first.actor.clone())
622            .unwrap_or_else(|| "team".to_string()),
623        triggers: vec![ContextPackTrigger {
624            kind: first.kind.clone(),
625            source: first.source.clone(),
626            match_hint: first.recurrence_hints.first().cloned(),
627        }],
628        inputs: vec![ContextPackInput {
629            name: "incident_or_task".to_string(),
630            description: Some("The current incident, ticket, run, or task identifier.".to_string()),
631            required: true,
632            source: first.source.clone(),
633        }],
634        included_queries,
635        included_docs,
636        included_tools,
637        refresh_policy: ContextPackRefreshPolicy::default(),
638        secrets: secrets.into_values().collect(),
639        capabilities: capabilities.into_iter().collect(),
640        output_slots: output_slots.into_values().collect(),
641        fallback_instructions: Some(
642            "If deterministic context is insufficient, ask a scoped clarifying question and record a new friction event.".to_string(),
643        ),
644        review: Some(ContextPackReviewPolicy {
645            owner: options.owner.clone(),
646            approval_required: true,
647            privacy_notes: vec!["Confirm queries and docs do not expose raw customer secrets.".to_string()],
648        }),
649        metadata: BTreeMap::from([(
650            "recommended_artifact".to_string(),
651            serde_json::json!(recommended_artifact),
652        )]),
653    }
654}
655
656fn expectation_has_match(
657    expectation: &ContextPackSuggestionExpectation,
658    suggestions: &[ContextPackSuggestion],
659) -> bool {
660    if expectation.min_suggestions.is_some()
661        && expectation.recommended_artifact.is_none()
662        && expectation.title_contains.is_none()
663        && expectation.manifest_name_contains.is_none()
664        && expectation.required_capability.is_none()
665        && expectation.required_output_slot.is_none()
666    {
667        return true;
668    }
669    suggestions.iter().any(|suggestion| {
670        expectation
671            .recommended_artifact
672            .as_ref()
673            .is_none_or(|expected| suggestion.recommended_artifact == *expected)
674            && expectation.title_contains.as_ref().is_none_or(|needle| {
675                suggestion
676                    .title
677                    .to_ascii_lowercase()
678                    .contains(&needle.to_ascii_lowercase())
679            })
680            && expectation
681                .manifest_name_contains
682                .as_ref()
683                .is_none_or(|needle| {
684                    suggestion
685                        .candidate_manifest
686                        .name
687                        .to_ascii_lowercase()
688                        .contains(&needle.to_ascii_lowercase())
689                })
690            && expectation
691                .required_capability
692                .as_ref()
693                .is_none_or(|capability| {
694                    suggestion
695                        .candidate_manifest
696                        .capabilities
697                        .iter()
698                        .any(|candidate| candidate == capability)
699                        || suggestion
700                            .candidate_manifest
701                            .included_tools
702                            .iter()
703                            .any(|tool| tool.capability.as_ref() == Some(capability))
704                })
705            && expectation
706                .required_output_slot
707                .as_ref()
708                .is_none_or(|slot| {
709                    suggestion
710                        .candidate_manifest
711                        .output_slots
712                        .iter()
713                        .any(|candidate| &candidate.name == slot)
714                })
715    })
716}
717
718fn friction_group_key(event: &FrictionEvent) -> String {
719    let hint = event
720        .recurrence_hints
721        .first()
722        .cloned()
723        .unwrap_or_else(|| normalize_words(&event.redacted_summary));
724    format!(
725        "{}|{}|{}|{}|{}",
726        event.kind,
727        event.source.as_deref().unwrap_or(""),
728        event.tool.as_deref().unwrap_or(""),
729        event.provider.as_deref().unwrap_or(""),
730        hint
731    )
732}
733
734fn suggestion_title(event: &FrictionEvent) -> String {
735    let source = event.source.as_deref().unwrap_or("team");
736    let topic = event.recurrence_hints.first().cloned().unwrap_or_else(|| {
737        normalize_words(&event.redacted_summary)
738            .split_whitespace()
739            .take(6)
740            .collect::<Vec<_>>()
741            .join(" ")
742    });
743    format!("{source} {topic} context pack")
744}
745
746fn recommended_artifact_for_kind(kind: &str) -> &'static str {
747    match kind {
748        "approval_stall" | "manual_handoff" => "workflow",
749        "tool_gap" | "failed_assumption" | "expensive_model_used_for_deterministic_step" => "both",
750        _ => "context_pack",
751    }
752}
753
754fn confidence_for_occurrences(occurrences: usize) -> f64 {
755    match occurrences {
756        0 | 1 => 0.0,
757        2 => 0.62,
758        3 => 0.74,
759        4 => 0.82,
760        _ => 0.9,
761    }
762}
763
764fn metadata_string(event: &FrictionEvent, key: &str) -> Option<String> {
765    event
766        .metadata
767        .get(key)
768        .and_then(|value| value.as_str())
769        .filter(|value| !value.trim().is_empty())
770        .map(str::to_string)
771}
772
773fn metadata_string_map(event: &FrictionEvent, key: &str) -> BTreeMap<String, String> {
774    event
775        .metadata
776        .get(key)
777        .and_then(|value| value.as_object())
778        .map(|map| {
779            map.iter()
780                .filter_map(|(key, value)| {
781                    value.as_str().map(|value| (key.clone(), value.to_string()))
782                })
783                .collect()
784        })
785        .unwrap_or_default()
786}
787
788fn redact_json_value(value: &mut serde_json::Value) {
789    RedactionPolicy::default().redact_json_in_place(value);
790}
791
792fn redact_metadata_map(map: &mut BTreeMap<String, serde_json::Value>) {
793    let policy = RedactionPolicy::default();
794    for (key, value) in map {
795        if policy.field_is_sensitive(key) {
796            *value = serde_json::Value::String(REDACTED_PLACEHOLDER.to_string());
797        } else {
798            policy.redact_json_in_place(value);
799        }
800    }
801}
802
803fn redact_text(text: &str) -> String {
804    RedactionPolicy::default().redact_string(text).into_owned()
805}
806
807fn value_looks_secret(value: &str) -> bool {
808    let trimmed = value.trim();
809    !trimmed.is_empty() && RedactionPolicy::default().looks_like_secret_value(trimmed)
810}
811
812fn normalize_words(text: &str) -> String {
813    text.chars()
814        .map(|ch| {
815            if ch.is_ascii_alphanumeric() || ch.is_whitespace() {
816                ch.to_ascii_lowercase()
817            } else {
818                ' '
819            }
820        })
821        .collect::<String>()
822        .split_whitespace()
823        .collect::<Vec<_>>()
824        .join(" ")
825}
826
827fn slugify(text: &str) -> String {
828    let slug = normalize_words(text)
829        .split_whitespace()
830        .take(8)
831        .collect::<Vec<_>>()
832        .join("_");
833    if slug.is_empty() {
834        new_id("context_pack")
835    } else {
836        slug
837    }
838}
839
840#[cfg(test)]
841mod tests {
842    use super::*;
843    use serde_json::json;
844
845    #[test]
846    fn friction_event_normalizes_and_redacts_sensitive_metadata() {
847        let event = normalize_friction_event_json(json!({
848            "kind": "repeated_query",
849            "source": "incident-triage",
850            "redacted_summary": "Run Splunk query token=abc123",
851            "metadata": {
852                "query": "index=prod error",
853                "api_key": "sk-live-secret"
854            }
855        }))
856        .unwrap();
857
858        assert_eq!(event.schema_version, FRICTION_SCHEMA_VERSION);
859        assert!(event.id.starts_with("friction_"));
860        assert!(event.redacted_summary.contains("<redacted:"));
861        assert!(!event.redacted_summary.contains("token=abc123"));
862        assert_eq!(event.metadata["api_key"], json!("[redacted]"));
863    }
864
865    #[test]
866    fn context_pack_manifest_rejects_raw_secret_values() {
867        let err = normalize_context_pack_manifest_json(json!({
868            "name": "Incident pack",
869            "owner": "sre",
870            "secrets": [{"name": "sk-live-secret"}]
871        }))
872        .unwrap_err();
873
874        assert!(err.to_string().contains("raw secret"));
875    }
876
877    #[test]
878    fn repeated_incident_events_produce_context_pack_suggestion() {
879        let events = vec![
880            normalize_friction_event_json(json!({
881                "kind": "repeated_query",
882                "source": "incident-triage",
883                "actor": "sre",
884                "tool": "splunk",
885                "provider": "splunk",
886                "redacted_summary": "Every checkout incident needs the checkout error query",
887                "estimated_time_ms": 300000,
888                "estimated_cost_usd": 0.12,
889                "recurrence_hints": ["checkout incident queries"],
890                "metadata": {
891                    "query": "index=checkout service=api error",
892                    "capability": "splunk.search",
893                    "secret_ref": "SPLUNK_READ_TOKEN",
894                    "output_slot": "splunk_errors"
895                }
896            }))
897            .unwrap(),
898            normalize_friction_event_json(json!({
899                "kind": "repeated_query",
900                "source": "incident-triage",
901                "actor": "sre",
902                "tool": "splunk",
903                "provider": "splunk",
904                "redacted_summary": "Need the same checkout error search again",
905                "estimated_time_ms": 240000,
906                "estimated_cost_usd": 0.10,
907                "recurrence_hints": ["checkout incident queries"],
908                "metadata": {
909                    "query": "index=checkout service=api error",
910                    "capability": "splunk.search",
911                    "secret_ref": "SPLUNK_READ_TOKEN",
912                    "output_slot": "splunk_errors"
913                }
914            }))
915            .unwrap(),
916        ];
917
918        let suggestions = generate_context_pack_suggestions(
919            &events,
920            &ContextPackSuggestionOptions {
921                min_occurrences: 2,
922                owner: Some("sre".to_string()),
923            },
924        );
925
926        assert_eq!(suggestions.len(), 1);
927        let suggestion = &suggestions[0];
928        assert_eq!(suggestion.recommended_artifact, "context_pack");
929        assert_eq!(suggestion.estimated_savings.occurrences, 2);
930        assert_eq!(suggestion.estimated_savings.estimated_time_saved_ms, 240000);
931        assert_eq!(
932            suggestion.candidate_manifest.capabilities,
933            vec!["splunk.search"]
934        );
935        assert_eq!(
936            suggestion.candidate_manifest.output_slots[0].name,
937            "splunk_errors"
938        );
939    }
940}