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 {:?}",
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}