use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use super::{new_id, now_rfc3339, parse_json_payload};
use crate::llm::vm_value_to_json;
use crate::value::{VmError, VmValue};
pub const FRICTION_SCHEMA_VERSION: u32 = 1;
pub const CONTEXT_PACK_MANIFEST_VERSION: u32 = 1;
const FRICTION_KINDS: &[&str] = &[
"repeated_query",
"repeated_clarification",
"approval_stall",
"missing_context",
"manual_handoff",
"tool_gap",
"failed_assumption",
"expensive_model_used_for_deterministic_step",
"human_hypothesis",
];
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct FrictionEvent {
pub schema_version: u32,
pub id: String,
pub kind: String,
pub source: Option<String>,
pub actor: Option<String>,
pub tenant_id: Option<String>,
pub task_id: Option<String>,
pub run_id: Option<String>,
pub workflow_id: Option<String>,
pub tool: Option<String>,
pub provider: Option<String>,
pub redacted_summary: String,
pub estimated_cost_usd: Option<f64>,
pub estimated_time_ms: Option<i64>,
pub recurrence_hints: Vec<String>,
pub trace_id: Option<String>,
pub span_id: Option<String>,
pub links: Vec<FrictionLink>,
pub human_hypothesis: Option<HumanHypothesis>,
pub metadata: BTreeMap<String, serde_json::Value>,
pub timestamp: String,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct FrictionLink {
pub label: Option<String>,
pub url: Option<String>,
pub trace_id: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct HumanHypothesis {
pub note: String,
pub confidence: Option<f64>,
pub expires_at: Option<String>,
pub checkback_at: Option<String>,
pub suggested_verification_tools: Vec<String>,
pub status: Option<String>,
pub evidence_outcome: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct ContextPackManifest {
pub version: u32,
pub id: String,
pub name: String,
pub description: Option<String>,
pub owner: String,
pub triggers: Vec<ContextPackTrigger>,
pub inputs: Vec<ContextPackInput>,
pub included_queries: Vec<ContextPackQuery>,
pub included_docs: Vec<ContextPackDoc>,
pub included_tools: Vec<ContextPackTool>,
pub refresh_policy: ContextPackRefreshPolicy,
pub secrets: Vec<ContextPackSecretRef>,
pub capabilities: Vec<String>,
pub output_slots: Vec<ContextPackOutputSlot>,
pub fallback_instructions: Option<String>,
pub review: Option<ContextPackReviewPolicy>,
pub metadata: BTreeMap<String, serde_json::Value>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct ContextPackTrigger {
pub kind: String,
pub source: Option<String>,
pub match_hint: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct ContextPackInput {
pub name: String,
pub description: Option<String>,
pub required: bool,
pub source: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct ContextPackQuery {
pub id: String,
pub label: Option<String>,
pub provider: Option<String>,
pub query: String,
pub filters: BTreeMap<String, String>,
pub output_slot: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct ContextPackDoc {
pub id: String,
pub title: Option<String>,
pub url: Option<String>,
pub path: Option<String>,
pub freshness: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct ContextPackTool {
pub name: String,
pub capability: Option<String>,
pub purpose: Option<String>,
pub deterministic: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct ContextPackRefreshPolicy {
pub mode: String,
pub interval: Option<String>,
pub stale_after: Option<String>,
}
impl Default for ContextPackRefreshPolicy {
fn default() -> Self {
Self {
mode: "on_demand".to_string(),
interval: None,
stale_after: None,
}
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct ContextPackSecretRef {
pub name: String,
pub capability: Option<String>,
pub required: bool,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct ContextPackOutputSlot {
pub name: String,
pub description: Option<String>,
pub artifact_kind: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct ContextPackReviewPolicy {
pub owner: Option<String>,
pub approval_required: bool,
pub privacy_notes: Vec<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct ContextPackSuggestion {
#[serde(rename = "_type")]
pub type_name: String,
pub id: String,
pub title: String,
pub recommended_artifact: String,
pub confidence: f64,
pub candidate_manifest: ContextPackManifest,
pub evidence: Vec<ContextPackSuggestionEvidence>,
pub examples: Vec<String>,
pub estimated_savings: ContextPackEstimatedSavings,
pub risk_privacy_notes: Vec<String>,
pub source_event_ids: Vec<String>,
pub created_at: String,
pub metadata: BTreeMap<String, serde_json::Value>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct ContextPackSuggestionEvidence {
pub event_id: String,
pub kind: String,
pub source: Option<String>,
pub tool: Option<String>,
pub provider: Option<String>,
pub redacted_summary: String,
pub run_id: Option<String>,
pub trace_id: Option<String>,
pub estimated_cost_usd: Option<f64>,
pub estimated_time_ms: Option<i64>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct ContextPackEstimatedSavings {
pub occurrences: usize,
pub estimated_time_saved_ms: i64,
pub estimated_cost_saved_usd: f64,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct ContextPackSuggestionOptions {
pub min_occurrences: usize,
pub owner: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct ContextPackSuggestionExpectation {
pub min_suggestions: Option<usize>,
pub recommended_artifact: Option<String>,
pub title_contains: Option<String>,
pub manifest_name_contains: Option<String>,
pub required_capability: Option<String>,
pub required_output_slot: Option<String>,
}
pub fn friction_kind_allowed(kind: &str) -> bool {
FRICTION_KINDS.contains(&kind)
}
pub fn normalize_friction_event(value: &VmValue) -> Result<FrictionEvent, VmError> {
let mut json = vm_value_to_json(value);
if let Some(map) = json.as_object_mut() {
if !map.contains_key("redacted_summary") {
if let Some(summary) = map
.get("summary")
.and_then(|value| value.as_str())
.or_else(|| map.get("message").and_then(|value| value.as_str()))
{
map.insert(
"redacted_summary".to_string(),
serde_json::Value::String(redact_text(summary)),
);
}
}
map.remove("summary");
map.remove("message");
map.remove("raw_content");
map.remove("raw_prompt");
if let Some(metadata) = map.get_mut("metadata") {
redact_json_value(metadata);
}
}
normalize_friction_event_json(json)
}
pub fn normalize_friction_event_json(json: serde_json::Value) -> Result<FrictionEvent, VmError> {
let mut event: FrictionEvent = parse_json_payload(json, "friction_event")?;
if event.schema_version == 0 {
event.schema_version = FRICTION_SCHEMA_VERSION;
}
if event.id.is_empty() {
event.id = new_id("friction");
}
if event.timestamp.is_empty() {
event.timestamp = now_rfc3339();
}
event.kind = event.kind.trim().to_ascii_lowercase();
if event.kind.is_empty() {
return Err(VmError::Runtime("friction_event: missing kind".to_string()));
}
if !friction_kind_allowed(&event.kind) {
return Err(VmError::Runtime(format!(
"friction_event: unsupported kind '{}' (expected one of {})",
event.kind,
FRICTION_KINDS.join(", ")
)));
}
event.redacted_summary = redact_text(&event.redacted_summary);
if event.redacted_summary.trim().is_empty() {
return Err(VmError::Runtime(
"friction_event: missing redacted_summary".to_string(),
));
}
for value in event.metadata.values_mut() {
redact_json_value(value);
}
Ok(event)
}
pub fn normalize_context_pack_manifest(value: &VmValue) -> Result<ContextPackManifest, VmError> {
normalize_context_pack_manifest_json(vm_value_to_json(value))
}
pub fn normalize_context_pack_manifest_json(
json: serde_json::Value,
) -> Result<ContextPackManifest, VmError> {
let mut manifest: ContextPackManifest = parse_json_payload(json, "context_pack_manifest")?;
normalize_context_pack_manifest_record(&mut manifest)?;
Ok(manifest)
}
pub fn parse_context_pack_manifest_src(src: &str) -> Result<ContextPackManifest, VmError> {
let trimmed = src.trim_start();
let mut manifest: ContextPackManifest = if trimmed.starts_with('{') {
serde_json::from_str(src).map_err(|e| {
VmError::Runtime(format!("context_pack_manifest_parse: invalid JSON: {e}"))
})?
} else {
toml::from_str(src).map_err(|e| {
VmError::Runtime(format!("context_pack_manifest_parse: invalid TOML: {e}"))
})?
};
normalize_context_pack_manifest_record(&mut manifest)?;
Ok(manifest)
}
pub fn generate_context_pack_suggestions(
events: &[FrictionEvent],
options: &ContextPackSuggestionOptions,
) -> Vec<ContextPackSuggestion> {
let min_occurrences = options.min_occurrences.max(2);
let mut groups: BTreeMap<String, Vec<FrictionEvent>> = BTreeMap::new();
for event in events {
groups
.entry(friction_group_key(event))
.or_default()
.push(event.clone());
}
groups
.into_values()
.filter(|group| group.len() >= min_occurrences)
.map(|group| build_suggestion(group, options))
.collect()
}
pub fn evaluate_context_pack_suggestion_expectations(
suggestions: &[ContextPackSuggestion],
expectations: &[ContextPackSuggestionExpectation],
) -> Vec<String> {
let mut failures = Vec::new();
for expectation in expectations {
if let Some(min) = expectation.min_suggestions {
if suggestions.len() < min {
failures.push(format!(
"expected at least {min} context-pack suggestion(s), got {}",
suggestions.len()
));
continue;
}
}
if expectation_has_match(expectation, suggestions) {
continue;
}
failures.push(format!(
"no context-pack suggestion matched expectation {:?}",
expectation
));
}
failures
}
pub fn normalize_friction_events_json(
value: serde_json::Value,
) -> Result<Vec<FrictionEvent>, VmError> {
let items = if let Some(events) = value.get("events").and_then(|events| events.as_array()) {
events.clone()
} else if let Some(array) = value.as_array() {
array.clone()
} else {
return Err(VmError::Runtime(
"friction events fixture must be an array or {events: [...]}".to_string(),
));
};
items
.into_iter()
.map(normalize_friction_event_json)
.collect()
}
pub fn parse_friction_events_value(value: &VmValue) -> Result<Vec<FrictionEvent>, VmError> {
normalize_friction_events_json(vm_value_to_json(value))
}
fn normalize_context_pack_manifest_record(
manifest: &mut ContextPackManifest,
) -> Result<(), VmError> {
if manifest.version == 0 {
manifest.version = CONTEXT_PACK_MANIFEST_VERSION;
}
if manifest.name.trim().is_empty() {
return Err(VmError::Runtime(
"context_pack_manifest: missing name".to_string(),
));
}
if manifest.id.trim().is_empty() {
manifest.id = slugify(&manifest.name);
}
if manifest.owner.trim().is_empty() {
return Err(VmError::Runtime(
"context_pack_manifest: missing owner".to_string(),
));
}
for secret in &manifest.secrets {
if looks_like_secret_value(&secret.name)
|| looks_like_secret_value(secret.capability.as_deref().unwrap_or(""))
{
return Err(VmError::Runtime(
"context_pack_manifest: secrets must be capability references, not raw secret values"
.to_string(),
));
}
}
for query in &manifest.included_queries {
if query.id.trim().is_empty() || query.query.trim().is_empty() {
return Err(VmError::Runtime(
"context_pack_manifest: included queries require id and query".to_string(),
));
}
}
Ok(())
}
fn build_suggestion(
mut group: Vec<FrictionEvent>,
options: &ContextPackSuggestionOptions,
) -> ContextPackSuggestion {
group.sort_by(|left, right| {
left.timestamp
.cmp(&right.timestamp)
.then(left.id.cmp(&right.id))
});
let first = group.first().expect("filtered non-empty group");
let title = suggestion_title(first);
let recommended_artifact = recommended_artifact_for_kind(&first.kind).to_string();
let evidence = group
.iter()
.map(|event| ContextPackSuggestionEvidence {
event_id: event.id.clone(),
kind: event.kind.clone(),
source: event.source.clone(),
tool: event.tool.clone(),
provider: event.provider.clone(),
redacted_summary: event.redacted_summary.clone(),
run_id: event.run_id.clone(),
trace_id: event.trace_id.clone(),
estimated_cost_usd: event.estimated_cost_usd,
estimated_time_ms: event.estimated_time_ms,
})
.collect::<Vec<_>>();
let examples = group
.iter()
.map(|event| event.redacted_summary.clone())
.collect::<BTreeSet<_>>()
.into_iter()
.take(3)
.collect::<Vec<_>>();
let candidate_manifest =
candidate_manifest_for_group(&title, &recommended_artifact, &group, options);
let occurrences = group.len();
let estimated_time_saved_ms = group
.iter()
.skip(1)
.filter_map(|event| event.estimated_time_ms)
.sum();
let estimated_cost_saved_usd = group
.iter()
.skip(1)
.filter_map(|event| event.estimated_cost_usd)
.sum();
let source_event_ids = group.iter().map(|event| event.id.clone()).collect();
ContextPackSuggestion {
type_name: "context_pack_suggestion".to_string(),
id: new_id("context_pack_suggestion"),
title,
recommended_artifact,
confidence: confidence_for_occurrences(occurrences),
candidate_manifest,
evidence,
examples,
estimated_savings: ContextPackEstimatedSavings {
occurrences,
estimated_time_saved_ms,
estimated_cost_saved_usd,
},
risk_privacy_notes: vec![
"Evidence uses redacted summaries; raw prompts, raw content, and secret-looking metadata are not retained.".to_string(),
"Review required before enabling this context pack for future runs.".to_string(),
],
source_event_ids,
created_at: now_rfc3339(),
metadata: BTreeMap::new(),
}
}
fn candidate_manifest_for_group(
title: &str,
recommended_artifact: &str,
group: &[FrictionEvent],
options: &ContextPackSuggestionOptions,
) -> ContextPackManifest {
let first = group.first().expect("non-empty suggestion group");
let mut included_queries = Vec::new();
let mut included_docs = Vec::new();
let mut included_tools = Vec::new();
let mut capabilities = BTreeSet::new();
let mut secrets = BTreeMap::<String, ContextPackSecretRef>::new();
let mut output_slots = BTreeMap::<String, ContextPackOutputSlot>::new();
for event in group {
if let Some(query) = metadata_string(event, "query")
.or_else(|| metadata_string(event, "deterministic_query"))
{
let id = format!("query_{}", included_queries.len() + 1);
included_queries.push(ContextPackQuery {
id: id.clone(),
label: metadata_string(event, "query_label").or_else(|| event.tool.clone()),
provider: event.provider.clone().or_else(|| event.tool.clone()),
query,
filters: metadata_string_map(event, "filters"),
output_slot: metadata_string(event, "output_slot")
.or_else(|| Some("primary_context".to_string())),
});
}
if let Some(doc) =
metadata_string(event, "doc_url").or_else(|| metadata_string(event, "document_url"))
{
included_docs.push(ContextPackDoc {
id: format!("doc_{}", included_docs.len() + 1),
title: metadata_string(event, "doc_title"),
url: Some(doc),
path: None,
freshness: metadata_string(event, "freshness"),
});
}
if let Some(path) =
metadata_string(event, "doc_path").or_else(|| metadata_string(event, "document_path"))
{
included_docs.push(ContextPackDoc {
id: format!("doc_{}", included_docs.len() + 1),
title: metadata_string(event, "doc_title"),
url: None,
path: Some(path),
freshness: metadata_string(event, "freshness"),
});
}
if let Some(tool) = event
.tool
.clone()
.or_else(|| metadata_string(event, "tool"))
{
included_tools.push(ContextPackTool {
name: tool.clone(),
capability: metadata_string(event, "capability"),
purpose: Some(event.redacted_summary.clone()),
deterministic: matches!(event.kind.as_str(), "repeated_query" | "missing_context"),
});
}
if let Some(capability) = metadata_string(event, "capability") {
capabilities.insert(capability);
}
if let Some(secret_ref) = metadata_string(event, "secret_ref") {
secrets
.entry(secret_ref.clone())
.or_insert(ContextPackSecretRef {
name: secret_ref,
capability: metadata_string(event, "capability"),
required: true,
});
}
let slot =
metadata_string(event, "output_slot").unwrap_or_else(|| "primary_context".to_string());
output_slots
.entry(slot.clone())
.or_insert(ContextPackOutputSlot {
name: slot,
description: Some("Context gathered before the agent starts work".to_string()),
artifact_kind: Some("context".to_string()),
});
}
if included_queries.is_empty()
&& matches!(first.kind.as_str(), "repeated_query" | "missing_context")
{
included_queries.push(ContextPackQuery {
id: "query_1".to_string(),
label: first.tool.clone().or_else(|| first.provider.clone()),
provider: first.provider.clone().or_else(|| first.tool.clone()),
query: first.redacted_summary.clone(),
filters: BTreeMap::new(),
output_slot: Some("primary_context".to_string()),
});
}
ContextPackManifest {
version: CONTEXT_PACK_MANIFEST_VERSION,
id: slugify(title),
name: title.to_string(),
description: Some(format!(
"Candidate generated from repeated {} friction; review before promotion.",
first.kind
)),
owner: options
.owner
.clone()
.or_else(|| first.actor.clone())
.unwrap_or_else(|| "team".to_string()),
triggers: vec![ContextPackTrigger {
kind: first.kind.clone(),
source: first.source.clone(),
match_hint: first.recurrence_hints.first().cloned(),
}],
inputs: vec![ContextPackInput {
name: "incident_or_task".to_string(),
description: Some("The current incident, ticket, run, or task identifier.".to_string()),
required: true,
source: first.source.clone(),
}],
included_queries,
included_docs,
included_tools,
refresh_policy: ContextPackRefreshPolicy::default(),
secrets: secrets.into_values().collect(),
capabilities: capabilities.into_iter().collect(),
output_slots: output_slots.into_values().collect(),
fallback_instructions: Some(
"If deterministic context is insufficient, ask a scoped clarifying question and record a new friction event.".to_string(),
),
review: Some(ContextPackReviewPolicy {
owner: options.owner.clone(),
approval_required: true,
privacy_notes: vec!["Confirm queries and docs do not expose raw customer secrets.".to_string()],
}),
metadata: BTreeMap::from([(
"recommended_artifact".to_string(),
serde_json::json!(recommended_artifact),
)]),
}
}
fn expectation_has_match(
expectation: &ContextPackSuggestionExpectation,
suggestions: &[ContextPackSuggestion],
) -> bool {
if expectation.min_suggestions.is_some()
&& expectation.recommended_artifact.is_none()
&& expectation.title_contains.is_none()
&& expectation.manifest_name_contains.is_none()
&& expectation.required_capability.is_none()
&& expectation.required_output_slot.is_none()
{
return true;
}
suggestions.iter().any(|suggestion| {
expectation
.recommended_artifact
.as_ref()
.is_none_or(|expected| suggestion.recommended_artifact == *expected)
&& expectation.title_contains.as_ref().is_none_or(|needle| {
suggestion
.title
.to_ascii_lowercase()
.contains(&needle.to_ascii_lowercase())
})
&& expectation
.manifest_name_contains
.as_ref()
.is_none_or(|needle| {
suggestion
.candidate_manifest
.name
.to_ascii_lowercase()
.contains(&needle.to_ascii_lowercase())
})
&& expectation
.required_capability
.as_ref()
.is_none_or(|capability| {
suggestion
.candidate_manifest
.capabilities
.iter()
.any(|candidate| candidate == capability)
|| suggestion
.candidate_manifest
.included_tools
.iter()
.any(|tool| tool.capability.as_ref() == Some(capability))
})
&& expectation
.required_output_slot
.as_ref()
.is_none_or(|slot| {
suggestion
.candidate_manifest
.output_slots
.iter()
.any(|candidate| &candidate.name == slot)
})
})
}
fn friction_group_key(event: &FrictionEvent) -> String {
let hint = event
.recurrence_hints
.first()
.cloned()
.unwrap_or_else(|| normalize_words(&event.redacted_summary));
format!(
"{}|{}|{}|{}|{}",
event.kind,
event.source.as_deref().unwrap_or(""),
event.tool.as_deref().unwrap_or(""),
event.provider.as_deref().unwrap_or(""),
hint
)
}
fn suggestion_title(event: &FrictionEvent) -> String {
let source = event.source.as_deref().unwrap_or("team");
let topic = event.recurrence_hints.first().cloned().unwrap_or_else(|| {
normalize_words(&event.redacted_summary)
.split_whitespace()
.take(6)
.collect::<Vec<_>>()
.join(" ")
});
format!("{source} {topic} context pack")
}
fn recommended_artifact_for_kind(kind: &str) -> &'static str {
match kind {
"approval_stall" | "manual_handoff" => "workflow",
"tool_gap" | "failed_assumption" | "expensive_model_used_for_deterministic_step" => "both",
_ => "context_pack",
}
}
fn confidence_for_occurrences(occurrences: usize) -> f64 {
match occurrences {
0 | 1 => 0.0,
2 => 0.62,
3 => 0.74,
4 => 0.82,
_ => 0.9,
}
}
fn metadata_string(event: &FrictionEvent, key: &str) -> Option<String> {
event
.metadata
.get(key)
.and_then(|value| value.as_str())
.filter(|value| !value.trim().is_empty())
.map(str::to_string)
}
fn metadata_string_map(event: &FrictionEvent, key: &str) -> BTreeMap<String, String> {
event
.metadata
.get(key)
.and_then(|value| value.as_object())
.map(|map| {
map.iter()
.filter_map(|(key, value)| {
value.as_str().map(|value| (key.clone(), value.to_string()))
})
.collect()
})
.unwrap_or_default()
}
fn redact_json_value(value: &mut serde_json::Value) {
match value {
serde_json::Value::String(text) => *text = redact_text(text),
serde_json::Value::Array(items) => {
for item in items {
redact_json_value(item);
}
}
serde_json::Value::Object(map) => {
for (key, value) in map.iter_mut() {
if is_sensitive_key(key) {
*value = serde_json::Value::String("[redacted]".to_string());
} else {
redact_json_value(value);
}
}
}
_ => {}
}
}
fn redact_text(text: &str) -> String {
text.split_whitespace()
.map(|word| {
let lower = word.to_ascii_lowercase();
if looks_like_secret_value(word)
|| lower.contains("token=")
|| lower.contains("password=")
|| lower.contains("api_key=")
|| lower.contains("apikey=")
{
"[redacted]".to_string()
} else {
word.to_string()
}
})
.collect::<Vec<_>>()
.join(" ")
}
fn is_sensitive_key(key: &str) -> bool {
let lower = key.to_ascii_lowercase();
lower.contains("secret")
|| lower.contains("token")
|| lower.contains("password")
|| lower.contains("api_key")
|| lower.contains("apikey")
|| lower == "authorization"
}
fn looks_like_secret_value(value: &str) -> bool {
let trimmed = value.trim();
trimmed.starts_with("sk-")
|| trimmed.starts_with("ghp_")
|| trimmed.starts_with("xoxb-")
|| trimmed.starts_with("AKIA")
|| trimmed.len() > 48
&& trimmed
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
}
fn normalize_words(text: &str) -> String {
text.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ch.is_whitespace() {
ch.to_ascii_lowercase()
} else {
' '
}
})
.collect::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
fn slugify(text: &str) -> String {
let slug = normalize_words(text)
.split_whitespace()
.take(8)
.collect::<Vec<_>>()
.join("_");
if slug.is_empty() {
new_id("context_pack")
} else {
slug
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn friction_event_normalizes_and_redacts_sensitive_metadata() {
let event = normalize_friction_event_json(json!({
"kind": "repeated_query",
"source": "incident-triage",
"redacted_summary": "Run Splunk query token=abc123",
"metadata": {
"query": "index=prod error",
"api_key": "sk-live-secret"
}
}))
.unwrap();
assert_eq!(event.schema_version, FRICTION_SCHEMA_VERSION);
assert!(event.id.starts_with("friction_"));
assert!(event.redacted_summary.contains("[redacted]"));
assert_eq!(event.metadata["api_key"], json!("[redacted]"));
}
#[test]
fn context_pack_manifest_rejects_raw_secret_values() {
let err = normalize_context_pack_manifest_json(json!({
"name": "Incident pack",
"owner": "sre",
"secrets": [{"name": "sk-live-secret"}]
}))
.unwrap_err();
assert!(err.to_string().contains("raw secret"));
}
#[test]
fn repeated_incident_events_produce_context_pack_suggestion() {
let events = vec![
normalize_friction_event_json(json!({
"kind": "repeated_query",
"source": "incident-triage",
"actor": "sre",
"tool": "splunk",
"provider": "splunk",
"redacted_summary": "Every checkout incident needs the checkout error query",
"estimated_time_ms": 300000,
"estimated_cost_usd": 0.12,
"recurrence_hints": ["checkout incident queries"],
"metadata": {
"query": "index=checkout service=api error",
"capability": "splunk.search",
"secret_ref": "SPLUNK_READ_TOKEN",
"output_slot": "splunk_errors"
}
}))
.unwrap(),
normalize_friction_event_json(json!({
"kind": "repeated_query",
"source": "incident-triage",
"actor": "sre",
"tool": "splunk",
"provider": "splunk",
"redacted_summary": "Need the same checkout error search again",
"estimated_time_ms": 240000,
"estimated_cost_usd": 0.10,
"recurrence_hints": ["checkout incident queries"],
"metadata": {
"query": "index=checkout service=api error",
"capability": "splunk.search",
"secret_ref": "SPLUNK_READ_TOKEN",
"output_slot": "splunk_errors"
}
}))
.unwrap(),
];
let suggestions = generate_context_pack_suggestions(
&events,
&ContextPackSuggestionOptions {
min_occurrences: 2,
owner: Some("sre".to_string()),
},
);
assert_eq!(suggestions.len(), 1);
let suggestion = &suggestions[0];
assert_eq!(suggestion.recommended_artifact, "context_pack");
assert_eq!(suggestion.estimated_savings.occurrences, 2);
assert_eq!(suggestion.estimated_savings.estimated_time_saved_ms, 240000);
assert_eq!(
suggestion.candidate_manifest.capabilities,
vec!["splunk.search"]
);
assert_eq!(
suggestion.candidate_manifest.output_slots[0].name,
"splunk_errors"
);
}
}