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 {expectation:?}"
374        ));
375    }
376    failures
377}
378
379pub fn normalize_friction_events_json(
380    value: serde_json::Value,
381) -> Result<Vec<FrictionEvent>, VmError> {
382    let items = if let Some(events) = value.get("events").and_then(|events| events.as_array()) {
383        events.clone()
384    } else if let Some(array) = value.as_array() {
385        array.clone()
386    } else {
387        return Err(VmError::Runtime(
388            "friction events fixture must be an array or {events: [...]}".to_string(),
389        ));
390    };
391    items
392        .into_iter()
393        .map(normalize_friction_event_json)
394        .collect()
395}
396
397pub fn parse_friction_events_value(value: &VmValue) -> Result<Vec<FrictionEvent>, VmError> {
398    normalize_friction_events_json(vm_value_to_json(value))
399}
400
401fn normalize_context_pack_manifest_record(
402    manifest: &mut ContextPackManifest,
403) -> Result<(), VmError> {
404    if manifest.version == 0 {
405        manifest.version = CONTEXT_PACK_MANIFEST_VERSION;
406    }
407    if manifest.name.trim().is_empty() {
408        return Err(VmError::Runtime(
409            "context_pack_manifest: missing name".to_string(),
410        ));
411    }
412    if manifest.id.trim().is_empty() {
413        manifest.id = slugify(&manifest.name);
414    }
415    if manifest.owner.trim().is_empty() {
416        return Err(VmError::Runtime(
417            "context_pack_manifest: missing owner".to_string(),
418        ));
419    }
420    for secret in &manifest.secrets {
421        if looks_like_secret_value(&secret.name)
422            || looks_like_secret_value(secret.capability.as_deref().unwrap_or(""))
423        {
424            return Err(VmError::Runtime(
425                "context_pack_manifest: secrets must be capability references, not raw secret values"
426                    .to_string(),
427            ));
428        }
429    }
430    for query in &manifest.included_queries {
431        if query.id.trim().is_empty() || query.query.trim().is_empty() {
432            return Err(VmError::Runtime(
433                "context_pack_manifest: included queries require id and query".to_string(),
434            ));
435        }
436    }
437    Ok(())
438}
439
440fn build_suggestion(
441    mut group: Vec<FrictionEvent>,
442    options: &ContextPackSuggestionOptions,
443) -> ContextPackSuggestion {
444    group.sort_by(|left, right| {
445        left.timestamp
446            .cmp(&right.timestamp)
447            .then(left.id.cmp(&right.id))
448    });
449    let first = group.first().expect("filtered non-empty group");
450    let title = suggestion_title(first);
451    let recommended_artifact = recommended_artifact_for_kind(&first.kind).to_string();
452    let evidence = group
453        .iter()
454        .map(|event| ContextPackSuggestionEvidence {
455            event_id: event.id.clone(),
456            kind: event.kind.clone(),
457            source: event.source.clone(),
458            tool: event.tool.clone(),
459            provider: event.provider.clone(),
460            redacted_summary: event.redacted_summary.clone(),
461            run_id: event.run_id.clone(),
462            trace_id: event.trace_id.clone(),
463            estimated_cost_usd: event.estimated_cost_usd,
464            estimated_time_ms: event.estimated_time_ms,
465        })
466        .collect::<Vec<_>>();
467    let examples = group
468        .iter()
469        .map(|event| event.redacted_summary.clone())
470        .collect::<BTreeSet<_>>()
471        .into_iter()
472        .take(3)
473        .collect::<Vec<_>>();
474    let candidate_manifest =
475        candidate_manifest_for_group(&title, &recommended_artifact, &group, options);
476    let occurrences = group.len();
477    let estimated_time_saved_ms = group
478        .iter()
479        .skip(1)
480        .filter_map(|event| event.estimated_time_ms)
481        .sum();
482    let estimated_cost_saved_usd = group
483        .iter()
484        .skip(1)
485        .filter_map(|event| event.estimated_cost_usd)
486        .sum();
487    let source_event_ids = group.iter().map(|event| event.id.clone()).collect();
488    ContextPackSuggestion {
489        type_name: "context_pack_suggestion".to_string(),
490        id: new_id("context_pack_suggestion"),
491        title,
492        recommended_artifact,
493        confidence: confidence_for_occurrences(occurrences),
494        candidate_manifest,
495        evidence,
496        examples,
497        estimated_savings: ContextPackEstimatedSavings {
498            occurrences,
499            estimated_time_saved_ms,
500            estimated_cost_saved_usd,
501        },
502        risk_privacy_notes: vec![
503            "Evidence uses redacted summaries; raw prompts, raw content, and secret-looking metadata are not retained.".to_string(),
504            "Review required before enabling this context pack for future runs.".to_string(),
505        ],
506        source_event_ids,
507        created_at: now_rfc3339(),
508        metadata: BTreeMap::new(),
509    }
510}
511
512fn candidate_manifest_for_group(
513    title: &str,
514    recommended_artifact: &str,
515    group: &[FrictionEvent],
516    options: &ContextPackSuggestionOptions,
517) -> ContextPackManifest {
518    let first = group.first().expect("non-empty suggestion group");
519    let mut included_queries = Vec::new();
520    let mut included_docs = Vec::new();
521    let mut included_tools = Vec::new();
522    let mut capabilities = BTreeSet::new();
523    let mut secrets = BTreeMap::<String, ContextPackSecretRef>::new();
524    let mut output_slots = BTreeMap::<String, ContextPackOutputSlot>::new();
525
526    for event in group {
527        if let Some(query) = metadata_string(event, "query")
528            .or_else(|| metadata_string(event, "deterministic_query"))
529        {
530            let id = format!("query_{}", included_queries.len() + 1);
531            included_queries.push(ContextPackQuery {
532                id: id.clone(),
533                label: metadata_string(event, "query_label").or_else(|| event.tool.clone()),
534                provider: event.provider.clone().or_else(|| event.tool.clone()),
535                query,
536                filters: metadata_string_map(event, "filters"),
537                output_slot: metadata_string(event, "output_slot")
538                    .or_else(|| Some("primary_context".to_string())),
539            });
540        }
541        if let Some(doc) =
542            metadata_string(event, "doc_url").or_else(|| metadata_string(event, "document_url"))
543        {
544            included_docs.push(ContextPackDoc {
545                id: format!("doc_{}", included_docs.len() + 1),
546                title: metadata_string(event, "doc_title"),
547                url: Some(doc),
548                path: None,
549                freshness: metadata_string(event, "freshness"),
550            });
551        }
552        if let Some(path) =
553            metadata_string(event, "doc_path").or_else(|| metadata_string(event, "document_path"))
554        {
555            included_docs.push(ContextPackDoc {
556                id: format!("doc_{}", included_docs.len() + 1),
557                title: metadata_string(event, "doc_title"),
558                url: None,
559                path: Some(path),
560                freshness: metadata_string(event, "freshness"),
561            });
562        }
563        if let Some(tool) = event
564            .tool
565            .clone()
566            .or_else(|| metadata_string(event, "tool"))
567        {
568            included_tools.push(ContextPackTool {
569                name: tool.clone(),
570                capability: metadata_string(event, "capability"),
571                purpose: Some(event.redacted_summary.clone()),
572                deterministic: matches!(event.kind.as_str(), "repeated_query" | "missing_context"),
573            });
574        }
575        if let Some(capability) = metadata_string(event, "capability") {
576            capabilities.insert(capability);
577        }
578        if let Some(secret_ref) = metadata_string(event, "secret_ref") {
579            secrets
580                .entry(secret_ref.clone())
581                .or_insert(ContextPackSecretRef {
582                    name: secret_ref,
583                    capability: metadata_string(event, "capability"),
584                    required: true,
585                });
586        }
587        let slot =
588            metadata_string(event, "output_slot").unwrap_or_else(|| "primary_context".to_string());
589        output_slots
590            .entry(slot.clone())
591            .or_insert(ContextPackOutputSlot {
592                name: slot,
593                description: Some("Context gathered before the agent starts work".to_string()),
594                artifact_kind: Some("context".to_string()),
595            });
596    }
597
598    if included_queries.is_empty()
599        && matches!(first.kind.as_str(), "repeated_query" | "missing_context")
600    {
601        included_queries.push(ContextPackQuery {
602            id: "query_1".to_string(),
603            label: first.tool.clone().or_else(|| first.provider.clone()),
604            provider: first.provider.clone().or_else(|| first.tool.clone()),
605            query: first.redacted_summary.clone(),
606            filters: BTreeMap::new(),
607            output_slot: Some("primary_context".to_string()),
608        });
609    }
610
611    ContextPackManifest {
612        version: CONTEXT_PACK_MANIFEST_VERSION,
613        id: slugify(title),
614        name: title.to_string(),
615        description: Some(format!(
616            "Candidate generated from repeated {} friction; review before promotion.",
617            first.kind
618        )),
619        owner: options
620            .owner
621            .clone()
622            .or_else(|| first.actor.clone())
623            .unwrap_or_else(|| "team".to_string()),
624        triggers: vec![ContextPackTrigger {
625            kind: first.kind.clone(),
626            source: first.source.clone(),
627            match_hint: first.recurrence_hints.first().cloned(),
628        }],
629        inputs: vec![ContextPackInput {
630            name: "incident_or_task".to_string(),
631            description: Some("The current incident, ticket, run, or task identifier.".to_string()),
632            required: true,
633            source: first.source.clone(),
634        }],
635        included_queries,
636        included_docs,
637        included_tools,
638        refresh_policy: ContextPackRefreshPolicy::default(),
639        secrets: secrets.into_values().collect(),
640        capabilities: capabilities.into_iter().collect(),
641        output_slots: output_slots.into_values().collect(),
642        fallback_instructions: Some(
643            "If deterministic context is insufficient, ask a scoped clarifying question and record a new friction event.".to_string(),
644        ),
645        review: Some(ContextPackReviewPolicy {
646            owner: options.owner.clone(),
647            approval_required: true,
648            privacy_notes: vec!["Confirm queries and docs do not expose raw customer secrets.".to_string()],
649        }),
650        metadata: BTreeMap::from([(
651            "recommended_artifact".to_string(),
652            serde_json::json!(recommended_artifact),
653        )]),
654    }
655}
656
657fn expectation_has_match(
658    expectation: &ContextPackSuggestionExpectation,
659    suggestions: &[ContextPackSuggestion],
660) -> bool {
661    if expectation.min_suggestions.is_some()
662        && expectation.recommended_artifact.is_none()
663        && expectation.title_contains.is_none()
664        && expectation.manifest_name_contains.is_none()
665        && expectation.required_capability.is_none()
666        && expectation.required_output_slot.is_none()
667    {
668        return true;
669    }
670    suggestions.iter().any(|suggestion| {
671        expectation
672            .recommended_artifact
673            .as_ref()
674            .is_none_or(|expected| suggestion.recommended_artifact == *expected)
675            && expectation.title_contains.as_ref().is_none_or(|needle| {
676                suggestion
677                    .title
678                    .to_ascii_lowercase()
679                    .contains(&needle.to_ascii_lowercase())
680            })
681            && expectation
682                .manifest_name_contains
683                .as_ref()
684                .is_none_or(|needle| {
685                    suggestion
686                        .candidate_manifest
687                        .name
688                        .to_ascii_lowercase()
689                        .contains(&needle.to_ascii_lowercase())
690                })
691            && expectation
692                .required_capability
693                .as_ref()
694                .is_none_or(|capability| {
695                    suggestion
696                        .candidate_manifest
697                        .capabilities
698                        .iter()
699                        .any(|candidate| candidate == capability)
700                        || suggestion
701                            .candidate_manifest
702                            .included_tools
703                            .iter()
704                            .any(|tool| tool.capability.as_ref() == Some(capability))
705                })
706            && expectation
707                .required_output_slot
708                .as_ref()
709                .is_none_or(|slot| {
710                    suggestion
711                        .candidate_manifest
712                        .output_slots
713                        .iter()
714                        .any(|candidate| &candidate.name == slot)
715                })
716    })
717}
718
719fn friction_group_key(event: &FrictionEvent) -> String {
720    let hint = event
721        .recurrence_hints
722        .first()
723        .cloned()
724        .unwrap_or_else(|| normalize_words(&event.redacted_summary));
725    format!(
726        "{}|{}|{}|{}|{}",
727        event.kind,
728        event.source.as_deref().unwrap_or(""),
729        event.tool.as_deref().unwrap_or(""),
730        event.provider.as_deref().unwrap_or(""),
731        hint
732    )
733}
734
735fn suggestion_title(event: &FrictionEvent) -> String {
736    let source = event.source.as_deref().unwrap_or("team");
737    let topic = event.recurrence_hints.first().cloned().unwrap_or_else(|| {
738        normalize_words(&event.redacted_summary)
739            .split_whitespace()
740            .take(6)
741            .collect::<Vec<_>>()
742            .join(" ")
743    });
744    format!("{source} {topic} context pack")
745}
746
747fn recommended_artifact_for_kind(kind: &str) -> &'static str {
748    match kind {
749        "approval_stall" | "manual_handoff" => "workflow",
750        "tool_gap" | "failed_assumption" | "expensive_model_used_for_deterministic_step" => "both",
751        _ => "context_pack",
752    }
753}
754
755fn confidence_for_occurrences(occurrences: usize) -> f64 {
756    match occurrences {
757        0 | 1 => 0.0,
758        2 => 0.62,
759        3 => 0.74,
760        4 => 0.82,
761        _ => 0.9,
762    }
763}
764
765fn metadata_string(event: &FrictionEvent, key: &str) -> Option<String> {
766    event
767        .metadata
768        .get(key)
769        .and_then(|value| value.as_str())
770        .filter(|value| !value.trim().is_empty())
771        .map(str::to_string)
772}
773
774fn metadata_string_map(event: &FrictionEvent, key: &str) -> BTreeMap<String, String> {
775    event
776        .metadata
777        .get(key)
778        .and_then(|value| value.as_object())
779        .map(|map| {
780            map.iter()
781                .filter_map(|(key, value)| {
782                    value.as_str().map(|value| (key.clone(), value.to_string()))
783                })
784                .collect()
785        })
786        .unwrap_or_default()
787}
788
789fn redact_json_value(value: &mut serde_json::Value) {
790    match value {
791        serde_json::Value::String(text) => *text = redact_text(text),
792        serde_json::Value::Array(items) => {
793            for item in items {
794                redact_json_value(item);
795            }
796        }
797        serde_json::Value::Object(map) => {
798            for (key, value) in map.iter_mut() {
799                if is_sensitive_key(key) {
800                    *value = serde_json::Value::String("[redacted]".to_string());
801                } else {
802                    redact_json_value(value);
803                }
804            }
805        }
806        _ => {}
807    }
808}
809
810fn redact_text(text: &str) -> String {
811    text.split_whitespace()
812        .map(|word| {
813            let lower = word.to_ascii_lowercase();
814            if looks_like_secret_value(word)
815                || lower.contains("token=")
816                || lower.contains("password=")
817                || lower.contains("api_key=")
818                || lower.contains("apikey=")
819            {
820                "[redacted]".to_string()
821            } else {
822                word.to_string()
823            }
824        })
825        .collect::<Vec<_>>()
826        .join(" ")
827}
828
829fn is_sensitive_key(key: &str) -> bool {
830    let lower = key.to_ascii_lowercase();
831    lower.contains("secret")
832        || lower.contains("token")
833        || lower.contains("password")
834        || lower.contains("api_key")
835        || lower.contains("apikey")
836        || lower == "authorization"
837}
838
839fn looks_like_secret_value(value: &str) -> bool {
840    let trimmed = value.trim();
841    trimmed.starts_with("sk-")
842        || trimmed.starts_with("ghp_")
843        || trimmed.starts_with("xoxb-")
844        || trimmed.starts_with("AKIA")
845        || trimmed.len() > 48
846            && trimmed
847                .chars()
848                .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
849}
850
851fn normalize_words(text: &str) -> String {
852    text.chars()
853        .map(|ch| {
854            if ch.is_ascii_alphanumeric() || ch.is_whitespace() {
855                ch.to_ascii_lowercase()
856            } else {
857                ' '
858            }
859        })
860        .collect::<String>()
861        .split_whitespace()
862        .collect::<Vec<_>>()
863        .join(" ")
864}
865
866fn slugify(text: &str) -> String {
867    let slug = normalize_words(text)
868        .split_whitespace()
869        .take(8)
870        .collect::<Vec<_>>()
871        .join("_");
872    if slug.is_empty() {
873        new_id("context_pack")
874    } else {
875        slug
876    }
877}
878
879#[cfg(test)]
880mod tests {
881    use super::*;
882    use serde_json::json;
883
884    #[test]
885    fn friction_event_normalizes_and_redacts_sensitive_metadata() {
886        let event = normalize_friction_event_json(json!({
887            "kind": "repeated_query",
888            "source": "incident-triage",
889            "redacted_summary": "Run Splunk query token=abc123",
890            "metadata": {
891                "query": "index=prod error",
892                "api_key": "sk-live-secret"
893            }
894        }))
895        .unwrap();
896
897        assert_eq!(event.schema_version, FRICTION_SCHEMA_VERSION);
898        assert!(event.id.starts_with("friction_"));
899        assert!(event.redacted_summary.contains("[redacted]"));
900        assert_eq!(event.metadata["api_key"], json!("[redacted]"));
901    }
902
903    #[test]
904    fn context_pack_manifest_rejects_raw_secret_values() {
905        let err = normalize_context_pack_manifest_json(json!({
906            "name": "Incident pack",
907            "owner": "sre",
908            "secrets": [{"name": "sk-live-secret"}]
909        }))
910        .unwrap_err();
911
912        assert!(err.to_string().contains("raw secret"));
913    }
914
915    #[test]
916    fn repeated_incident_events_produce_context_pack_suggestion() {
917        let events = vec![
918            normalize_friction_event_json(json!({
919                "kind": "repeated_query",
920                "source": "incident-triage",
921                "actor": "sre",
922                "tool": "splunk",
923                "provider": "splunk",
924                "redacted_summary": "Every checkout incident needs the checkout error query",
925                "estimated_time_ms": 300000,
926                "estimated_cost_usd": 0.12,
927                "recurrence_hints": ["checkout incident queries"],
928                "metadata": {
929                    "query": "index=checkout service=api error",
930                    "capability": "splunk.search",
931                    "secret_ref": "SPLUNK_READ_TOKEN",
932                    "output_slot": "splunk_errors"
933                }
934            }))
935            .unwrap(),
936            normalize_friction_event_json(json!({
937                "kind": "repeated_query",
938                "source": "incident-triage",
939                "actor": "sre",
940                "tool": "splunk",
941                "provider": "splunk",
942                "redacted_summary": "Need the same checkout error search again",
943                "estimated_time_ms": 240000,
944                "estimated_cost_usd": 0.10,
945                "recurrence_hints": ["checkout incident queries"],
946                "metadata": {
947                    "query": "index=checkout service=api error",
948                    "capability": "splunk.search",
949                    "secret_ref": "SPLUNK_READ_TOKEN",
950                    "output_slot": "splunk_errors"
951                }
952            }))
953            .unwrap(),
954        ];
955
956        let suggestions = generate_context_pack_suggestions(
957            &events,
958            &ContextPackSuggestionOptions {
959                min_occurrences: 2,
960                owner: Some("sre".to_string()),
961            },
962        );
963
964        assert_eq!(suggestions.len(), 1);
965        let suggestion = &suggestions[0];
966        assert_eq!(suggestion.recommended_artifact, "context_pack");
967        assert_eq!(suggestion.estimated_savings.occurrences, 2);
968        assert_eq!(suggestion.estimated_savings.estimated_time_saved_ms, 240000);
969        assert_eq!(
970            suggestion.candidate_manifest.capabilities,
971            vec!["splunk.search"]
972        );
973        assert_eq!(
974            suggestion.candidate_manifest.output_slots[0].name,
975            "splunk_errors"
976        );
977    }
978}