use super::{SessionEvent, SessionEventKind};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::path::PathBuf;
const MAX_SAMPLES: usize = 24;
const MAX_PATH_BYTES: usize = 512;
const MAX_ID_BYTES: usize = 256;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct ExecutionEffects {
mutation_calls: u64,
shell_calls: u64,
incomplete: bool,
samples: Vec<MutationSample>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct MutationSample {
call_id: String,
path: String,
}
pub(crate) fn mutation_evidence(paths: &[PathBuf]) -> Value {
serde_json::json!({
"changed_path_count": paths.len(),
"paths": paths.iter().take(MAX_SAMPLES).map(|path| {
bounded(&path.to_string_lossy(), MAX_PATH_BYTES)
}).collect::<Vec<_>>(),
})
}
fn bounded(value: &str, bytes: usize) -> String {
let value = crate::output::redact_sensitive_text(value);
let mut end = value.len().min(bytes);
while !value.is_char_boundary(end) {
end -= 1;
}
value[..end].to_string()
}
impl ExecutionEffects {
pub(crate) fn from_checkpoint(value: Option<&Value>) -> Self {
let Some(value) = value else {
return Self {
incomplete: true,
..Self::default()
};
};
let valid = value
.get("samples")
.and_then(Value::as_array)
.is_some_and(|samples| {
samples.len() <= MAX_SAMPLES
&& samples.iter().all(|sample| {
sample
.get("path")
.and_then(Value::as_str)
.is_some_and(|s| s.len() <= MAX_PATH_BYTES)
&& sample
.get("call_id")
.and_then(Value::as_str)
.is_some_and(|s| s.len() <= MAX_ID_BYTES)
})
});
if valid && let Ok(mut effects) = serde_json::from_value::<Self>(value.clone()) {
for sample in &mut effects.samples {
sample.path = bounded(&sample.path, MAX_PATH_BYTES);
sample.call_id = bounded(&sample.call_id, MAX_ID_BYTES);
}
effects.incomplete |= effects.mutation_calls == 0 && !effects.samples.is_empty();
return effects;
}
Self {
incomplete: true,
..Self::default()
}
}
pub(crate) fn mark_incomplete(&mut self) {
self.incomplete = true;
}
pub(crate) fn from_events(session_id: &str, events: &[SessionEvent]) -> Self {
let mut effects = Self::default();
for (index, event) in events.iter().enumerate() {
effects.observe_event(session_id, index, event);
}
effects
}
pub(crate) fn observe_event(&mut self, session_id: &str, index: usize, event: &SessionEvent) {
if event.session_id != session_id {
return;
}
if event.kind() == Some(SessionEventKind::Compaction) {
match super::metadata::validate_compaction_checkpoint(
session_id,
event,
index,
index + 1,
) {
Ok(_) if index == 0 => {
*self = Self::from_checkpoint(event.payload.get("execution_effects"));
}
Ok(_) => {}
Err(_) => self.mark_incomplete(),
}
} else {
self.observe_tool_result(event);
}
}
pub(crate) fn observe_tool_result(&mut self, event: &SessionEvent) {
if !matches!(
event.kind(),
Some(SessionEventKind::ToolResult | SessionEventKind::CodeModeToolResult)
) {
return;
}
let payload = &event.payload;
if event.kind() == Some(SessionEventKind::CodeModeToolResult)
&& payload.get("execution_effects").is_none()
{
return;
}
let result = &payload["result"];
let name = result["tool_name"].as_str().unwrap_or("");
if name == "code_mode" {
return;
}
if name == "bash" {
self.shell_calls = self.shell_calls.saturating_add(1);
}
let mut paths = Vec::new();
let changed = if let Some(evidence) = payload.get("execution_effects") {
let count = evidence["changed_path_count"].as_u64();
if let Some(values) = evidence["paths"].as_array() {
paths.extend(values.iter().take(MAX_SAMPLES).filter_map(Value::as_str));
self.incomplete |= values.len() > MAX_SAMPLES || paths.len() != values.len();
} else {
self.incomplete = true;
}
self.incomplete |= count.is_none() || (count == Some(0) && !paths.is_empty());
count.unwrap_or_default() > 0
} else {
let metadata = &result["metadata"];
if name == "write"
&& (result["success"] == true || metadata["outcome"] == "committed_but_undurable")
{
if let Some(path) = metadata["path"].as_str() {
paths.push(path);
}
true
} else if name == "hash_edit" {
if let Some(files) = metadata["files"].as_array() {
for file in files {
let committed = matches!(
file["status"].as_str(),
Some(
"committed"
| "committed_but_undurable"
| "committed_with_error"
| "destination_written_source_retained"
)
);
if committed
&& file["operation"] != "noop"
&& let Some(path) = file["path"].as_str()
{
paths.push(path);
}
if paths.len() == MAX_SAMPLES {
break;
}
}
}
if paths.is_empty() && result["success"] == true {
self.incomplete = true;
}
!paths.is_empty()
} else {
false
}
};
if changed {
self.mutation_calls = self.mutation_calls.saturating_add(1);
let id = bounded(
payload["call_id"].as_str().unwrap_or("unknown"),
MAX_ID_BYTES,
);
for path in paths {
if self.samples.len() == MAX_SAMPLES {
break;
}
self.samples.push(MutationSample {
call_id: id.clone(),
path: bounded(path, MAX_PATH_BYTES),
});
}
}
}
pub(crate) fn provider_note(&self) -> Option<String> {
if self.mutation_calls == 0 && self.shell_calls == 0 && !self.incomplete {
return None;
}
let mutations = if self.mutation_calls > 0 {
"Recorded file mutations occurred even if later reverted. A clean status does not mean no edits were made. Preserve and disclose recorded edit/revert incidents; do not replace them with a claim that no changes occurred."
} else {
"No file mutations are established by these counters; they do not prove that no edits occurred."
};
Some(format!(
"[Recorded execution evidence — not a model summary]\n{}\nThese are historical observations, not the current working-tree diff. {mutations} Shell side effects and whether a restore fully reverted them are not independently verified here. Samples are bounded; full evidence remains in tool-call/result history. An incomplete record cannot prove absence of mutations.\n[/Recorded execution evidence]",
serde_json::to_string(self).expect("execution evidence serialization")
))
}
}