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