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