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