diff --git a/cli/migrations/agent-trace-repository/005_mutation_scope_provenance.sql b/cli/migrations/agent-trace-repository/005_mutation_scope_provenance.sql new file mode 100644 index 00000000..50b84c65 --- /dev/null +++ b/cli/migrations/agent-trace-repository/005_mutation_scope_provenance.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS mutation_trace_scope_provenance ( + scope_id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + model_id TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); diff --git a/cli/src/services/agent_trace.rs b/cli/src/services/agent_trace.rs index a6a0862b..15c2bebf 100644 --- a/cli/src/services/agent_trace.rs +++ b/cli/src/services/agent_trace.rs @@ -459,6 +459,20 @@ fn classify_hunk_combined( } } +fn combined_model_id( + direct_hunk: Option<&PatchHunk>, + mutation_hunk: Option<&PatchHunk>, +) -> Option { + match (direct_hunk, mutation_hunk) { + (Some(direct), Some(mutation)) if direct.model_id == mutation.model_id => { + direct.model_id.clone() + } + (Some(direct), None) => direct.model_id.clone(), + (None, Some(mutation)) => mutation.model_id.clone(), + (None, None) | (Some(_), Some(_)) => None, + } +} + #[allow(dead_code)] pub(crate) fn patches_have_overlap( candidate_patch: &ParsedPatch, @@ -596,13 +610,14 @@ fn build_trace_file( ); let contributor_model_id = match contributor_kind { HunkContributor::Ai | HunkContributor::Mixed => { - matched_intersection_hunk.and_then(|hunk| hunk.model_id.clone()) + combined_model_id(matched_intersection_hunk, matched_mutation_hunk) } HunkContributor::Unknown => None, }; record_hunk_line_changes(line_changes, contributor_kind, post_commit_hunk); let related_session_ids = matched_intersection_hunk .into_iter() + .chain(matched_mutation_hunk) .flat_map(|hunk| hunk.lines.iter()) .filter_map(|line| line.session_id.as_deref()) .filter(|session_id| !session_id.is_empty()) diff --git a/cli/src/services/agent_trace/fixtures/direct_plus_mutation/golden.json b/cli/src/services/agent_trace/fixtures/direct_plus_mutation/golden.json index 87ab93f3..322f2ef7 100644 --- a/cli/src/services/agent_trace/fixtures/direct_plus_mutation/golden.json +++ b/cli/src/services/agent_trace/fixtures/direct_plus_mutation/golden.json @@ -28,8 +28,7 @@ { "url": "https://sce.crocoder.dev/conversations/PLACEHOLDER", "contributor": { - "type": "ai", - "model_id": "claude-sonnet-5" + "type": "ai" }, "ranges": [ { diff --git a/cli/src/services/agent_trace/fixtures/partial_combined/golden.json b/cli/src/services/agent_trace/fixtures/partial_combined/golden.json index 48b2436a..f1a2e2cd 100644 --- a/cli/src/services/agent_trace/fixtures/partial_combined/golden.json +++ b/cli/src/services/agent_trace/fixtures/partial_combined/golden.json @@ -28,8 +28,7 @@ { "url": "https://sce.crocoder.dev/conversations/PLACEHOLDER", "contributor": { - "type": "mixed", - "model_id": "claude-sonnet-5" + "type": "mixed" }, "ranges": [ { diff --git a/cli/src/services/agent_trace/tests.rs b/cli/src/services/agent_trace/tests.rs index 58619d16..bf1f3b1f 100644 --- a/cli/src/services/agent_trace/tests.rs +++ b/cli/src/services/agent_trace/tests.rs @@ -31,6 +31,49 @@ fn parse_fixture(fixture: &str) -> ParsedPatch { parse_patch(fixture, None).expect("fixture patch should parse") } +fn build_evidence_trace_with_mutation_provenance( + direct_fixture: &str, + mutation_fixture: &str, + post_commit_fixture: &str, + mutation_model_id: Option<&str>, + mutation_session_ids: &[&str], +) -> super::AgentTrace { + let mut direct_patch = parse_patch(direct_fixture, Some(EVIDENCE_DIRECT_SESSION_ID)) + .expect("direct fixture patch should parse"); + for file in &mut direct_patch.files { + for hunk in &mut file.hunks { + hunk.model_id = Some(String::from(EVIDENCE_DIRECT_MODEL_ID)); + } + } + + let mut mutation_ai_patch = parse_fixture(mutation_fixture); + for (line, session_id) in mutation_ai_patch.files[0].hunks[0] + .lines + .iter_mut() + .zip(mutation_session_ids.iter().copied()) + { + line.session_id = Some(String::from(session_id)); + } + mutation_ai_patch.files[0].hunks[0].model_id = mutation_model_id.map(str::to_owned); + + let post_commit_patch = parse_fixture(post_commit_fixture); + build_agent_trace_from_evidence( + AgentTraceEvidence { + direct_patch: &direct_patch, + mutation_ai_patch: &mutation_ai_patch, + }, + &post_commit_patch, + AgentTraceMetadataInput { + commit_timestamp: TEST_COMMIT_TIMESTAMP, + commit_revision: TEST_COMMIT_REVISION, + vcs_type: Some(AgentTraceVcsType::Git), + tool_name: Some(EVIDENCE_TOOL_NAME), + tool_version: Some(EVIDENCE_TOOL_VERSION), + }, + ) + .expect("agent trace should build") +} + const TEXT_FILE_LIFECYCLE_RECONSTRUCTION_INCREMENTALS: &[&str] = &[ include_str!("fixtures/text_file_lifecycle_reconstruction/incremental_01.patch"), include_str!("fixtures/text_file_lifecycle_reconstruction/incremental_02.patch"), @@ -531,6 +574,114 @@ fn mutation_only_no_provenance_evidence_matches_golden_agent_trace() { }); } +#[test] +fn mutation_only_evidence_emits_mutation_model_and_session() { + let trace = build_evidence_trace_with_mutation_provenance( + include_str!("fixtures/exclusive_without_direct/direct.patch"), + include_str!("fixtures/exclusive_without_direct/mutation_ai.patch"), + include_str!("fixtures/exclusive_without_direct/post_commit.patch"), + Some("gpt-5.6-sol"), + &[ + "cx-session-1", + "cx-session-1", + "cx-session-1", + "cx-session-1", + ], + ); + + let conversation = &trace.files[0].conversations[0]; + assert_eq!(conversation.contributor.kind, super::HunkContributor::Ai); + assert_eq!( + conversation.contributor.model_id.as_deref(), + Some("gpt-5.6-sol") + ); + assert_eq!( + conversation.related, + Some(vec![super::ConversationRelated { + kind: String::from("session"), + url: String::from("https://sce.crocoder.dev/sessions/cx-session-1"), + }]) + ); + validate_agent_trace_value( + &serde_json::to_value(&trace).expect("agent trace should serialize"), + ) + .expect("mutation-only agent trace should validate against schema"); +} + +#[test] +fn combined_evidence_unions_sessions_and_requires_model_agreement() { + let matching_trace = build_evidence_trace_with_mutation_provenance( + include_str!("fixtures/direct_plus_mutation/direct.patch"), + include_str!("fixtures/direct_plus_mutation/mutation_ai.patch"), + include_str!("fixtures/direct_plus_mutation/post_commit.patch"), + Some(EVIDENCE_DIRECT_MODEL_ID), + &["sess-a", "sess-z"], + ); + let matching_conversation = &matching_trace.files[0].conversations[0]; + assert_eq!( + matching_conversation.contributor.model_id.as_deref(), + Some(EVIDENCE_DIRECT_MODEL_ID) + ); + assert_eq!( + matching_conversation.related, + Some(vec![ + super::ConversationRelated { + kind: String::from("session"), + url: String::from("https://sce.crocoder.dev/sessions/sess-a"), + }, + super::ConversationRelated { + kind: String::from("session"), + url: String::from("https://sce.crocoder.dev/sessions/sess-direct"), + }, + super::ConversationRelated { + kind: String::from("session"), + url: String::from("https://sce.crocoder.dev/sessions/sess-z"), + }, + ]) + ); + + let conflicting_trace = build_evidence_trace_with_mutation_provenance( + include_str!("fixtures/direct_plus_mutation/direct.patch"), + include_str!("fixtures/direct_plus_mutation/mutation_ai.patch"), + include_str!("fixtures/direct_plus_mutation/post_commit.patch"), + Some("claude-opus-5"), + &["sess-a", "sess-direct"], + ); + assert_eq!( + conflicting_trace.files[0].conversations[0] + .contributor + .model_id, + None + ); + assert_eq!( + conflicting_trace.files[0].conversations[0] + .related + .as_ref() + .expect("conflicting evidence should retain related sessions") + .len(), + 2 + ); + + let unknown_trace = build_evidence_trace_with_mutation_provenance( + include_str!("fixtures/direct_plus_mutation/direct.patch"), + include_str!("fixtures/direct_plus_mutation/mutation_ai.patch"), + include_str!("fixtures/direct_plus_mutation/post_commit.patch"), + None, + &["sess-a", "sess-z"], + ); + assert_eq!( + unknown_trace.files[0].conversations[0].contributor.model_id, + None + ); + + for trace in [&matching_trace, &conflicting_trace, &unknown_trace] { + validate_agent_trace_value( + &serde_json::to_value(trace).expect("agent trace should serialize"), + ) + .expect("combined agent trace should validate against schema"); + } +} + #[test] fn direct_only_evidence_equals_direct_only_build_agent_trace() { let direct = include_str!("fixtures/direct_only/direct.patch"); diff --git a/cli/src/services/agent_trace_db/repository.rs b/cli/src/services/agent_trace_db/repository.rs index d8aa53b5..d68675c7 100644 --- a/cli/src/services/agent_trace_db/repository.rs +++ b/cli/src/services/agent_trace_db/repository.rs @@ -411,6 +411,7 @@ mod tests { "mutation_trace_processed_events", "mutation_trace_events", "mutation_trace_event_active_scopes", + "mutation_trace_scope_provenance", ] { assert!( sqlite_object_exists(&db, "table", table), @@ -453,10 +454,11 @@ mod tests { String::from("002_repository_source_instance_id"), String::from("003_claude_model_state"), String::from("004_mutation_trace_protocol"), + String::from("005_mutation_scope_provenance"), ], "repository DBs should be initialized from the baseline schema plus \ - its additive source-instance-id, Claude model-state, and \ - mutation-trace-protocol migrations" + its additive source-instance-id, Claude model-state, \ + mutation-trace-protocol, and mutation-scope-provenance migrations" ); db.ensure_schema_ready_for_hooks() @@ -499,6 +501,7 @@ mod tests { String::from("002_repository_source_instance_id"), String::from("003_claude_model_state"), String::from("004_mutation_trace_protocol"), + String::from("005_mutation_scope_provenance"), ] ); @@ -1068,8 +1071,9 @@ mod tests { String::from("002_repository_source_instance_id"), String::from("003_claude_model_state"), String::from("004_mutation_trace_protocol"), + String::from("005_mutation_scope_provenance"), ], - "an existing 001+002 database should get 003 and 004 applied on top through the setup/lifecycle path, without reapplying 001/002" + "an existing 001+002 database should get 003, 004, and 005 applied on top through the setup/lifecycle path, without reapplying 001/002" ); for table in [ @@ -1078,6 +1082,7 @@ mod tests { "mutation_trace_processed_events", "mutation_trace_events", "mutation_trace_event_active_scopes", + "mutation_trace_scope_provenance", ] { assert!( sqlite_object_exists(&migrated, "table", table), @@ -1140,7 +1145,7 @@ mod tests { String::from("001_repository_schema"), String::from("002_repository_source_instance_id"), ], - "the no-migration hook-runtime path must never record or apply 003 or 004" + "the no-migration hook-runtime path must never record or apply 003, 004, or 005" ); for table in [ @@ -1149,6 +1154,7 @@ mod tests { "mutation_trace_processed_events", "mutation_trace_events", "mutation_trace_event_active_scopes", + "mutation_trace_scope_provenance", ] { assert!( !sqlite_object_exists(&db, "table", table), diff --git a/cli/src/services/hooks/claude_mutation_scope/mod.rs b/cli/src/services/hooks/claude_mutation_scope/mod.rs index b60f188f..5b9408d0 100644 --- a/cli/src/services/hooks/claude_mutation_scope/mod.rs +++ b/cli/src/services/hooks/claude_mutation_scope/mod.rs @@ -334,6 +334,10 @@ const EXPLICIT_BACKGROUND_SHELL_DENY_REASON: &str = const PRE_TOOL_USE_FAIL_CLOSED_EVENT: &str = "sce.hooks.claude_mutation_scope.pre_tool_use_fail_closed"; +const MODEL_STATE_UNAVAILABLE_EVENT: &str = + "sce.hooks.claude_mutation_scope.model_state_unavailable"; + +type ClaudeModelStateResolver<'a> = &'a dyn Fn(&Path, &str, &str) -> Result>; fn log_pre_tool_use_fail_closed(logger: Option<&dyn Logger>, context: &str, error: &anyhow::Error) { if let Some(log) = logger { @@ -356,56 +360,103 @@ pub(crate) fn run_claude_mutation_scope_from_payload( logger: Option<&dyn Logger>, ) -> Result { let resolve_git_dir_fn = |cwd: &str| checkout::resolve_git_dir(Path::new(cwd)); + let model_state_resolver = + |repository_root: &Path, session_id: &str, agent_id: &str| -> Result> { + let db = super::open_agent_trace_db_for_hook_runtime( + repository_root, + "Failed to open Agent Trace DB for Claude mutation-scope model resolution.", + )?; + Ok(db + .claude_model_state_by_session_and_agent(session_id, agent_id)? + .map(|state| state.model_id)) + }; let seam_fn = |repository_root: &Path, payload: &str, logger: Option<&dyn Logger>| { super::mutation_scope::run_mutation_scope_from_payload(repository_root, payload, logger) }; - run_claude_mutation_scope_from_payload_with( + run_claude_mutation_scope_from_payload_with_resolver( stdin_payload, logger, &resolve_git_dir_fn, + &model_state_resolver, &seam_fn, ) } #[cfg(test)] -fn run_claude_mutation_scope_from_payload_at_state_root( +pub(crate) fn run_claude_mutation_scope_from_payload_at_state_root( state_root: &Path, stdin_payload: &str, logger: Option<&dyn Logger>, ) -> Result { let resolve_git_dir_fn = |cwd: &str| checkout::resolve_git_dir(Path::new(cwd)); + let model_state_root = state_root.to_path_buf(); + let seam_state_root = state_root.to_path_buf(); + let model_state_resolver = + move |repository_root: &Path, session_id: &str, agent_id: &str| -> Result> { + let db = super::open_agent_trace_db_for_hook_runtime_at_state_root( + repository_root, + &model_state_root, + "Failed to open Agent Trace DB for Claude mutation-scope model resolution.", + )?; + Ok(db + .claude_model_state_by_session_and_agent(session_id, agent_id)? + .map(|state| state.model_id)) + }; let seam_fn = |repository_root: &Path, payload: &str, logger: Option<&dyn Logger>| { super::mutation_scope::run_mutation_scope_from_payload_at_state_root( repository_root, - state_root, + &seam_state_root, payload, logger, ) }; - run_claude_mutation_scope_from_payload_with( + run_claude_mutation_scope_from_payload_with_resolver( stdin_payload, logger, &resolve_git_dir_fn, + &model_state_resolver, &seam_fn, ) } +#[cfg(test)] fn run_claude_mutation_scope_from_payload_with( stdin_payload: &str, logger: Option<&dyn Logger>, resolve_git_dir: GitDirResolver, seam: IngressSeam, +) -> Result { + let unavailable_model_state = |_repository_root: &Path, + _session_id: &str, + _agent_id: &str| + -> Result> { Ok(None) }; + run_claude_mutation_scope_from_payload_with_resolver( + stdin_payload, + logger, + resolve_git_dir, + &unavailable_model_state, + seam, + ) +} + +fn run_claude_mutation_scope_from_payload_with_resolver( + stdin_payload: &str, + logger: Option<&dyn Logger>, + resolve_git_dir: GitDirResolver, + model_state_resolver: ClaudeModelStateResolver, + seam: IngressSeam, ) -> Result { let event = parse_claude_hook_event(stdin_payload)?; - dispatch_claude_hook_event(event, logger, resolve_git_dir, seam) + dispatch_claude_hook_event(event, logger, resolve_git_dir, model_state_resolver, seam) } fn dispatch_claude_hook_event( event: ClaudeHookEvent, logger: Option<&dyn Logger>, resolve_git_dir: GitDirResolver, + model_state_resolver: ClaudeModelStateResolver, seam: IngressSeam, ) -> Result { match event { @@ -413,6 +464,7 @@ fn dispatch_claude_hook_event( &execution, logger, resolve_git_dir, + model_state_resolver, seam, )), ClaudeHookEvent::PostToolUse(identity) | ClaudeHookEvent::PostToolUseFailure(identity) => { @@ -483,6 +535,7 @@ fn handle_pre_tool_use( execution: &ClaudeToolExecution, logger: Option<&dyn Logger>, resolve_git_dir: GitDirResolver, + model_state_resolver: ClaudeModelStateResolver, seam: IngressSeam, ) -> String { let identity = &execution.identity; @@ -514,7 +567,14 @@ fn handle_pre_tool_use( return pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON); } - match establish_start(&git_dir, repository_root, identity, logger, seam) { + match establish_start( + &git_dir, + repository_root, + identity, + logger, + model_state_resolver, + seam, + ) { Ok(()) => String::new(), Err(error) => { log_pre_tool_use_fail_closed(logger, "establish_start", &error); @@ -574,12 +634,34 @@ fn establish_start( repository_root: &Path, identity: &ClaudeToolIdentity, logger: Option<&dyn Logger>, + model_state_resolver: ClaudeModelStateResolver, seam: IngressSeam, ) -> Result<()> { let allocated = state::allocate_attempt(git_dir, &identity.attempt_key(), &identity.tool_name)?; let scope_id = &allocated.attempt.scope_id; - let start_payload = - scope_boundary_payload("start", scope_id, &claude_scope_start_event_id(scope_id)); + let canonical_session_id = + super::prefixed_diff_trace_session_id(super::CLAUDE_TOOL_NAME, &identity.session_id); + let agent_id = identity.agent_id.as_deref().unwrap_or(""); + let model_id = match model_state_resolver(repository_root, &canonical_session_id, agent_id) { + Ok(model_id) => model_id.and_then(|model| super::normalize_claude_model_id(&model)), + Err(error) => { + if let Some(log) = logger { + log.warn( + MODEL_STATE_UNAVAILABLE_EVENT, + &error.to_string(), + &[("agent_id", agent_id)], + Some(&canonical_session_id), + ); + } + None + } + }; + let start_payload = scope_start_payload( + scope_id, + &claude_scope_start_event_id(scope_id), + &canonical_session_id, + model_id.as_deref(), + ); seam(repository_root, &start_payload, logger)?; state::mark_active(git_dir, scope_id)?; @@ -694,6 +776,25 @@ fn scope_boundary_payload(operation: &str, scope_id: &str, event_id: &str) -> St .to_string() } +fn scope_start_payload( + scope_id: &str, + event_id: &str, + session_id: &str, + model_id: Option<&str>, +) -> String { + json!({ + "operation": "start", + "scope_id": scope_id, + "event_id": event_id, + "actor_kind": ACTOR_KIND_CLAUDE_CODE, + "provenance": { + "session_id": session_id, + "model_id": model_id, + }, + }) + .to_string() +} + fn abandon_payload(scope_id: &str) -> String { json!({ "operation": "abandon", @@ -1370,7 +1471,7 @@ mod tests { } mod driver { - use std::cell::RefCell; + use std::cell::{Cell, RefCell}; use std::path::PathBuf; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; @@ -1433,6 +1534,22 @@ mod tests { move |_cwd| Ok(git_dir.clone()) } + fn start_with_model_resolver( + payload: &str, + logger: Option<&dyn Logger>, + resolve_git_dir: GitDirResolver, + model_state_resolver: ClaudeModelStateResolver, + seam: IngressSeam, + ) -> Result { + run_claude_mutation_scope_from_payload_with_resolver( + payload, + logger, + resolve_git_dir, + model_state_resolver, + seam, + ) + } + #[derive(Clone, Default)] struct RecordingLogger { warnings: Arc>>, @@ -1477,6 +1594,176 @@ mod tests { Value::Object(object).to_string() } + #[test] + fn pre_tool_use_resolves_main_and_subagent_model_state_exactly_at_admission() { + let git_dir = unique_test_git_dir("model-state-admission"); + let git_dir_resolver = fixed_resolver(git_dir.clone()); + let resolver_calls: RefCell> = RefCell::new(Vec::new()); + let model_state_resolver = |_: &Path, session_id: &str, agent_id: &str| { + resolver_calls + .borrow_mut() + .push((session_id.to_string(), agent_id.to_string())); + Ok(Some(if agent_id.is_empty() { + "claude/sonnet".to_string() + } else { + "claude/opus".to_string() + })) + }; + let starts: RefCell> = RefCell::new(Vec::new()); + let seam = + |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { + let value: Value = serde_json::from_str(payload).expect("payload is JSON"); + if value.get("operation") == Some(&Value::String("start".to_string())) { + starts.borrow_mut().push(value); + } + Ok(String::new()) + }; + + let main_payload = pre_tool_use_json(&[]); + let subagent_payload = pre_tool_use_json(&[ + ( + TOOL_USE_ID_FIELD, + Value::String("toolu_subagent".to_string()), + ), + (AGENT_ID_FIELD, Value::String("agent-1".to_string())), + ]); + start_with_model_resolver( + &main_payload, + None, + &git_dir_resolver, + &model_state_resolver, + &seam, + ) + .expect("main-agent Start should succeed"); + start_with_model_resolver( + &subagent_payload, + None, + &git_dir_resolver, + &model_state_resolver, + &seam, + ) + .expect("subagent Start should succeed"); + + assert_eq!( + resolver_calls.into_inner(), + vec![ + ("cc_session-1".to_string(), String::new()), + ("cc_session-1".to_string(), "agent-1".to_string()), + ] + ); + let starts = starts.into_inner(); + assert_eq!(starts.len(), 2); + assert_eq!( + starts[0]["provenance"], + json!({"session_id": "cc_session-1", "model_id": "claude/sonnet"}) + ); + assert_eq!( + starts[1]["provenance"], + json!({"session_id": "cc_session-1", "model_id": "claude/opus"}) + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn subagent_without_exact_model_state_does_not_inherit_main_model() { + let git_dir = unique_test_git_dir("subagent-model-state-missing"); + let git_dir_resolver = fixed_resolver(git_dir.clone()); + let starts: RefCell> = RefCell::new(Vec::new()); + let model_state_resolver = |_: &Path, _: &str, agent_id: &str| { + Ok(if agent_id.is_empty() { + Some("claude/sonnet".to_string()) + } else { + None + }) + }; + let seam = + |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { + starts + .borrow_mut() + .push(serde_json::from_str(payload).expect("payload is JSON")); + Ok(String::new()) + }; + + for (tool_use_id, agent_id) in + [("toolu_main", None), ("toolu_subagent", Some("agent-1"))] + { + let overrides = agent_id + .map(|agent_id| vec![(AGENT_ID_FIELD, Value::String(agent_id.to_string()))]) + .unwrap_or_default(); + let mut overrides = overrides; + overrides.push((TOOL_USE_ID_FIELD, Value::String(tool_use_id.to_string()))); + let payload = pre_tool_use_json(&overrides); + start_with_model_resolver( + &payload, + None, + &git_dir_resolver, + &model_state_resolver, + &seam, + ) + .expect("both Starts should succeed"); + } + + let starts = starts.into_inner(); + assert_eq!(starts.len(), 2); + assert_eq!(starts[0]["provenance"]["model_id"], "claude/sonnet"); + assert_eq!(starts[1]["provenance"]["session_id"], "cc_session-1"); + assert_eq!(starts[1]["provenance"]["model_id"], Value::Null); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn missing_or_failed_model_resolution_keeps_session_provenance_and_allows_start() { + let git_dir = unique_test_git_dir("model-state-unavailable"); + let git_dir_resolver = fixed_resolver(git_dir.clone()); + let starts: RefCell> = RefCell::new(Vec::new()); + let seam = + |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { + starts + .borrow_mut() + .push(serde_json::from_str(payload).expect("payload is JSON")); + Ok(String::new()) + }; + let response_index = Cell::new(0); + let model_state_resolver = |_: &Path, _: &str, _: &str| -> Result> { + let index = response_index.get(); + response_index.set(index + 1); + if index == 0 { + Ok(None) + } else { + Err(anyhow!("local model-state DB is unavailable")) + } + }; + + for tool_use_id in ["toolu_missing", "toolu_failed"] { + let payload = pre_tool_use_json(&[( + TOOL_USE_ID_FIELD, + Value::String(tool_use_id.to_string()), + )]); + let output = start_with_model_resolver( + &payload, + None, + &git_dir_resolver, + &model_state_resolver, + &seam, + ) + .expect("model unavailability must not fail Start"); + assert_eq!(output, ""); + } + + let starts = starts.into_inner(); + assert_eq!(starts.len(), 2); + for start in starts { + assert_eq!( + start["provenance"], + json!({"session_id": "cc_session-1", "model_id": null}) + ); + } + + remove_test_git_dir(&git_dir); + } + #[test] fn read_only_tool_creates_no_scope_and_never_touches_the_seam_or_git_dir() { let resolver = |_: &str| -> Result { @@ -2661,6 +2948,7 @@ mod tests { use super::*; use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; + use crate::services::agent_trace_db::{ClaudeModelStateObservation, ObservationKind}; use crate::services::agent_trace_storage::{ resolve_agent_trace_storage_at_state_root, AgentTraceStorageContext, }; @@ -2879,6 +3167,25 @@ mod tests { .next() } + fn scope_provenance( + db: &RepositoryAgentTraceDb, + scope_id: &str, + ) -> Option<(String, Option)> { + db.query_map( + "SELECT session_id, model_id FROM mutation_trace_scope_provenance \ + WHERE scope_id = ?1", + (scope_id,), + |row| { + let session_id = row.get::(0).map_err(anyhow::Error::from)?; + let model_id = row.get::>(1).map_err(anyhow::Error::from)?; + Ok((session_id, model_id)) + }, + ) + .expect("scope-provenance query should succeed") + .into_iter() + .next() + } + fn mutation_events_for( db: &RepositoryAgentTraceDb, worktree_id: &str, @@ -3878,6 +4185,57 @@ mod tests { assert_raw_agent_trace_tables_untouched(&db); } + #[test] + fn test18_model_switch_does_not_rewrite_scope_provenance() { + let repo = ClaudeRepo::new("model-switch-provenance"); + let session_id = "session-model-switch"; + let db = repo.db(); + db.upsert_claude_model_state(ClaudeModelStateObservation { + session_id: "cc_session-model-switch".to_string(), + agent_id: String::new(), + model_id: "claude/sonnet".to_string(), + observation_kind: ObservationKind::SessionStart, + source: "test".to_string(), + observed_at_ms: 1, + }) + .expect("initial Claude model state should persist"); + + let pre_payload = + pre_tool_use_for(&repo.cwd(), session_id, "Bash", "toolu_model_switch", None); + repo.drive(&pre_payload) + .expect("initial PreToolUse should establish a scope"); + let scope_id = repo + .adapter_state() + .attempts + .first() + .expect("the scope should remain live") + .scope_id + .clone(); + let before = scope_provenance(&repo.db(), &scope_id); + assert_eq!( + before, + Some(( + "cc_session-model-switch".to_string(), + Some("claude/sonnet".to_string()), + )) + ); + + repo.db() + .upsert_claude_model_state(ClaudeModelStateObservation { + session_id: "cc_session-model-switch".to_string(), + agent_id: String::new(), + model_id: "claude/opus".to_string(), + observation_kind: ObservationKind::PostModelSwitch, + source: "test".to_string(), + observed_at_ms: 2, + }) + .expect("model switch should persist"); + repo.drive(&pre_payload) + .expect("replayed PreToolUse should remain idempotent"); + + assert_eq!(scope_provenance(&repo.db(), &scope_id), before); + } + #[test] fn test16_regression_matrix_leaves_raw_agent_trace_tables_untouched() { let repo = ClaudeRepo::new("test16-raw-tables-untouched"); diff --git a/cli/src/services/hooks/codex_mutation_scope/mod.rs b/cli/src/services/hooks/codex_mutation_scope/mod.rs index a80ac388..3a547a51 100644 --- a/cli/src/services/hooks/codex_mutation_scope/mod.rs +++ b/cli/src/services/hooks/codex_mutation_scope/mod.rs @@ -13,6 +13,9 @@ use crate::services::checkout; use crate::services::hooks::codex::bash_policy::{ bash_command_from_tool_input, evaluate_codex_bash_policy, CodexBashPolicyDecision, }; +use crate::services::hooks::{ + normalize_codex_model_id, prefixed_diff_trace_session_id, CODEX_TOOL_NAME, +}; use crate::services::observability::traits::Logger; use boundary_lock::{AdapterBoundaryLock, DEFAULT_BOUNDARY_LOCK_TIMEOUT}; @@ -23,6 +26,8 @@ const TURN_ID_FIELD: &str = "turn_id"; const CWD_FIELD: &str = "cwd"; const AGENT_ID_FIELD: &str = "agent_id"; const AGENT_TYPE_FIELD: &str = "agent_type"; +const MODEL_FIELD: &str = "model"; +const PROVENANCE_FIELD: &str = "provenance"; const TOOL_NAME_FIELD: &str = "tool_name"; const TOOL_USE_ID_FIELD: &str = "tool_use_id"; const TOOL_INPUT_FIELD: &str = "tool_input"; @@ -74,6 +79,7 @@ impl CodexToolIdentity { pub(crate) struct CodexToolExecution { pub identity: CodexToolIdentity, pub agent_type: Option, + pub model: Option, pub tool_input: Option, } @@ -198,6 +204,7 @@ fn parse_pre_tool_use(object: &Map) -> Result Ok(CodexToolExecution { identity: parse_tool_identity(object)?, agent_type: optional_non_blank_str(object, AGENT_TYPE_FIELD)?, + model: tolerated_model(object), tool_input: object.get(TOOL_INPUT_FIELD).cloned(), }) } @@ -273,6 +280,13 @@ fn optional_non_blank_str(object: &Map, field: &str) -> Result) -> Option { + object + .get(MODEL_FIELD) + .and_then(Value::as_str) + .map(str::to_owned) +} + fn validation_error(detail: &str) -> String { format!("Invalid Codex hook event payload from STDIN: {detail}.") } @@ -285,6 +299,22 @@ type BashPolicyEvaluator<'a> = &'a dyn Fn(&Path, &str) -> Result, +} + +fn codex_scope_provenance(execution: &CodexToolExecution) -> CodexScopeProvenance { + CodexScopeProvenance { + session_id: prefixed_diff_trace_session_id(CODEX_TOOL_NAME, &execution.identity.session_id), + model_id: execution + .model + .as_deref() + .and_then(normalize_codex_model_id), + } +} + const FAIL_CLOSED_DENY_REASON: &str = "SCE could not establish mutation attribution for this tool execution."; @@ -329,7 +359,7 @@ pub(crate) fn run_codex_mutation_scope_from_payload( } #[cfg(test)] -fn run_codex_mutation_scope_from_payload_at_state_root( +pub(crate) fn run_codex_mutation_scope_from_payload_at_state_root( state_root: &Path, stdin_payload: &str, logger: Option<&dyn Logger>, @@ -526,6 +556,7 @@ fn handle_pre_tool_use( let key = identity.attempt_key(); let turn_id = identity.turn_id.as_str(); + let provenance = codex_scope_provenance(execution); let outcome = with_boundary_lock(&git_dir, || { state::normalize_recovery_after_boundary_lock_acquired(&git_dir)?; @@ -541,7 +572,14 @@ fn handle_pre_tool_use( seam, )? { Admission::Admitted(allocated) => { - establish_start(&git_dir, repository_root, &allocated, logger, seam)?; + establish_start( + &git_dir, + repository_root, + &allocated, + &provenance, + logger, + seam, + )?; Ok(PreToolUseOutcome::Continue) } Admission::Denied => Ok(PreToolUseOutcome::Deny), @@ -670,6 +708,7 @@ fn establish_start( git_dir: &Path, repository_root: &Path, allocated: &state::AllocatedAttempt, + provenance: &CodexScopeProvenance, logger: Option<&dyn Logger>, seam: IngressSeam, ) -> Result<()> { @@ -680,7 +719,7 @@ fn establish_start( } let start_payload = - scope_boundary_payload("start", scope_id, &codex_scope_start_event_id(scope_id)); + scope_start_payload(scope_id, &codex_scope_start_event_id(scope_id), provenance); seam(repository_root, &start_payload, logger)?; state::mark_active(git_dir, scope_id)?; @@ -774,6 +813,24 @@ fn scope_boundary_payload(operation: &str, scope_id: &str, event_id: &str) -> St .to_string() } +fn scope_start_payload( + scope_id: &str, + event_id: &str, + provenance: &CodexScopeProvenance, +) -> String { + json!({ + "operation": "start", + "scope_id": scope_id, + "event_id": event_id, + "actor_kind": ACTOR_KIND_CODEX, + PROVENANCE_FIELD: { + "session_id": provenance.session_id, + "model_id": provenance.model_id, + }, + }) + .to_string() +} + fn abandon_payload(scope_id: &str) -> String { json!({ "operation": "abandon", @@ -1038,6 +1095,65 @@ mod tests { assert_eq!(execution.agent_type.as_deref(), Some("default")); } + #[test] + fn ac3_pre_tool_use_fixtures_retain_the_codex_model() { + for fixture in [ + PROBE01_SHELL_PRE, + PROBE01_APPLY_PATCH_PRE, + PROBE05_SHELL_PRE, + PROBE08_AGENT_APPLY_PATCH_PRE, + ] { + assert_eq!(pre_tool_use(fixture).model.as_deref(), Some("gpt-5.6-sol")); + } + } + + #[test] + fn ac3_scope_provenance_canonicalizes_the_session_and_normalizes_the_model() { + let provenance = codex_scope_provenance(&pre_tool_use(PROBE01_SHELL_PRE)); + assert_eq!( + provenance.session_id, + "cx_01a07c1e-e08e-7172-8032-cb9d62af21d9" + ); + assert_eq!(provenance.model_id.as_deref(), Some("gpt-5.6-sol")); + + let apply_patch = codex_scope_provenance(&pre_tool_use(PROBE01_APPLY_PATCH_PRE)); + assert_eq!(apply_patch, provenance); + } + + #[test] + fn ac3_scope_provenance_keeps_an_already_prefixed_session_id() { + let execution = pre_tool_use(&pre_tool_use_json(&[( + SESSION_ID_FIELD, + Value::String("cx_session-1".to_string()), + )])); + assert_eq!( + codex_scope_provenance(&execution).session_id, + "cx_session-1" + ); + } + + #[test] + fn ac3_an_unusable_model_yields_no_model_id_without_rejecting_the_event() { + for model in [ + Value::Null, + Value::String(String::new()), + Value::String(" ".to_string()), + Value::Bool(true), + json!(7), + json!({ "id": "gpt-5.6-sol" }), + ] { + let payload = pre_tool_use_json(&[(MODEL_FIELD, model.clone())]); + let execution = pre_tool_use(&payload); + let provenance = codex_scope_provenance(&execution); + assert_eq!(provenance.model_id, None, "model {model:?}"); + assert_eq!(provenance.session_id, "cx_session-1", "model {model:?}"); + } + + let absent = pre_tool_use(&pre_tool_use_json(&[])); + assert_eq!(absent.model, None); + assert_eq!(codex_scope_provenance(&absent).model_id, None); + } + #[test] fn ac2_post_tool_use_fixtures_parse() { for (payload, tool_name, tool_use_id) in [ @@ -1619,6 +1735,96 @@ mod tests { remove_test_git_dir(&git_dir); } + fn boundary_payload_field(payload: &str, field: &str) -> Option { + let object: Map = + serde_json::from_str(payload).expect("a boundary payload is a JSON object"); + object.get(field).cloned() + } + + fn start_provenance(payload: &str) -> Value { + assert_eq!( + boundary_payload_field(payload, "operation"), + Some(Value::String("start".to_string())) + ); + boundary_payload_field(payload, PROVENANCE_FIELD) + .expect("a Codex start payload carries provenance") + } + + fn drive_recording_start(label: &str, payload: &str) -> Vec { + let git_dir = unique_test_git_dir(label); + let resolver = fixed_resolver(git_dir.clone()); + let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); + + drive(payload, &resolver, &recording_seam(Arc::clone(&recorded))); + + let calls = recorded.lock().expect("recording seam mutex").clone(); + remove_test_git_dir(&git_dir); + calls + } + + #[test] + fn ac3_tracked_start_carries_scope_provenance_for_both_tracked_tools() { + for tool in TRACKED_MUTATION_TOOL_NAMES { + let payload = pre_tool_use_json(&[ + (TOOL_NAME_FIELD, Value::String((*tool).to_string())), + (MODEL_FIELD, Value::String("gpt-5.6-sol".to_string())), + ]); + let calls = drive_recording_start(&format!("provenance-{tool}"), &payload); + + assert_eq!(calls.len(), 1, "{tool} should drive exactly one boundary"); + assert_eq!( + start_provenance(&calls[0]), + json!({ "session_id": "cx_session-1", "model_id": "gpt-5.6-sol" }), + "AC3: {tool} must carry its canonical session and normalized model" + ); + } + } + + #[test] + fn ac3_a_start_without_a_usable_model_still_carries_its_session() { + let cases: [(&str, Option); 4] = [ + ("absent", None), + ("null", Some(Value::Null)), + ("blank", Some(Value::String(" ".to_string()))), + ("non-string", Some(Value::Bool(true))), + ]; + + for (label, model) in cases { + let overrides = model.map_or_else(Vec::new, |value| vec![(MODEL_FIELD, value)]); + let payload = pre_tool_use_json(&overrides); + let calls = drive_recording_start(&format!("provenance-model-{label}"), &payload); + + assert_eq!(calls.len(), 1, "{label} should drive exactly one boundary"); + assert_eq!( + start_provenance(&calls[0]), + json!({ "session_id": "cx_session-1", "model_id": Value::Null }), + "AC3: a {label} model records no model without losing the session" + ); + } + } + + #[test] + fn ac3_only_the_start_boundary_carries_provenance() { + let git_dir = unique_test_git_dir("provenance-start-only"); + let resolver = fixed_resolver(git_dir.clone()); + let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); + let seam = recording_seam(Arc::clone(&recorded)); + + drive(&pre_tool_use_json(&[]), &resolver, &seam); + drive(&post_tool_use_json(&[]), &resolver, &seam); + + let calls = recorded.lock().expect("recording seam mutex").clone(); + assert_eq!(calls.len(), 2); + assert!(boundary_payload_field(&calls[0], PROVENANCE_FIELD).is_some()); + assert_eq!( + boundary_payload_field(&calls[1], "operation"), + Some(Value::String("close".to_string())) + ); + assert_eq!(boundary_payload_field(&calls[1], PROVENANCE_FIELD), None); + + remove_test_git_dir(&git_dir); + } + #[test] fn duplicate_pre_tool_use_reuses_the_same_scope_id_ac4_test_e() { let git_dir = unique_test_git_dir("duplicate-pre"); @@ -3843,6 +4049,25 @@ mod tests { .expect("mutation-events query should succeed") } + fn scope_provenance( + db: &RepositoryAgentTraceDb, + scope_id: &str, + ) -> Option<(String, Option)> { + db.query_map( + "SELECT session_id, model_id FROM mutation_trace_scope_provenance \ + WHERE scope_id = ?1", + (scope_id,), + |row| { + let session_id = row.get::(0).map_err(anyhow::Error::from)?; + let model_id = row.get::>(1).map_err(anyhow::Error::from)?; + Ok((session_id, model_id)) + }, + ) + .expect("scope-provenance query should succeed") + .into_iter() + .next() + } + fn active_scopes_for(db: &RepositoryAgentTraceDb, worktree_id: &str) -> Vec { db.query_map( "SELECT scope_id FROM mutation_trace_event_active_scopes \ @@ -5157,5 +5382,81 @@ mod tests { assert_raw_agent_trace_tables_untouched(&db); } + + #[test] + fn test27_tracked_fixtures_persist_scope_provenance_ac3() { + for (label, fixture) in [ + ("bash", PROBE01_SHELL_PRE), + ("apply-patch", PROBE01_APPLY_PATCH_PRE), + ] { + let repo = CodexRepo::new(&format!("test27-provenance-{label}")); + let cwd = repo.cwd(); + + repo.drive(&fixture_at(fixture, &cwd)) + .expect("a tracked PreToolUse should Start"); + let scope_id = repo.live_scope_id(); + + let db = repo.db(); + assert_eq!( + scope_provenance(&db, &scope_id), + Some(( + "cx_01a07c1e-e08e-7172-8032-cb9d62af21d9".to_string(), + Some("gpt-5.6-sol".to_string()) + )), + "AC3: the {label} fixture must persist its cx_ session and normalized model" + ); + assert_eq!(count(&db, "mutation_trace_scope_provenance"), 1); + assert_eq!( + scope_status(&db, &scope_id), + Some(("codex".to_string(), "active".to_string())) + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + } + + #[test] + fn test28_a_tracked_execution_without_a_model_persists_a_null_model_ac3() { + let repo = CodexRepo::new("test28-provenance-no-model"); + let cwd = repo.cwd(); + let call = bash_call(&cwd, "session-no-model", "exec-no-model"); + + repo.drive(&call.pre()) + .expect("a tracked PreToolUse without a model should still Start"); + let scope_id = repo.live_scope_id(); + + let db = repo.db(); + assert_eq!( + scope_provenance(&db, &scope_id), + Some(("cx_session-no-model".to_string(), None)), + "AC3: a missing model records model_id = NULL without losing the session" + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test29_untracked_and_delegation_tools_persist_no_provenance_ac3() { + let repo = CodexRepo::new("test29-untracked-no-provenance"); + let cwd = repo.cwd(); + + for fixture in [ + PROBE12_MCP_PRE, + PROBE08_SPAWN_AGENT_PRE, + PROBE08_WAIT_AGENT_PRE, + ] { + assert_eq!( + repo.drive(&fixture_at(fixture, &cwd)) + .expect("an untracked or delegation PreToolUse should succeed"), + "" + ); + } + + let db = repo.db(); + assert_eq!(count(&db, "mutation_trace_scopes"), 0); + assert_eq!(count(&db, "mutation_trace_scope_provenance"), 0); + + assert_raw_agent_trace_tables_untouched(&db); + } } } diff --git a/cli/src/services/hooks/mod.rs b/cli/src/services/hooks/mod.rs index c53b20df..1617800e 100644 --- a/cli/src/services/hooks/mod.rs +++ b/cli/src/services/hooks/mod.rs @@ -122,7 +122,6 @@ struct DiffTracePayload { payload_type: String, } -/// Either a diff-trace payload to persist or a deterministic no-op result. #[derive(Clone, Debug, Eq, PartialEq)] enum DiffTraceParseResult { Persist(DiffTracePayload), @@ -195,14 +194,6 @@ impl ConversationTracePersistenceSummary { } } -/// Required `sce hooks diff-trace` STDIN payload shape: -/// `{ sessionID, diff, time, model_id?, tool_name, tool_version }`. -/// -/// Validation contract: -/// - `sessionID`, `diff`, and `tool_name` must be non-empty strings. -/// - `model_id` is optional: absent or `null` → `None`, present+non-empty → `Some`, present+empty → error. -/// - `time` must be a `u64` Unix epoch millisecond value. -/// - `tool_version` must be present and either `null` or a non-empty string. pub fn run_hooks_subcommand( subcommand: &HookSubcommand, logger: Option<&dyn Logger>, @@ -549,7 +540,6 @@ pub fn parse_conversation_trace_payload(stdin_payload: &str) -> Result { - // Try JSON first — if payload.text is already a serialized ParsedPatch, use it directly. if load_patch_from_json(&raw_text).is_ok() { raw_text } else { - // Fall back to raw unified-diff parsing. match parse_patch_from_text(&raw_text, None) { Ok(parsed_patch) => serialize_to_json(&parsed_patch).map_err(|error| { anyhow!(conversation_trace_validation_error(&format!( @@ -948,12 +936,10 @@ fn parse_diff_trace_payload(stdin_payload: &str) -> Result .as_object() .ok_or_else(|| anyhow!(payload_kind.validation_error("expected a JSON object")))?; - // Classify: Claude structured payloads carry hook_event_name. if payload.contains_key("hook_event_name") { return parse_claude_diff_trace_payload(payload, stdin_payload, payload_kind); } - // OpenCode normalized payload — unchanged validation. let session_id = required_non_empty_string_field(payload, "sessionID", |d| { payload_kind.validation_error(d) })?; @@ -979,10 +965,6 @@ fn parse_diff_trace_payload(stdin_payload: &str) -> Result })) } -/// Parse a Claude structured hook payload into a diff-trace intake result. -/// -/// Returns `NoOp` for events without diff traces and unsupported tool usage; -/// only supported `PostToolUse Write` / `Edit` events produce a `Persist` result. fn parse_claude_diff_trace_payload( payload: &serde_json::Map, stdin_payload: &str, @@ -1109,8 +1091,6 @@ fn normalize_codex_model_id(model: &str) -> Option { Some(normalized.to_string()) } -/// Extract a u64 timestamp from a Claude hook event payload, falling back to the -/// current system time when no timestamp field is present. fn extract_claude_event_time(payload: &serde_json::Map) -> u64 { for key in &["time", "timestamp"] { if let Some(time_value) = payload.get(*key) { @@ -1596,9 +1576,6 @@ fn run_post_commit_agent_trace_flow( "Failed to open Agent Trace DB for post-commit trace.", )?; - // Direct evidence is resolved first with the existing intersection, then the - // committed lines it does not cover are offered to bounded mutation history - // (read-only, current-worktree-only, direct-only fallback on absent identity). let direct_intersection = intersect_patches_fn( &flow_result.combined_recent_patch, &flow_result.post_commit_data.parsed_patch, @@ -1696,7 +1673,6 @@ where Ok(agent_trace) } -/// Duration for looking up recent diff traces: 7 days in milliseconds. const RECENT_DAYS_MILLIS: i64 = 7 * 24 * 60 * 60 * 1000; fn run_post_commit_intersection_flow( @@ -1724,22 +1700,10 @@ fn run_post_commit_intersection_flow( ) } -/// Result of the staged-diff AI-overlap evidence check. -/// -/// Used by the commit-msg hook to decide whether to append the canonical -/// co-author trailer. Errors are collapsed to `NoEvidence` at the policy -/// level (trailer is never appended on error), but the `Error` variant -/// allows the caller to log a diagnostic event distinguishing error -/// paths from honest no-overlap. #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum StagedDiffAiOverlapResult { - /// Staged diff overlaps with at least one recent AI/editor diff trace. Overlap, - /// No overlap found; staged diff and recent traces were both available - /// but share no touched lines. NoOverlap, - /// An error occurred (DB open failure, schema not ready, query error, - /// staged diff read failure, etc.). The trailer must not be appended. Error, } @@ -2088,7 +2052,6 @@ pub enum HookNoOpReason { AttributionOnlyCommitMsgMode, } -/// Post-commit patch data captured from git for intersection flows. #[derive(Clone, Debug, Eq, PartialEq)] pub struct PostCommitPatchData { pub commit_oid: String, @@ -2096,7 +2059,6 @@ pub struct PostCommitPatchData { pub parsed_patch: ParsedPatch, } -/// Structured post-commit intersection flow result. #[derive(Clone, Debug, Eq, PartialEq)] pub struct PostCommitIntersectionFlowResult { pub combined_recent_patch: ParsedPatch, @@ -2105,7 +2067,6 @@ pub struct PostCommitIntersectionFlowResult { pub tool_version: Option, } -/// Capture and parse the current commit patch. pub fn capture_post_commit_patch_from_git(repository_root: &Path) -> Result { let commit_oid = capture_head_oid_from_git(repository_root)?; let commit_time_ms = capture_head_timestamp_from_git(repository_root)?; @@ -2167,17 +2128,6 @@ fn post_commit_patch_error(detail: &str, context: &str) -> String { format!("Post-commit patch capture error: {detail} ({context}).") } -/// Transform a validated raw Claude `UserPromptSubmit` event payload into the two -/// normalized `serde_json::Value` items expected by `parse_conversation_trace_payloads`. -/// -/// Returns one `message` item and one `message.part` item sharing -/// the same generated `UUIDv7` `message_id` and the event's `session_id`. -/// -/// Supported events: -/// - `UserPromptSubmit`: produces two items (parent user message + text part). -/// -/// Any other `hook_event_name` value produces a validation error. -/// Missing or empty required fields (`session_id`, `prompt`) produce a validation error. fn transform_claude_user_prompt_submit( payload: &serde_json::Map, ) -> Result> { @@ -2194,7 +2144,6 @@ fn transform_claude_user_prompt_submit( ) } -/// Injectable counterpart of `transform_claude_user_prompt_submit` for deterministic testing. fn transform_claude_user_prompt_submit_with( payload: &serde_json::Map, generate_message_id: G, @@ -2247,18 +2196,6 @@ where ]) } -/// Transform a raw Claude `Stop` hook event into two normalized conversation-trace -/// payload items. -/// -/// Returns one `message` item and one `message.part` item sharing -/// the same generated `UUIDv7` `message_id` and the event's `session_id`. -/// -/// Supported events: -/// - `Stop`: produces two items (assistant parent message + text part). -/// -/// Any other `hook_event_name` value produces a validation error. -/// Missing or empty required fields (`session_id`, `last_assistant_message`) produce -/// a validation error. fn transform_claude_stop(payload: &serde_json::Map) -> Result> { transform_claude_stop_with( payload, @@ -2273,7 +2210,6 @@ fn transform_claude_stop(payload: &serde_json::Map) -> Result( payload: &serde_json::Map, generate_message_id: G, @@ -2342,7 +2278,6 @@ fn transform_claude_post_tool_use(payload: &serde_json::Map) -> R ) } -/// Injectable counterpart of `transform_claude_post_tool_use` for deterministic testing. fn transform_claude_post_tool_use_with( payload: &serde_json::Map, generate_message_id: G, @@ -2365,7 +2300,6 @@ where ))); } - // Silently skip PostToolUse events for non-Write/Edit tools let tool_name = payload .get("tool_name") .and_then(|v| v.as_str()) @@ -4357,6 +4291,287 @@ mod tests { } } + mod mutation_provenance_e2e { + use super::*; + use crate::services::agent_trace_db::{ClaudeModelStateObservation, ObservationKind}; + use crate::services::agent_trace_storage::{ + resolve_agent_trace_storage_at_state_root, AgentTraceStorageContext, + }; + use crate::services::hooks::claude_mutation_scope; + use crate::services::hooks::codex_mutation_scope; + use crate::services::mutation_trace::runtime::resolve_post_commit_mutation_ai_patch; + + fn git(repo: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .args(args) + .current_dir(repo) + .output() + .expect("git should spawn"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).expect("git output should be UTF-8") + } + + fn row_count(db: &RepositoryAgentTraceDb, table: &str) -> i64 { + db.query_map(&format!("SELECT COUNT(*) FROM {table}"), (), |row| { + row.get::(0).map_err(anyhow::Error::from) + }) + .expect("count query should succeed") + .into_iter() + .next() + .expect("count row should exist") + } + + struct ProvenanceE2eRepo { + _temp: tempfile::TempDir, + root: PathBuf, + state_root: PathBuf, + db_path: PathBuf, + } + + impl ProvenanceE2eRepo { + fn new(label: &str) -> Self { + let temp = tempfile::Builder::new() + .prefix(&format!("sce-mutation-provenance-e2e-{label}-")) + .tempdir() + .expect("temp dir should be created"); + let root = temp.path().join("repo"); + fs::create_dir_all(&root).expect("repo dir should be created"); + git(&root, &["init", "-q"]); + git(&root, &["config", "user.email", "test@example.invalid"]); + git(&root, &["config", "user.name", "SCE Test"]); + git( + &root, + &["remote", "add", "origin", "git@github.com:acme/widgets.git"], + ); + fs::write(root.join("file.txt"), "one\n").expect("seed file should write"); + git(&root, &["add", "-A"]); + git(&root, &["commit", "-qm", "base"]); + + let state_root = temp.path().join("state"); + fs::create_dir_all(&state_root).expect("state root should be created"); + let storage = resolve_agent_trace_storage_at_state_root( + &AgentTraceStorageContext { + repository_root: &root, + explicit_repository_id: None, + repository_remote: "origin", + }, + &state_root, + ) + .expect("state-root storage should initialize the repository DB"); + + Self { + _temp: temp, + root, + state_root, + db_path: storage.db_path, + } + } + + fn db(&self) -> RepositoryAgentTraceDb { + RepositoryAgentTraceDb::open_for_hooks_without_migrations_at(&self.db_path) + .expect("repository DB should reopen") + } + + fn cwd(&self) -> String { + self.root.to_string_lossy().into_owned() + } + + fn write_change(&self, content: &str) { + fs::write(self.root.join("file.txt"), content).expect("mutation should write"); + } + + fn commit_change(&self) { + git(&self.root, &["add", "-A"]); + git(&self.root, &["commit", "-qm", "AI mutation"]); + } + + fn run_post_commit(&self) -> Value { + let db = self.db(); + run_post_commit_subcommand_with( + &self.root, + Some(AgentTraceVcsType::Git), + "git@github.com:acme/widgets.git", + |root| { + run_post_commit_intersection_flow_with( + root, + capture_post_commit_patch_from_git, + current_unix_time_ms, + |cutoff_ms, end_ms| db.recent_diff_trace_patches(cutoff_ms, end_ms), + |insert| db.insert_post_commit_patch_intersection(insert).map(|_| ()), + ) + }, + |root, flow_result, vcs_type, remote_url| { + let direct_intersection = intersect_patches_fn( + &flow_result.combined_recent_patch, + &flow_result.post_commit_data.parsed_patch, + ); + let mutation_ai_patch = resolve_post_commit_mutation_ai_patch( + root, + &db, + &direct_intersection, + &flow_result.post_commit_data.parsed_patch, + ); + + run_post_commit_agent_trace_flow_with( + flow_result, + vcs_type, + remote_url, + &mutation_ai_patch, + |value| { + validate_agent_trace_value(value) + .map_err(|error| anyhow!(error.to_string())) + }, + |insert| db.insert_agent_trace(insert).map(|_| ()), + ) + }, + |_| Ok(false), + |_| Ok(()), + |_| db.passive_checkpoint(), + None, + ) + .expect("the real post-commit hook flow should persist Agent Trace"); + + db.query_map("SELECT trace_json FROM agent_traces", (), |row| { + row.get::(0).map_err(anyhow::Error::from) + }) + .expect("persisted Agent Trace should be readable") + .into_iter() + .next() + .map(|trace| serde_json::from_str(&trace).expect("trace JSON should parse")) + .expect("one Agent Trace row should exist") + } + } + + fn assert_mutation_trace_provenance(trace: &Value, model_id: &str, session_id: &str) { + assert_eq!(trace["files"][0]["path"], json!("file.txt")); + assert_eq!( + trace["files"][0]["conversations"][0]["contributor"], + json!({"type": "ai", "model_id": model_id}) + ); + assert_eq!( + trace["files"][0]["conversations"][0]["related"], + json!([{ + "type": "session", + "url": format!("https://sce.crocoder.dev/sessions/{session_id}"), + }]) + ); + assert_eq!( + trace["metadata"]["sce"]["line_changes"]["ai"]["added"], + json!(1) + ); + assert_eq!( + trace["metadata"]["sce"]["line_changes"]["unknown"]["added"], + json!(0) + ); + } + + #[test] + fn claude_bash_mutation_persists_model_and_session_in_agent_trace() { + let repo = ProvenanceE2eRepo::new("claude"); + let session_id = "claude-session-e2e"; + let db = repo.db(); + db.upsert_claude_model_state(ClaudeModelStateObservation { + session_id: format!("cc_{session_id}"), + agent_id: String::new(), + model_id: String::from("claude/opus-4-1"), + observation_kind: ObservationKind::SessionStart, + source: String::from("test"), + observed_at_ms: 1, + }) + .expect("Claude model state should be persisted"); + + let cwd = repo.cwd(); + let pre = json!({ + "hook_event_name": "PreToolUse", + "session_id": session_id, + "cwd": cwd, + "tool_name": "Bash", + "tool_use_id": "claude-bash-e2e", + "tool_input": {"command": "printf mutation"}, + }); + claude_mutation_scope::run_claude_mutation_scope_from_payload_at_state_root( + &repo.state_root, + &pre.to_string(), + None, + ) + .expect("Claude Bash PreToolUse should establish a scope"); + + let post = json!({ + "hook_event_name": "PostToolUse", + "session_id": session_id, + "cwd": repo.cwd(), + "tool_name": "Bash", + "tool_use_id": "claude-bash-e2e", + }); + repo.write_change("one\nclaude mutation\n"); + claude_mutation_scope::run_claude_mutation_scope_from_payload_at_state_root( + &repo.state_root, + &post.to_string(), + None, + ) + .expect("Claude Bash PostToolUse should close the scope"); + repo.commit_change(); + + let trace = repo.run_post_commit(); + assert_mutation_trace_provenance(&trace, "claude/opus-4-1", "cc_claude-session-e2e"); + assert_eq!(row_count(&repo.db(), "diff_traces"), 0); + assert_eq!(row_count(&repo.db(), "post_commit_patch_intersections"), 1); + assert_eq!(row_count(&repo.db(), "mutation_trace_events"), 1); + assert_eq!(row_count(&repo.db(), "agent_traces"), 1); + } + + #[test] + fn codex_bash_mutation_persists_model_and_session_in_agent_trace() { + let repo = ProvenanceE2eRepo::new("codex"); + let session_id = "codex-session-e2e"; + let cwd = repo.cwd(); + let pre = json!({ + "hook_event_name": "PreToolUse", + "session_id": session_id, + "turn_id": "codex-turn-e2e", + "cwd": cwd, + "tool_name": "Bash", + "tool_use_id": "codex-bash-e2e", + "model": "gpt-5.6-sol", + "tool_input": {"command": "true"}, + }); + codex_mutation_scope::run_codex_mutation_scope_from_payload_at_state_root( + &repo.state_root, + &pre.to_string(), + None, + ) + .expect("Codex Bash PreToolUse should establish a scope"); + + let post = json!({ + "hook_event_name": "PostToolUse", + "session_id": session_id, + "turn_id": "codex-turn-e2e", + "cwd": repo.cwd(), + "tool_name": "Bash", + "tool_use_id": "codex-bash-e2e", + }); + repo.write_change("one\ncodex mutation\n"); + codex_mutation_scope::run_codex_mutation_scope_from_payload_at_state_root( + &repo.state_root, + &post.to_string(), + None, + ) + .expect("Codex Bash PostToolUse should close the scope"); + repo.commit_change(); + + let trace = repo.run_post_commit(); + assert_mutation_trace_provenance(&trace, "gpt-5.6-sol", "cx_codex-session-e2e"); + assert_eq!(row_count(&repo.db(), "diff_traces"), 0); + assert_eq!(row_count(&repo.db(), "post_commit_patch_intersections"), 1); + assert_eq!(row_count(&repo.db(), "mutation_trace_events"), 1); + assert_eq!(row_count(&repo.db(), "agent_traces"), 1); + } + } + #[test] fn post_commit_auto_sync_does_not_launch_when_disabled() { let launch_called = RefCell::new(false); diff --git a/cli/src/services/hooks/mutation_scope.rs b/cli/src/services/hooks/mutation_scope.rs index 5a949f5d..617c8bae 100644 --- a/cli/src/services/hooks/mutation_scope.rs +++ b/cli/src/services/hooks/mutation_scope.rs @@ -6,7 +6,7 @@ use serde_json::{Map, Value}; use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; use crate::services::mutation_trace::runtime::{ abandon_scope, coordinate, AbandonScopeError, AbandonScopeOutcome, CoordinateError, - CoordinateOutcome, RuntimeBoundary, + CoordinateOutcome, RuntimeBoundary, StartProvenance, }; use crate::services::mutation_trace::types::{ActorKind, EventId, ScopeId}; use crate::services::observability::traits::Logger; @@ -18,6 +18,9 @@ const SCOPE_ID_FIELD: &str = "scope_id"; const EVENT_ID_FIELD: &str = "event_id"; const ACTOR_KIND_FIELD: &str = "actor_kind"; const WORKTREE_ID_FIELD: &str = "worktree_id"; +const PROVENANCE_FIELD: &str = "provenance"; +const SESSION_ID_FIELD: &str = "session_id"; +const MODEL_ID_FIELD: &str = "model_id"; const ACTOR_KIND_CLAUDE_CODE: &str = "claude_code"; const ACTOR_KIND_CODEX: &str = "codex"; @@ -30,6 +33,7 @@ pub(crate) enum MutationScopePayload { scope_id: String, event_id: String, actor_kind: ActorKind, + provenance: Option, }, Advance { scope_id: String, @@ -63,13 +67,7 @@ pub(crate) fn parse_mutation_scope_payload(stdin_payload: &str) -> Result parse_scope_boundary(object, |scope_id, event_id, actor_kind| { - MutationScopePayload::Start { - scope_id, - event_id, - actor_kind, - } - }), + "start" => parse_start(object), "advance" => parse_scope_boundary(object, |scope_id, event_id, actor_kind| { MutationScopePayload::Advance { scope_id, @@ -92,11 +90,31 @@ pub(crate) fn parse_mutation_scope_payload(stdin_payload: &str) -> Result) -> Result { + let (scope_id, event_id, actor_kind) = parse_scope_boundary_identity( + object, + &[ + OPERATION_FIELD, + SCOPE_ID_FIELD, + EVENT_ID_FIELD, + ACTOR_KIND_FIELD, + PROVENANCE_FIELD, + ], + )?; + + Ok(MutationScopePayload::Start { + scope_id, + event_id, + actor_kind, + provenance: parse_provenance(object)?, + }) +} + fn parse_scope_boundary( object: &Map, build: impl FnOnce(String, String, ActorKind) -> MutationScopePayload, ) -> Result { - reject_unexpected_keys( + let (scope_id, event_id, actor_kind) = parse_scope_boundary_identity( object, &[ OPERATION_FIELD, @@ -106,11 +124,43 @@ fn parse_scope_boundary( ], )?; + Ok(build(scope_id, event_id, actor_kind)) +} + +fn parse_scope_boundary_identity( + object: &Map, + allowed: &[&str], +) -> Result<(String, String, ActorKind)> { + reject_unexpected_keys(object, allowed)?; + let scope_id = required_non_blank_str(object, SCOPE_ID_FIELD)?; let event_id = required_non_blank_str(object, EVENT_ID_FIELD)?; let actor_kind = parse_actor_kind(&required_str(object, ACTOR_KIND_FIELD)?)?; - Ok(build(scope_id, event_id, actor_kind)) + Ok((scope_id, event_id, actor_kind)) +} + +fn parse_provenance(object: &Map) -> Result> { + let Some(value) = object.get(PROVENANCE_FIELD) else { + return Ok(None); + }; + + let provenance = value + .as_object() + .ok_or_else(|| anyhow!(validation_error("field 'provenance' must be a JSON object")))?; + + for key in provenance.keys() { + if key != SESSION_ID_FIELD && key != MODEL_ID_FIELD { + bail!(validation_error(&format!( + "unexpected field 'provenance.{key}'" + ))); + } + } + + Ok(Some(StartProvenance { + session_id: required_non_blank_str(provenance, SESSION_ID_FIELD)?, + model_id: optional_non_blank_str(provenance, MODEL_ID_FIELD)?, + })) } fn parse_flush(object: &Map) -> Result { @@ -179,6 +229,13 @@ fn required_non_blank_str(object: &Map, field: &str) -> Result, field: &str) -> Result> { + match object.get(field) { + None | Some(Value::Null) => Ok(None), + Some(_) => Ok(Some(required_non_blank_str(object, field)?)), + } +} + fn validation_error(detail: &str) -> String { format!("Invalid mutation-scope payload from STDIN: {detail}.") } @@ -261,10 +318,12 @@ where scope_id, event_id, actor_kind, + provenance, } => RuntimeBoundary::Start { scope: ScopeId(scope_id), event: EventId(event_id), actor_kind, + provenance, }, MutationScopePayload::Advance { scope_id, @@ -367,10 +426,132 @@ mod tests { scope_id: " scope-A ".to_string(), event_id: "e1".to_string(), actor_kind: ActorKind::ClaudeCode, + provenance: None, } ); } + #[test] + fn start_parses_provenance_with_a_model() { + let payload = parse( + r#"{"operation":"start","scope_id":"A","event_id":"e1","actor_kind":"codex","provenance":{"session_id":"cx_session-1","model_id":"gpt-5-codex"}}"#, + ) + .expect("valid start payload with provenance"); + + assert_eq!( + payload, + MutationScopePayload::Start { + scope_id: "A".to_string(), + event_id: "e1".to_string(), + actor_kind: ActorKind::Codex, + provenance: Some(StartProvenance { + session_id: "cx_session-1".to_string(), + model_id: Some("gpt-5-codex".to_string()), + }), + } + ); + } + + #[test] + fn start_parses_provenance_without_a_model() { + for payload in [ + r#"{"operation":"start","scope_id":"A","event_id":"e1","actor_kind":"claude_code","provenance":{"session_id":"cc_session-1"}}"#, + r#"{"operation":"start","scope_id":"A","event_id":"e1","actor_kind":"claude_code","provenance":{"session_id":"cc_session-1","model_id":null}}"#, + ] { + assert_eq!( + parse(payload).expect("valid start payload with a model-less provenance"), + MutationScopePayload::Start { + scope_id: "A".to_string(), + event_id: "e1".to_string(), + actor_kind: ActorKind::ClaudeCode, + provenance: Some(StartProvenance { + session_id: "cc_session-1".to_string(), + model_id: None, + }), + } + ); + } + } + + #[test] + fn provenance_is_rejected_on_every_operation_other_than_start() { + for payload in [ + r#"{"operation":"advance","scope_id":"A","event_id":"e2","actor_kind":"codex","provenance":{"session_id":"cx_session-1"}}"#, + r#"{"operation":"close","scope_id":"A","event_id":"e3","actor_kind":"codex","provenance":{"session_id":"cx_session-1"}}"#, + r#"{"operation":"flush","provenance":{"session_id":"cx_session-1"}}"#, + r#"{"operation":"abandon","scope_id":"A","provenance":{"session_id":"cx_session-1"}}"#, + ] { + let error = error_of(payload); + assert_eq!( + error, "Invalid mutation-scope payload from STDIN: unexpected field 'provenance'.", + "unexpected error for {payload}" + ); + } + } + + #[test] + fn provenance_without_a_session_id_is_rejected() { + assert!(parse( + r#"{"operation":"start","scope_id":"A","event_id":"e1","actor_kind":"codex","provenance":{"model_id":"gpt-5-codex"}}"# + ) + .is_err()); + } + + #[test] + fn blank_or_non_string_provenance_session_id_is_rejected() { + for payload in [ + r#"{"operation":"start","scope_id":"A","event_id":"e1","actor_kind":"codex","provenance":{"session_id":""}}"#, + r#"{"operation":"start","scope_id":"A","event_id":"e1","actor_kind":"codex","provenance":{"session_id":" "}}"#, + r#"{"operation":"start","scope_id":"A","event_id":"e1","actor_kind":"codex","provenance":{"session_id":null}}"#, + r#"{"operation":"start","scope_id":"A","event_id":"e1","actor_kind":"codex","provenance":{"session_id":7}}"#, + ] { + let error = error_of(payload); + assert!( + error.contains("'session_id'"), + "unexpected error for {payload}: {error}" + ); + } + } + + #[test] + fn blank_or_non_string_provenance_model_id_is_rejected() { + for payload in [ + r#"{"operation":"start","scope_id":"A","event_id":"e1","actor_kind":"codex","provenance":{"session_id":"cx_session-1","model_id":""}}"#, + r#"{"operation":"start","scope_id":"A","event_id":"e1","actor_kind":"codex","provenance":{"session_id":"cx_session-1","model_id":" "}}"#, + r#"{"operation":"start","scope_id":"A","event_id":"e1","actor_kind":"codex","provenance":{"session_id":"cx_session-1","model_id":42}}"#, + ] { + let error = error_of(payload); + assert!( + error.contains("'model_id'"), + "unexpected error for {payload}: {error}" + ); + } + } + + #[test] + fn non_object_provenance_is_rejected() { + for provenance in ["\"cx_session-1\"", "[]", "5", "true", "null"] { + let error = error_of(&format!( + r#"{{"operation":"start","scope_id":"A","event_id":"e1","actor_kind":"codex","provenance":{provenance}}}"# + )); + assert!( + error.contains("field 'provenance' must be a JSON object"), + "unexpected error for provenance {provenance}: {error}" + ); + } + } + + #[test] + fn unexpected_provenance_key_is_rejected() { + let error = error_of( + r#"{"operation":"start","scope_id":"A","event_id":"e1","actor_kind":"codex","provenance":{"session_id":"cx_session-1","agent_id":"sub"}}"#, + ); + assert_eq!( + error, + "Invalid mutation-scope payload from STDIN: unexpected field 'provenance.agent_id'." + ); + } + #[test] fn advance_and_close_parse_to_their_variants() { assert_eq!( @@ -614,6 +795,10 @@ mod tests { scope_id: "A".to_string(), event_id: "e1".to_string(), actor_kind: ActorKind::ClaudeCode, + provenance: Some(StartProvenance { + session_id: "cc_session-1".to_string(), + model_id: Some("claude/opus".to_string()), + }), }, None, |_root, boundary| { @@ -622,10 +807,18 @@ mod tests { scope, event, actor_kind, + provenance, } => { assert_eq!(scope.0, "A"); assert_eq!(event.0, "e1"); assert_eq!(*actor_kind, ActorKind::ClaudeCode); + assert_eq!( + *provenance, + Some(StartProvenance { + session_id: "cc_session-1".to_string(), + model_id: Some("claude/opus".to_string()), + }) + ); } other => panic!("expected RuntimeBoundary::Start, got {other:?}"), } @@ -958,6 +1151,25 @@ mod tests { .next() } + fn scope_provenance( + db: &RepositoryAgentTraceDb, + scope_id: &str, + ) -> Option<(String, Option)> { + db.query_map( + "SELECT session_id, model_id FROM mutation_trace_scope_provenance \ + WHERE scope_id = ?1", + (scope_id,), + |row| { + let session_id = row.get::(0).map_err(anyhow::Error::from)?; + let model_id = row.get::>(1).map_err(anyhow::Error::from)?; + Ok((session_id, model_id)) + }, + ) + .expect("scope-provenance query should succeed") + .into_iter() + .next() + } + fn mutation_events(db: &RepositoryAgentTraceDb) -> Vec<(String, Option, String)> { db.query_map( "SELECT attribution_kind, attribution_scope_id, boundary_kind \ @@ -980,6 +1192,7 @@ mod tests { r#"{"operation":"advance","scope_id":"A","event_id":"e2","actor_kind":"claude_code"}"#; const CLOSE_A_E3: &str = r#"{"operation":"close","scope_id":"A","event_id":"e3","actor_kind":"claude_code"}"#; + const START_A_E1_WITH_PROVENANCE: &str = r#"{"operation":"start","scope_id":"A","event_id":"e1","actor_kind":"claude_code","provenance":{"session_id":"cc_session-1","model_id":"claude/opus"}}"#; const FLUSH: &str = r#"{"operation":"flush"}"#; const ABANDON_A: &str = r#"{"operation":"abandon","scope_id":"A"}"#; @@ -1296,5 +1509,209 @@ mod tests { assert_raw_agent_trace_tables_untouched(&db); } + + #[test] + fn test8_start_with_provenance_registers_it_before_the_protocol_start_commits() { + let repo = IngressRepo::new("provenance-start"); + + assert_eq!( + repo.drive(START_A_E1_WITH_PROVENANCE) + .expect("a start carrying provenance should succeed"), + "" + ); + + let db = repo.db(); + assert_eq!( + scope_status(&db, "A"), + Some(("claude_code".to_string(), "active".to_string())), + "the owning scope row must exist before provenance is registered" + ); + assert_eq!( + scope_provenance(&db, "A"), + Some(("cc_session-1".to_string(), Some("claude/opus".to_string()))) + ); + assert_eq!( + processed_events(&db), + vec![("A".to_string(), "e1".to_string())], + "the pure protocol Start must commit after provenance is durably registered" + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test9_start_without_provenance_persists_no_provenance_row() { + let repo = IngressRepo::new("provenance-absent"); + + repo.drive(START_A_E1) + .expect("a start without provenance should succeed"); + + let db = repo.db(); + assert_eq!( + scope_status(&db, "A").map(|(_, status)| status), + Some("active".to_string()) + ); + assert_eq!(scope_provenance(&db, "A"), None); + assert_eq!(count(&db, "mutation_trace_scope_provenance"), 0); + assert_eq!( + processed_events(&db), + vec![("A".to_string(), "e1".to_string())] + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test10_replayed_start_provenance_is_idempotent_and_keeps_the_first_model() { + let repo = IngressRepo::new("provenance-replay"); + + repo.drive(START_A_E1_WITH_PROVENANCE) + .expect("the first start should succeed"); + let revision_after_start = { + let db = repo.db(); + worktree_revision(&db) + }; + + assert_eq!( + repo.drive(START_A_E1_WITH_PROVENANCE) + .expect("an identical replay should succeed"), + "" + ); + assert_eq!( + repo.drive( + r#"{"operation":"start","scope_id":"A","event_id":"e1","actor_kind":"claude_code","provenance":{"session_id":"cc_session-1"}}"# + ) + .expect("a replay that discovered no model should succeed"), + "" + ); + assert_eq!( + repo.drive( + r#"{"operation":"start","scope_id":"A","event_id":"e1","actor_kind":"claude_code","provenance":{"session_id":"cc_session-1","model_id":"claude/sonnet"}}"# + ) + .expect("a replay whose model disagrees should still succeed"), + "" + ); + + let db = repo.db(); + assert_eq!(count(&db, "mutation_trace_scope_provenance"), 1); + assert_eq!( + scope_provenance(&db, "A"), + Some(("cc_session-1".to_string(), Some("claude/opus".to_string()))), + "model_id is immutable first-observed metadata" + ); + assert_eq!(worktree_revision(&db), revision_after_start); + assert_eq!( + processed_events(&db), + vec![("A".to_string(), "e1".to_string())] + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test11_conflicting_provenance_session_fails_the_start_without_rewriting_the_row() { + let repo = IngressRepo::new("provenance-session-conflict"); + + repo.drive(START_A_E1_WITH_PROVENANCE) + .expect("the first start should succeed"); + + let (revision_before, processed_before, provenance_before) = { + let db = repo.db(); + ( + worktree_revision(&db), + processed_events(&db), + scope_provenance(&db, "A"), + ) + }; + + let error = repo + .drive( + r#"{"operation":"start","scope_id":"A","event_id":"e9","actor_kind":"claude_code","provenance":{"session_id":"cc_session-2","model_id":"claude/opus"}}"#, + ) + .expect_err("a conflicting provenance session must fail the start"); + + let rendered = format!("{error:#}"); + assert!( + rendered.contains("already has provenance for session"), + "the ingress error must carry the provenance session conflict diagnostic, \ + got: {rendered}" + ); + + let db = repo.db(); + assert_eq!(scope_provenance(&db, "A"), provenance_before); + assert_eq!(count(&db, "mutation_trace_scope_provenance"), 1); + assert_eq!(worktree_revision(&db), revision_before); + assert_eq!(processed_events(&db), processed_before); + assert!(!processed_events(&db) + .into_iter() + .any(|(scope_id, event_id)| scope_id == "A" && event_id == "e9")); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test13_a_start_admitted_without_provenance_is_never_backfilled_by_a_replay() { + let repo = IngressRepo::new("provenance-no-late-backfill"); + + repo.drive(START_A_E1) + .expect("a start without provenance should succeed"); + + let (revision_before, processed_before) = { + let db = repo.db(); + assert_eq!( + scope_status(&db, "A").map(|(_, status)| status), + Some("active".to_string()) + ); + assert_eq!(scope_provenance(&db, "A"), None); + (worktree_revision(&db), processed_events(&db)) + }; + + assert_eq!( + repo.drive(START_A_E1_WITH_PROVENANCE) + .expect("replaying the start with provenance should follow replay semantics"), + "" + ); + + let db = repo.db(); + assert_eq!( + scope_provenance(&db, "A"), + None, + "provenance may only be created while the scope is never_seen" + ); + assert_eq!(count(&db, "mutation_trace_scope_provenance"), 0); + assert_eq!( + scope_status(&db, "A").map(|(_, status)| status), + Some("active".to_string()) + ); + assert_eq!(worktree_revision(&db), revision_before); + assert_eq!(processed_events(&db), processed_before); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test12_no_provenance_row_ever_exists_without_its_owning_scope() { + let repo = IngressRepo::new("provenance-owning-scope"); + + repo.drive(START_A_E1_WITH_PROVENANCE) + .expect("the start should succeed"); + + let db = repo.db(); + let orphans = db + .query_map( + "SELECT COUNT(*) FROM mutation_trace_scope_provenance p \ + LEFT JOIN mutation_trace_scopes s ON s.scope_id = p.scope_id \ + WHERE s.scope_id IS NULL", + (), + |row| row.get::(0).map_err(anyhow::Error::from), + ) + .expect("orphan query should succeed") + .into_iter() + .next() + .expect("a count row should exist"); + + assert_eq!(orphans, 0); + assert_eq!(count(&db, "mutation_trace_scope_provenance"), 1); + } } } diff --git a/cli/src/services/mutation_trace/attribution.rs b/cli/src/services/mutation_trace/attribution.rs index 6e00ae85..d7039bed 100644 --- a/cli/src/services/mutation_trace/attribution.rs +++ b/cli/src/services/mutation_trace/attribution.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeSet, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashSet}; use crate::services::patch::{ ParsedPatch, PatchFileChange, PatchHunk, TouchedLine, TouchedLineKind, @@ -20,6 +20,12 @@ pub struct PatchLineLocation { pub line_index: usize, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PatchLineProvenance { + pub session_id: Option, + pub model_id: Option, +} + #[must_use] pub fn exclude_direct_coverage( target_patch: &ParsedPatch, @@ -137,6 +143,73 @@ pub fn patch_for_locations( ParsedPatch { files } } +pub fn patch_for_locations_with_provenance( + patch: &ParsedPatch, + selected: &BTreeMap, +) -> ParsedPatch { + let files = patch + .files + .iter() + .enumerate() + .filter_map(|(file_index, file)| { + let hunks = file + .hunks + .iter() + .enumerate() + .filter_map(|(hunk_index, hunk)| { + let lines = hunk + .lines + .iter() + .enumerate() + .filter_map(|(line_index, line)| { + let location = PatchLineLocation { + file_index, + hunk_index, + line_index, + }; + let provenance = selected.get(&location)?; + let mut line = line.clone(); + line.session_id.clone_from(&provenance.session_id); + Some(line) + }) + .collect::>(); + (!lines.is_empty()).then(|| PatchHunk { + model_id: agreed_model(selected, file_index, hunk_index), + lines, + ..hunk.clone() + }) + }) + .collect::>(); + (!hunks.is_empty()).then(|| PatchFileChange { + hunks, + ..file.clone() + }) + }) + .collect(); + + ParsedPatch { files } +} + +fn agreed_model( + selected: &BTreeMap, + file_index: usize, + hunk_index: usize, +) -> Option { + let mut model: Option<&str> = None; + for (location, provenance) in selected { + if location.file_index != file_index || location.hunk_index != hunk_index { + continue; + } + let candidate = provenance.model_id.as_deref()?; + match model { + None => model = Some(candidate), + Some(existing) if existing == candidate => {} + Some(_) => return None, + } + } + model.map(ToOwned::to_owned) +} + #[cfg(test)] mod tests { use super::*; diff --git a/cli/src/services/mutation_trace/runtime/coordinator.rs b/cli/src/services/mutation_trace/runtime/coordinator.rs index 7e6816cd..e2ddd9bc 100644 --- a/cli/src/services/mutation_trace/runtime/coordinator.rs +++ b/cli/src/services/mutation_trace/runtime/coordinator.rs @@ -5,9 +5,12 @@ use uuid::Uuid; use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; use crate::services::mutation_trace::protocol; -use crate::services::mutation_trace::store::{CasResult, DurableTransition, MutationTraceStore}; +use crate::services::mutation_trace::store::{ + CasResult, DurableTransition, MutationTraceStore, ScopeProvenance, +}; use crate::services::mutation_trace::types::{ - self, ActorKind, AttemptId, Boundary, EventId, MutationEvent, ScopeId, TreeId, WorktreeId, + self, ActorKind, AttemptId, Boundary, EventId, MutationEvent, ScopeId, ScopeStatus, TreeId, + WorktreeId, }; use super::git_snapshot::GitSnapshotService; @@ -17,12 +20,19 @@ pub use super::protected_worktree::ExternalTaintOperation; pub(super) const MAX_CAS_RETRY_ATTEMPTS: u32 = 5; +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StartProvenance { + pub session_id: String, + pub model_id: Option, +} + #[derive(Clone, Debug)] pub enum RuntimeBoundary { Start { scope: ScopeId, event: EventId, actor_kind: ActorKind, + provenance: Option, }, Advance { scope: ScopeId, @@ -53,6 +63,7 @@ pub enum CoordinateError { source: anyhow::Error, }, ScopeIdentityConflict(anyhow::Error), + ScopeProvenanceRegistration(anyhow::Error), CasConflictExhausted { attempts: u32, }, @@ -123,6 +134,7 @@ impl std::fmt::Display for CoordinateError { write!(f, "Repository Agent Trace DB is unavailable: {source}") } CoordinateError::ScopeIdentityConflict(source) + | CoordinateError::ScopeProvenanceRegistration(source) | CoordinateError::LockAcquisition(source) | CoordinateError::Other(source) => write!(f, "{source}"), } @@ -289,11 +301,16 @@ where .initialize_worktree(worktree_id, &observed_tree) .map_err(CoordinateError::Other)?; - if let Some((scope, actor_kind)) = hook_identity(boundary) { - store - .register_scope(scope, worktree_id, actor_kind) - .map_err(CoordinateError::ScopeIdentityConflict)?; - } + let registered_scope = match hook_identity(boundary) { + Some((scope, actor_kind)) => Some( + store + .register_scope(scope, worktree_id, actor_kind) + .map_err(CoordinateError::ScopeIdentityConflict)?, + ), + None => None, + }; + + register_start_provenance(&store, boundary, registered_scope.as_ref())?; let type_boundary = into_protocol_boundary(boundary, worktree_id); let scope_ref = types::boundary_scope(&type_boundary); @@ -372,6 +389,47 @@ where }) } +/// Applies the admission-bounded provenance rule for a `Start` carrying +/// provenance, using the durable [`types::ScopeState`] that `register_scope` +/// just returned. +/// +/// An existing provenance row is always re-registered, so the store's immutable +/// session identity and first-observed model semantics stay authoritative on +/// every replay. A row may only be *created* while the durable scope is still +/// `NeverSeen` — a retry whose earlier attempt never committed the protocol +/// `Start`. Once the scope has crossed protocol admission, absent provenance +/// stays absent permanently: provenance describes its scope as observed at +/// admission, so it is never attached retroactively. +fn register_start_provenance( + store: &MutationTraceStore<'_>, + boundary: &RuntimeBoundary, + registered_scope: Option<&types::ScopeState>, +) -> Result<(), CoordinateError> { + let Some((scope, provenance)) = start_provenance(boundary) else { + return Ok(()); + }; + + let stored = store + .load_scope_provenance(scope) + .map_err(CoordinateError::ScopeProvenanceRegistration)?; + let before_admission = + registered_scope.is_some_and(|scope_state| scope_state.status == ScopeStatus::NeverSeen); + + if stored.is_none() && !before_admission { + return Ok(()); + } + + store + .register_scope_provenance(&ScopeProvenance { + scope_id: scope.clone(), + session_id: provenance.session_id.clone(), + model_id: provenance.model_id.clone(), + }) + .map_err(CoordinateError::ScopeProvenanceRegistration)?; + + Ok(()) +} + fn needs_recovery(state: &types::ProtocolState, worktree_id: &WorktreeId) -> bool { state.external_taint.contains(worktree_id) || state @@ -422,6 +480,17 @@ fn into_protocol_boundary(boundary: &RuntimeBoundary, worktree_id: &WorktreeId) } } +fn start_provenance(boundary: &RuntimeBoundary) -> Option<(&ScopeId, &StartProvenance)> { + match boundary { + RuntimeBoundary::Start { + scope, + provenance: Some(provenance), + .. + } => Some((scope, provenance)), + _ => None, + } +} + fn hook_identity(boundary: &RuntimeBoundary) -> Option<(&ScopeId, ActorKind)> { match boundary { RuntimeBoundary::Start { @@ -757,6 +826,7 @@ mod tests { scope: scope.clone(), event: EventId("evt-start".to_string()), actor_kind: ActorKind::ClaudeCode, + provenance: None, }, false, ) @@ -801,6 +871,7 @@ mod tests { scope: scope.clone(), event: EventId("evt-start".to_string()), actor_kind: ActorKind::ClaudeCode, + provenance: None, }, false, ) @@ -848,6 +919,7 @@ mod tests { scope: scope.clone(), event: EventId("evt-start".to_string()), actor_kind: ActorKind::ClaudeCode, + provenance: None, }, false, ) @@ -902,6 +974,7 @@ mod tests { scope: ScopeId("scope-a".to_string()), event: EventId("evt-start-a".to_string()), actor_kind: actor_a, + provenance: None, }, false, ) @@ -914,6 +987,7 @@ mod tests { scope: ScopeId("scope-b".to_string()), event: EventId("evt-start-b".to_string()), actor_kind: actor_b, + provenance: None, }, false, ) @@ -971,6 +1045,7 @@ mod tests { scope: codex.clone(), event: EventId("evt-codex-start".to_string()), actor_kind: ActorKind::Codex, + provenance: None, }, false, ) @@ -983,6 +1058,7 @@ mod tests { scope: claude.clone(), event: EventId("evt-claude-start".to_string()), actor_kind: ActorKind::ClaudeCode, + provenance: None, }, false, ) @@ -1054,6 +1130,7 @@ mod tests { scope: codex.clone(), event: EventId("evt-codex-start".to_string()), actor_kind: ActorKind::Codex, + provenance: None, }, false, ) @@ -1066,6 +1143,7 @@ mod tests { scope: claude.clone(), event: EventId("evt-claude-start".to_string()), actor_kind: ActorKind::ClaudeCode, + provenance: None, }, false, ) @@ -1179,6 +1257,7 @@ mod tests { scope: live_scope.clone(), event: EventId("evt-start-live".to_string()), actor_kind: ActorKind::ClaudeCode, + provenance: None, }, false, ) @@ -1191,6 +1270,7 @@ mod tests { scope: abandoned_scope.clone(), event: EventId("evt-start-abandoned".to_string()), actor_kind: ActorKind::ClaudeCode, + provenance: None, }, false, ) @@ -1274,6 +1354,7 @@ mod tests { scope: live_scope.clone(), event: EventId("evt-start".to_string()), actor_kind: ActorKind::ClaudeCode, + provenance: None, }, false, ) @@ -1363,6 +1444,7 @@ mod tests { scope: scope.clone(), event: event.clone(), actor_kind: ActorKind::ClaudeCode, + provenance: None, }, false, ) @@ -1405,6 +1487,360 @@ mod tests { remove_test_db(&db_path); } + #[test] + fn start_registers_provenance_after_its_owning_scope_and_before_the_protocol_commits() { + let (db, db_path) = test_db("provenance-start-ordering"); + let worktree = WorktreeId("wt-1".to_string()); + let scope = ScopeId("scope-1".to_string()); + let capture = FakeSnapshotCapture::new(TreeId("tree-a".to_string())); + + coordinate_boundary( + &db, + &capture, + &worktree, + &RuntimeBoundary::Start { + scope: scope.clone(), + event: EventId("evt-start".to_string()), + actor_kind: ActorKind::Codex, + provenance: Some(StartProvenance { + session_id: "cx_session-1".to_string(), + model_id: Some("gpt-5-codex".to_string()), + }), + }, + false, + ) + .expect("a start carrying provenance for a first-seen scope should succeed"); + + let store = MutationTraceStore::new(&db); + assert_eq!( + store + .load_scope_provenance(&scope) + .expect("provenance load should succeed"), + Some(ScopeProvenance { + scope_id: scope.clone(), + session_id: "cx_session-1".to_string(), + model_id: Some("gpt-5-codex".to_string()), + }), + "provenance is registered against the scope row the same Start just created" + ); + + let projection = store + .load_worktree(&worktree, Some(&scope), None) + .expect("load should succeed") + .expect("worktree should exist"); + assert_eq!( + projection.scopes.get(&scope).map(|state| state.status), + Some(ScopeStatus::Active), + "the pure protocol Start must have committed after provenance was registered" + ); + + remove_test_db(&db_path); + } + + #[test] + fn start_without_provenance_then_replay_with_provenance_does_not_backfill() { + let (db, db_path) = test_db("provenance-no-late-backfill"); + let worktree = WorktreeId("wt-1".to_string()); + let scope = ScopeId("scope-1".to_string()); + let event = EventId("evt-start".to_string()); + let capture = FakeSnapshotCapture::new(TreeId("tree-a".to_string())); + + coordinate_boundary( + &db, + &capture, + &worktree, + &RuntimeBoundary::Start { + scope: scope.clone(), + event: event.clone(), + actor_kind: ActorKind::ClaudeCode, + provenance: None, + }, + false, + ) + .expect("a start without provenance should succeed"); + + let store = MutationTraceStore::new(&db); + let admitted = store + .load_worktree(&worktree, Some(&scope), None) + .expect("load should succeed") + .expect("worktree should exist"); + assert_eq!( + admitted.scopes.get(&scope).map(|state| state.status), + Some(ScopeStatus::Active), + "the provenance-free start must have admitted the scope" + ); + assert_eq!( + store + .load_scope_provenance(&scope) + .expect("provenance load should succeed"), + None, + "a start without provenance persists no row" + ); + + coordinate_boundary( + &db, + &capture, + &worktree, + &RuntimeBoundary::Start { + scope: scope.clone(), + event: event.clone(), + actor_kind: ActorKind::ClaudeCode, + provenance: Some(StartProvenance { + session_id: "cc_session-1".to_string(), + model_id: Some("claude/opus".to_string()), + }), + }, + false, + ) + .expect("replaying the start with provenance follows normal replay semantics"); + + assert_eq!( + store + .load_scope_provenance(&scope) + .expect("provenance load should succeed"), + None, + "provenance is admission-bounded: an already-admitted scope never gains it later" + ); + + let replayed = store + .load_worktree(&worktree, Some(&scope), None) + .expect("load should succeed") + .expect("worktree should exist"); + assert_eq!( + replayed.worktree_state.revision, admitted.worktree_state.revision, + "a replay must not advance the worktree revision" + ); + assert_eq!( + replayed.processed_events, admitted.processed_events, + "a replay must not change the processed-event set" + ); + assert_eq!( + replayed.scopes.get(&scope).map(|state| state.status), + Some(ScopeStatus::Active) + ); + + remove_test_db(&db_path); + } + + #[test] + fn never_seen_scope_can_receive_provenance_before_successful_start() { + let (db, db_path) = test_db("provenance-retry-before-admission"); + let worktree = WorktreeId("wt-1".to_string()); + let scope = ScopeId("scope-1".to_string()); + let capture = FakeSnapshotCapture::new(TreeId("tree-a".to_string())); + + let store = MutationTraceStore::new(&db); + store + .initialize_worktree(&worktree, &TreeId("tree-a".to_string())) + .expect("worktree initialization should succeed"); + let seeded = store + .register_scope(&scope, &worktree, ActorKind::ClaudeCode) + .expect("seeding the owning scope row should succeed"); + assert_eq!( + seeded.status, + ScopeStatus::NeverSeen, + "the seeded scope stands in for a first attempt that never reached the protocol" + ); + assert_eq!( + store + .load_scope_provenance(&scope) + .expect("provenance load should succeed"), + None + ); + + coordinate_boundary( + &db, + &capture, + &worktree, + &RuntimeBoundary::Start { + scope: scope.clone(), + event: EventId("evt-start".to_string()), + actor_kind: ActorKind::ClaudeCode, + provenance: Some(StartProvenance { + session_id: "cc_session-1".to_string(), + model_id: Some("claude/opus".to_string()), + }), + }, + false, + ) + .expect("retrying a start against a still-NeverSeen scope should succeed"); + + assert_eq!( + store + .load_scope_provenance(&scope) + .expect("provenance load should succeed"), + Some(ScopeProvenance { + scope_id: scope.clone(), + session_id: "cc_session-1".to_string(), + model_id: Some("claude/opus".to_string()), + }), + "a scope that has not crossed admission may still gain provenance" + ); + + let projection = store + .load_worktree(&worktree, Some(&scope), None) + .expect("load should succeed") + .expect("worktree should exist"); + assert_eq!( + projection.scopes.get(&scope).map(|state| state.status), + Some(ScopeStatus::Active), + "the retried start must commit the protocol transition" + ); + + remove_test_db(&db_path); + } + + #[test] + fn an_admitted_scope_with_provenance_still_rejects_a_different_session() { + let (db, db_path) = test_db("provenance-admitted-session-conflict"); + let worktree = WorktreeId("wt-1".to_string()); + let scope = ScopeId("scope-1".to_string()); + let event = EventId("evt-start".to_string()); + let capture = FakeSnapshotCapture::new(TreeId("tree-a".to_string())); + + coordinate_boundary( + &db, + &capture, + &worktree, + &RuntimeBoundary::Start { + scope: scope.clone(), + event: event.clone(), + actor_kind: ActorKind::ClaudeCode, + provenance: Some(StartProvenance { + session_id: "cc_session-1".to_string(), + model_id: Some("claude/opus".to_string()), + }), + }, + false, + ) + .expect("the admitting start should succeed"); + + let store = MutationTraceStore::new(&db); + let admitted = store + .load_worktree(&worktree, Some(&scope), None) + .expect("load should succeed") + .expect("worktree should exist"); + assert_eq!( + admitted.scopes.get(&scope).map(|state| state.status), + Some(ScopeStatus::Active) + ); + let stored = store + .load_scope_provenance(&scope) + .expect("provenance load should succeed"); + + let failure = coordinate_boundary( + &db, + &capture, + &worktree, + &RuntimeBoundary::Start { + scope: scope.clone(), + event, + actor_kind: ActorKind::ClaudeCode, + provenance: Some(StartProvenance { + session_id: "cc_session-2".to_string(), + model_id: Some("claude/opus".to_string()), + }), + }, + false, + ) + .expect_err("an existing provenance row is checked even after admission"); + + assert!( + matches!(failure, CoordinateError::ScopeProvenanceRegistration(_)), + "unexpected error: {failure:?}" + ); + + assert_eq!( + store + .load_scope_provenance(&scope) + .expect("provenance load should succeed"), + stored, + "a rejected replay must never rewrite the stored provenance row" + ); + + let after = store + .load_worktree(&worktree, Some(&scope), None) + .expect("load should succeed") + .expect("worktree should exist"); + assert_eq!( + after.worktree_state.revision, + admitted.worktree_state.revision + ); + assert_eq!(after.processed_events, admitted.processed_events); + + remove_test_db(&db_path); + } + + #[test] + fn a_provenance_registration_failure_rejects_the_start_before_the_protocol_commits() { + let (db, db_path) = test_db("provenance-registration-failure"); + let worktree = WorktreeId("wt-1".to_string()); + let scope = ScopeId("scope-1".to_string()); + let capture = FakeSnapshotCapture::new(TreeId("tree-a".to_string())); + + let store = MutationTraceStore::new(&db); + store + .initialize_worktree(&worktree, &TreeId("tree-a".to_string())) + .expect("worktree initialization should succeed"); + store + .register_scope(&scope, &worktree, ActorKind::ClaudeCode) + .expect("seeding the owning scope row should succeed"); + let seeded = store + .register_scope_provenance(&ScopeProvenance { + scope_id: scope.clone(), + session_id: "cc_session-1".to_string(), + model_id: Some("claude/opus".to_string()), + }) + .expect("seeding provenance should succeed"); + + let failure = coordinate_boundary( + &db, + &capture, + &worktree, + &RuntimeBoundary::Start { + scope: scope.clone(), + event: EventId("evt-start".to_string()), + actor_kind: ActorKind::ClaudeCode, + provenance: Some(StartProvenance { + session_id: "cc_session-2".to_string(), + model_id: Some("claude/opus".to_string()), + }), + }, + false, + ) + .expect_err("a conflicting provenance session must reject the start"); + + assert!( + matches!(failure, CoordinateError::ScopeProvenanceRegistration(_)), + "unexpected error: {failure:?}" + ); + + assert_eq!( + store + .load_scope_provenance(&scope) + .expect("provenance load should succeed"), + Some(seeded), + "a rejected start must never rewrite the stored provenance row" + ); + + let projection = store + .load_worktree(&worktree, Some(&scope), None) + .expect("load should succeed") + .expect("worktree should exist"); + assert_eq!( + projection.scopes.get(&scope).map(|state| state.status), + Some(ScopeStatus::NeverSeen), + "a failed provenance registration leaves at most the owning NeverSeen scope row" + ); + assert!( + projection.processed_events.is_empty(), + "the rejected start's event must never have been recorded as processed" + ); + assert_eq!(projection.worktree_state.revision, 0); + + remove_test_db(&db_path); + } + #[test] fn mandatory_recovery_that_cannot_advance_revision_rejects_the_triggering_boundary() { assert_recovery_at_revision_exhaustion_is_rejected( @@ -1920,6 +2356,7 @@ mod tests { scope: scope.clone(), event: EventId("evt-start".to_string()), actor_kind: ActorKind::ClaudeCode, + provenance: None, }, false, ) @@ -2024,6 +2461,7 @@ mod tests { scope: scope.clone(), event: EventId("evt-start".to_string()), actor_kind: ActorKind::ClaudeCode, + provenance: None, }, false, ) @@ -2107,6 +2545,7 @@ mod tests { scope: scope.clone(), event: EventId("evt-start".to_string()), actor_kind: ActorKind::ClaudeCode, + provenance: None, }, false, ) @@ -2182,6 +2621,7 @@ mod tests { scope: scope.clone(), event: EventId("evt-start".to_string()), actor_kind: ActorKind::ClaudeCode, + provenance: None, }, ok_db, ) diff --git a/cli/src/services/mutation_trace/runtime/mod.rs b/cli/src/services/mutation_trace/runtime/mod.rs index 6022a18e..31d91712 100644 --- a/cli/src/services/mutation_trace/runtime/mod.rs +++ b/cli/src/services/mutation_trace/runtime/mod.rs @@ -13,6 +13,7 @@ mod tests; #[allow(unused_imports)] pub(crate) use coordinator::{ coordinate, CoordinateError, CoordinateOutcome, ExternalTaintOperation, RuntimeBoundary, + StartProvenance, }; #[allow(unused_imports)] pub(crate) use mutation_attribution::{ diff --git a/cli/src/services/mutation_trace/runtime/mutation_attribution.rs b/cli/src/services/mutation_trace/runtime/mutation_attribution.rs index 548d9700..04e6ab80 100644 --- a/cli/src/services/mutation_trace/runtime/mutation_attribution.rs +++ b/cli/src/services/mutation_trace/runtime/mutation_attribution.rs @@ -1,20 +1,22 @@ use anyhow::Result; -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; use std::time::Duration; use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; use crate::services::checkout::{read_checkout_id, resolve_git_dir}; use crate::services::mutation_trace::attribution::{ - exclude_direct_coverage, logical_path, patch_for_locations, MutationAttributionResult, - PatchLineLocation, + exclude_direct_coverage, logical_path, patch_for_locations, + patch_for_locations_with_provenance, MutationAttributionResult, PatchLineLocation, + PatchLineProvenance, }; use crate::services::mutation_trace::lineage::{LineProvenance, MutationLineage, TransitionOrigin}; use crate::services::mutation_trace::store::{ - AttributionKind, MutationEventPageRow, MutationTraceStore, MUTATION_ATTRIBUTION_PAGE_SIZE, + AttributionKind, MutationEventPageRow, MutationTraceStore, ScopeProvenance, + MUTATION_ATTRIBUTION_PAGE_SIZE, }; -use crate::services::mutation_trace::types::{FailureKind, TreeId, WorktreeId}; +use crate::services::mutation_trace::types::{FailureKind, ScopeId, TreeId, WorktreeId}; use crate::services::patch::{parse_patch, ParsedPatch, TouchedLineKind}; use super::git_snapshot::GitSnapshotService; @@ -31,6 +33,11 @@ pub trait MutationEventPageSource { revision_cursor: Option, requested_limit: usize, ) -> Result>; + + fn load_scope_provenance(&self, scope_id: &ScopeId) -> Result> { + let _ = scope_id; + Ok(None) + } } impl MutationEventPageSource for MutationTraceStore<'_> { @@ -47,6 +54,10 @@ impl MutationEventPageSource for MutationTraceStore<'_> { requested_limit, ) } + + fn load_scope_provenance(&self, scope_id: &ScopeId) -> Result> { + MutationTraceStore::load_scope_provenance(self, scope_id) + } } pub trait TreeReadSource { @@ -145,7 +156,7 @@ where &mut state, )) }; - finish(&target, lineage.as_ref(), &state) + finish(&target, lineage.as_ref(), &state, page_source) } struct ReplayState { @@ -157,13 +168,17 @@ struct ReplayState { barrier: Option, } -fn finish( +fn finish

( target: &ParsedPatch, lineage: Option<&MutationLineage>, state: &ReplayState, -) -> BoundedMutationAttribution { + page_source: &P, +) -> BoundedMutationAttribution +where + P: MutationEventPageSource + ?Sized, +{ let result = match lineage { - Some(lineage) => project(target, lineage), + Some(lineage) => project(target, lineage, page_source), None => MutationAttributionResult { mutation_ai_patch: empty_patch(), resolved_non_ai_patch: empty_patch(), @@ -347,8 +362,15 @@ fn transition_origin(row: &MutationEventPageRow) -> TransitionOrigin { } } -fn project(target: &ParsedPatch, lineage: &MutationLineage) -> MutationAttributionResult { - let mut ai = BTreeSet::new(); +fn project

( + target: &ParsedPatch, + lineage: &MutationLineage, + page_source: &P, +) -> MutationAttributionResult +where + P: MutationEventPageSource + ?Sized, +{ + let mut ai: BTreeMap = BTreeMap::new(); let mut non_ai = BTreeSet::new(); let mut unresolved = BTreeSet::new(); @@ -366,8 +388,8 @@ fn project(target: &ParsedPatch, lineage: &MutationLineage) -> MutationAttributi continue; } match lineage.provenance_at(path, line.line_number, &line.content) { - LineProvenance::MutationAi { .. } => { - ai.insert(location); + LineProvenance::MutationAi { scope_id } => { + ai.insert(location, scope_id); } LineProvenance::MutationNonAi => { non_ai.insert(location); @@ -380,8 +402,31 @@ fn project(target: &ParsedPatch, lineage: &MutationLineage) -> MutationAttributi } } + let scope_provenance = ai + .values() + .collect::>() + .into_iter() + .map(|scope_id| { + let provenance = page_source.load_scope_provenance(scope_id).ok().flatten(); + (scope_id.clone(), provenance) + }) + .collect::>(); + let ai_with_provenance = ai + .into_iter() + .map(|(location, scope_id)| { + let provenance = scope_provenance.get(&scope_id).and_then(Option::as_ref); + ( + location, + PatchLineProvenance { + session_id: provenance.map(|value| value.session_id.clone()), + model_id: provenance.and_then(|value| value.model_id.clone()), + }, + ) + }) + .collect(); + MutationAttributionResult { - mutation_ai_patch: patch_for_locations(target, &ai), + mutation_ai_patch: patch_for_locations_with_provenance(target, &ai_with_provenance), resolved_non_ai_patch: patch_for_locations(target, &non_ai), unresolved_patch: patch_for_locations(target, &unresolved), } diff --git a/cli/src/services/mutation_trace/runtime/mutation_attribution/tests.rs b/cli/src/services/mutation_trace/runtime/mutation_attribution/tests.rs index 6165df15..19fa2fc0 100644 --- a/cli/src/services/mutation_trace/runtime/mutation_attribution/tests.rs +++ b/cli/src/services/mutation_trace/runtime/mutation_attribution/tests.rs @@ -2,7 +2,7 @@ use std::cell::RefCell; use std::collections::HashMap; use super::*; -use crate::services::mutation_trace::store::AttributionKind; +use crate::services::mutation_trace::store::{AttributionKind, ScopeProvenance}; use crate::services::mutation_trace::types::{FailureKind, ScopeId}; use crate::services::patch::{FileChangeKind, PatchFileChange, PatchHunk, TouchedLine}; @@ -83,6 +83,14 @@ fn non_ai_row(revision: u64, before: &str, after: &str) -> MutationEventPageRow page_row(revision, before, after, AttributionKind::AiContended, None) } +fn provenance(scope_id: &str, session_id: &str, model_id: Option<&str>) -> ScopeProvenance { + ScopeProvenance { + scope_id: ScopeId(scope_id.to_owned()), + session_id: session_id.to_owned(), + model_id: model_id.map(str::to_owned), + } +} + #[derive(Clone, Debug, Eq, PartialEq)] struct PageRequest { cursor: Option, @@ -91,19 +99,28 @@ struct PageRequest { struct FakePageSource { events: Vec, + provenance: HashMap, fail_on_page: Option, requests: RefCell>, + provenance_reads: RefCell>, } impl FakePageSource { fn new(events: Vec) -> Self { Self { events, + provenance: HashMap::new(), fail_on_page: None, requests: RefCell::new(Vec::new()), + provenance_reads: RefCell::new(Vec::new()), } } + fn with_provenance(mut self, value: ScopeProvenance) -> Self { + self.provenance.insert(value.scope_id.clone(), value); + self + } + fn failing_on_page(mut self, page: usize) -> Self { self.fail_on_page = Some(page); self @@ -112,6 +129,10 @@ impl FakePageSource { fn requests(&self) -> Vec { self.requests.borrow().clone() } + + fn provenance_reads(&self) -> Vec { + self.provenance_reads.borrow().clone() + } } impl MutationEventPageSource for FakePageSource { @@ -145,6 +166,11 @@ impl MutationEventPageSource for FakePageSource { .cloned() .collect()) } + + fn load_scope_provenance(&self, scope_id: &ScopeId) -> Result> { + self.provenance_reads.borrow_mut().push(scope_id.clone()); + Ok(self.provenance.get(scope_id).cloned()) + } } #[derive(Default)] @@ -299,6 +325,163 @@ fn a_surviving_ai_mutation_line_is_attributed() { assert!(unresolved_contents(&attr).is_empty()); assert_eq!(attr.reconstructed_events, 1); assert_eq!(attr.barrier, None); + let hunk = &attr.result.mutation_ai_patch.files[0].hunks[0]; + assert_eq!(hunk.model_id, None); + assert_eq!(hunk.lines[0].session_id, None); +} + +#[test] +fn a_mutation_ai_hunk_carries_scope_session_and_model_provenance() { + let page_source = FakePageSource::new(vec![ai_row(1, "t0", "t1", "scope-1")]) + .with_provenance(provenance("scope-1", "cx_session-1", Some("codex/gpt-5"))); + let tree_source = FakeTreeSource::new() + .with_file("t0", "f.rs", "a\n") + .with_diff( + "t0", + "t1", + "diff --git a/f.rs b/f.rs\n--- a/f.rs\n+++ b/f.rs\n@@ -1,1 +1,2 @@\n a\n+foo\n", + ); + + let attr = resolve_bounded_mutation_attribution( + &page_source, + &tree_source, + &worktree(), + &empty(), + &committed("f.rs", 1, 1, 1, vec![added(2, "foo")]), + &tree("t1"), + Some(1), + ); + + let hunk = &attr.result.mutation_ai_patch.files[0].hunks[0]; + assert_eq!(hunk.model_id.as_deref(), Some("codex/gpt-5")); + assert_eq!(hunk.lines[0].session_id.as_deref(), Some("cx_session-1")); + assert_eq!( + page_source.provenance_reads(), + vec![ScopeId("scope-1".to_owned())] + ); +} + +#[test] +fn mutation_ai_lines_keep_their_sessions_and_agreeing_models_across_scopes() { + let page_source = FakePageSource::new(vec![ + ai_row(2, "t1", "t2", "scope-2"), + ai_row(1, "t0", "t1", "scope-1"), + ]) + .with_provenance(provenance("scope-1", "cx_session-1", Some("codex/gpt-5"))) + .with_provenance(provenance("scope-2", "cx_session-2", Some("codex/gpt-5"))); + let tree_source = FakeTreeSource::new() + .with_file("t0", "f.rs", "a\n") + .with_diff( + "t0", + "t1", + "diff --git a/f.rs b/f.rs\n--- a/f.rs\n+++ b/f.rs\n@@ -1,1 +1,2 @@\n a\n+foo\n", + ) + .with_diff( + "t1", + "t2", + "diff --git a/f.rs b/f.rs\n--- a/f.rs\n+++ b/f.rs\n@@ -2,0 +3,1 @@\n+bar\n", + ); + + let attr = resolve_bounded_mutation_attribution( + &page_source, + &tree_source, + &worktree(), + &empty(), + &committed("f.rs", 1, 1, 1, vec![added(2, "foo"), added(3, "bar")]), + &tree("t2"), + Some(2), + ); + + let hunk = &attr.result.mutation_ai_patch.files[0].hunks[0]; + assert_eq!(hunk.model_id.as_deref(), Some("codex/gpt-5")); + assert_eq!( + hunk.lines + .iter() + .map(|line| line.session_id.as_deref()) + .collect::>(), + vec![Some("cx_session-1"), Some("cx_session-2")] + ); + assert_eq!(page_source.provenance_reads().len(), 2); +} + +#[test] +fn mutation_ai_hunk_omits_a_conflicting_model_but_keeps_line_sessions() { + let page_source = FakePageSource::new(vec![ + ai_row(2, "t1", "t2", "scope-2"), + ai_row(1, "t0", "t1", "scope-1"), + ]) + .with_provenance(provenance("scope-1", "cc_session-1", Some("claude/sonnet"))) + .with_provenance(provenance("scope-2", "cc_session-2", Some("claude/opus"))); + let tree_source = FakeTreeSource::new() + .with_file("t0", "f.rs", "a\n") + .with_diff( + "t0", + "t1", + "diff --git a/f.rs b/f.rs\n--- a/f.rs\n+++ b/f.rs\n@@ -1,1 +1,2 @@\n a\n+foo\n", + ) + .with_diff( + "t1", + "t2", + "diff --git a/f.rs b/f.rs\n--- a/f.rs\n+++ b/f.rs\n@@ -2,0 +3,1 @@\n+bar\n", + ); + + let attr = resolve_bounded_mutation_attribution( + &page_source, + &tree_source, + &worktree(), + &empty(), + &committed("f.rs", 1, 1, 1, vec![added(2, "foo"), added(3, "bar")]), + &tree("t2"), + Some(2), + ); + + let hunk = &attr.result.mutation_ai_patch.files[0].hunks[0]; + assert_eq!(hunk.model_id, None); + assert_eq!( + hunk.lines + .iter() + .map(|line| line.session_id.as_deref()) + .collect::>(), + vec![Some("cc_session-1"), Some("cc_session-2")] + ); +} + +#[test] +fn missing_or_unknown_scope_provenance_does_not_downgrade_ai_lines() { + let page_source = FakePageSource::new(vec![ + ai_row(2, "t1", "t2", "scope-2"), + ai_row(1, "t0", "t1", "scope-1"), + ]) + .with_provenance(provenance("scope-1", "cx_session-1", None)) + .with_provenance(provenance("scope-2", "cx_session-2", Some("codex/gpt-5"))); + let tree_source = FakeTreeSource::new() + .with_file("t0", "f.rs", "a\n") + .with_diff( + "t0", + "t1", + "diff --git a/f.rs b/f.rs\n--- a/f.rs\n+++ b/f.rs\n@@ -1,1 +1,2 @@\n a\n+foo\n", + ) + .with_diff( + "t1", + "t2", + "diff --git a/f.rs b/f.rs\n--- a/f.rs\n+++ b/f.rs\n@@ -2,0 +3,1 @@\n+bar\n", + ); + + let attr = resolve_bounded_mutation_attribution( + &page_source, + &tree_source, + &worktree(), + &empty(), + &committed("f.rs", 1, 1, 1, vec![added(2, "foo"), added(3, "bar")]), + &tree("t2"), + Some(2), + ); + + let hunk = &attr.result.mutation_ai_patch.files[0].hunks[0]; + assert_eq!(ai_contents(&attr), vec!["foo".to_owned(), "bar".to_owned()]); + assert_eq!(hunk.model_id, None); + assert_eq!(hunk.lines[0].session_id.as_deref(), Some("cx_session-1")); + assert_eq!(hunk.lines[1].session_id.as_deref(), Some("cx_session-2")); } #[test] diff --git a/cli/src/services/mutation_trace/runtime/tests.rs b/cli/src/services/mutation_trace/runtime/tests.rs index c55e281f..ff181381 100644 --- a/cli/src/services/mutation_trace/runtime/tests.rs +++ b/cli/src/services/mutation_trace/runtime/tests.rs @@ -335,6 +335,7 @@ fn a_snapshot_failure_then_recovery_cycle_runs_through_the_public_api() { scope: scope.clone(), event: EventId("evt-during-failure".to_string()), actor_kind: ActorKind::ClaudeCode, + provenance: None, }, ok_db, ) @@ -423,6 +424,7 @@ fn a_db_open_failure_after_arming_leaves_the_marker_and_the_next_invocation_reba scope: scope.clone(), event: EventId("evt-start".to_string()), actor_kind: ActorKind::ClaudeCode, + provenance: None, }, ok_db, ) @@ -524,6 +526,7 @@ fn a_stale_marker_rebaselines_to_the_current_tree_abandons_scopes_then_processes scope: scope.clone(), event: EventId("evt-start".to_string()), actor_kind: ActorKind::ClaudeCode, + provenance: None, }, ok_db, ) @@ -685,6 +688,7 @@ fn linked_worktrees_keep_independent_external_taint_markers_over_a_shared_db() { scope: linked_scope.clone(), event: EventId("evt-linked-start".to_string()), actor_kind: ActorKind::ClaudeCode, + provenance: None, }, || repo.open_db(), ) @@ -775,6 +779,7 @@ fn a_snapshot_failure_arms_the_marker_and_the_next_invocation_recovers_once() { scope: scope.clone(), event: EventId("evt-during-failure".to_string()), actor_kind: ActorKind::ClaudeCode, + provenance: None, }, ok_db, ) @@ -869,6 +874,7 @@ fn a_marker_clear_failure_after_a_durable_boundary_keeps_the_marker_for_a_later_ scope: scope.clone(), event: EventId("evt-start".to_string()), actor_kind: ActorKind::ClaudeCode, + provenance: None, }, ok_db, ) @@ -1319,6 +1325,7 @@ fn historical_before_and_after_pins_survive_reconciliation_after_real_coordinate scope: scope.clone(), event: EventId("evt-start".to_string()), actor_kind: ActorKind::ClaudeCode, + provenance: None, }, ok_db, ) @@ -1904,6 +1911,7 @@ fn an_abandoned_scope_rebaselines_the_successor_start_without_evidence_for_the_g scope: scope_a.clone(), event: EventId("evt-a-start".to_string()), actor_kind: ActorKind::ClaudeCode, + provenance: None, }, ok_db, ) @@ -1944,6 +1952,7 @@ fn an_abandoned_scope_rebaselines_the_successor_start_without_evidence_for_the_g scope: scope_b.clone(), event: EventId("evt-b-start".to_string()), actor_kind: ActorKind::ClaudeCode, + provenance: None, }, ok_db, ) @@ -2020,6 +2029,7 @@ fn abandoning_a_stale_scope_leaves_an_unrelated_live_scope_active_through_the_re scope: stale.clone(), event: EventId("evt-stale-start".to_string()), actor_kind: ActorKind::ClaudeCode, + provenance: None, }, ok_db, ) @@ -2030,6 +2040,7 @@ fn abandoning_a_stale_scope_leaves_an_unrelated_live_scope_active_through_the_re scope: live.clone(), event: EventId("evt-live-start".to_string()), actor_kind: ActorKind::Codex, + provenance: None, }, ok_db, ) @@ -2107,6 +2118,7 @@ fn abandoning_a_scope_through_another_worktrees_checkout_is_rejected_without_wri scope: scope.clone(), event: EventId("evt-main-start".to_string()), actor_kind: ActorKind::ClaudeCode, + provenance: None, }, ok_db, ) @@ -2185,6 +2197,7 @@ fn a_real_thread_cas_race_settles_on_the_competitors_terminal_status() { scope: scope.clone(), event: EventId("evt-race-start".to_string()), actor_kind: ActorKind::ClaudeCode, + provenance: None, }, ok_db, ) @@ -2426,6 +2439,7 @@ fn drive_codex_overlap_transition( scope: codex.clone(), event: EventId("evt-codex-start".to_string()), actor_kind: ActorKind::Codex, + provenance: None, }, ok_db, ) @@ -2436,6 +2450,7 @@ fn drive_codex_overlap_transition( scope: claude.clone(), event: EventId("evt-claude-start".to_string()), actor_kind: ActorKind::ClaudeCode, + provenance: None, }, ok_db, ) diff --git a/cli/src/services/mutation_trace/store.rs b/cli/src/services/mutation_trace/store.rs index 3e9d428b..5baeda3f 100644 --- a/cli/src/services/mutation_trace/store.rs +++ b/cli/src/services/mutation_trace/store.rs @@ -213,6 +213,8 @@ const SELECT_SCOPES_BY_WORKTREE_AND_STATUS_SQL: &str = FROM mutation_trace_scopes WHERE worktree_id = ?1 AND status = ?2"; const SELECT_SCOPE_BY_ID_SQL: &str = "SELECT scope_id, worktree_id, actor_kind, status FROM mutation_trace_scopes WHERE scope_id = ?1"; +const SELECT_SCOPE_PROVENANCE_SQL: &str = + "SELECT scope_id, session_id, model_id FROM mutation_trace_scope_provenance WHERE scope_id = ?1"; const SELECT_PROCESSED_EVENT_SQL: &str = "SELECT 1 FROM mutation_trace_processed_events WHERE scope_id = ?1 AND event_id = ?2"; const SELECT_MUTATION_EVENT_SQL: &str = "SELECT before_tree, after_tree, tainted, failure_kind, @@ -268,6 +270,10 @@ const INSERT_SCOPE_IF_ABSENT_SQL: &str = "INSERT INTO mutation_trace_scopes (scope_id, worktree_id, actor_kind, status) VALUES (?1, ?2, ?3, 'never_seen') ON CONFLICT (scope_id) DO NOTHING"; +const INSERT_SCOPE_PROVENANCE_IF_ABSENT_SQL: &str = + "INSERT INTO mutation_trace_scope_provenance (scope_id, session_id, model_id) + VALUES (?1, ?2, ?3) + ON CONFLICT (scope_id) DO NOTHING"; const UPDATE_WORKTREE_CAS_SQL: &str = "UPDATE mutation_trace_worktrees SET cursor_tree = ?1, revision = ?2, tainted = ?3, failure_kind = ?4, needs_rebaseline = ?5, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') @@ -509,6 +515,14 @@ fn diff_new_mutation_event( Ok(Some((*event).clone())) } +#[allow(clippy::struct_field_names)] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ScopeProvenance { + pub scope_id: ScopeId, + pub session_id: String, + pub model_id: Option, +} + pub struct MutationTraceStore<'a> { db: &'a RepositoryAgentTraceDb, } @@ -836,6 +850,57 @@ impl<'a> MutationTraceStore<'a> { Ok(rows.into_iter().next().map(|(_, scope_state)| scope_state)) } + pub fn register_scope_provenance( + &self, + provenance: &ScopeProvenance, + ) -> Result { + if self.load_scope(&provenance.scope_id)?.is_none() { + bail!( + "cannot register provenance for scope {:?}: it has no mutation_trace_scopes row", + provenance.scope_id + ); + } + + self.db.execute( + INSERT_SCOPE_PROVENANCE_IF_ABSENT_SQL, + ( + provenance.scope_id.0.as_str(), + provenance.session_id.as_str(), + provenance.model_id.as_deref(), + ), + )?; + + let stored = self + .load_scope_provenance(&provenance.scope_id)? + .ok_or_else(|| { + anyhow::anyhow!( + "scope {:?} has no provenance row immediately after register_scope_provenance insert", + provenance.scope_id + ) + })?; + + if stored.session_id != provenance.session_id { + bail!( + "scope {:?} already has provenance for session {}, not {}", + provenance.scope_id, + stored.session_id, + provenance.session_id + ); + } + + Ok(stored) + } + + pub fn load_scope_provenance(&self, scope_id: &ScopeId) -> Result> { + let rows = self.db.query_map( + SELECT_SCOPE_PROVENANCE_SQL, + (scope_id.0.as_str(),), + scope_provenance_row_from_turso, + )?; + + Ok(rows.into_iter().next()) + } + fn load_worktree_state(&self, worktree: &WorktreeId) -> Result> { let rows = self.db.query_map( SELECT_WORKTREE_SQL, @@ -1081,6 +1146,24 @@ fn scope_row_from_turso(row: &turso::Row) -> Result<(ScopeId, ScopeState)> { )) } +fn scope_provenance_row_from_turso(row: &turso::Row) -> Result { + let scope_id: String = row + .get(0) + .context("failed to read mutation_trace_scope_provenance.scope_id")?; + let session_id: String = row + .get(1) + .context("failed to read mutation_trace_scope_provenance.session_id")?; + let model_id: Option = row + .get(2) + .context("failed to read mutation_trace_scope_provenance.model_id")?; + + Ok(ScopeProvenance { + scope_id: ScopeId(scope_id), + session_id, + model_id, + }) +} + fn mutation_event_page_row_from_turso(row: &turso::Row) -> Result { let revision_blob: Vec = row .get(0) @@ -2153,6 +2236,242 @@ mod tests { assert!(error.to_string().contains("wt-missing")); } + fn provenance(scope_id: &str, session_id: &str, model_id: Option<&str>) -> ScopeProvenance { + ScopeProvenance { + scope_id: ScopeId(scope_id.to_string()), + session_id: session_id.to_string(), + model_id: model_id.map(str::to_string), + } + } + + fn provenance_row_count(db: &RepositoryAgentTraceDb) -> i64 { + db.query_map( + "SELECT COUNT(*) FROM mutation_trace_scope_provenance", + (), + |row| row.get::(0).map_err(Into::into), + ) + .expect("provenance count query should succeed") + .into_iter() + .next() + .expect("count row should exist") + } + + fn registered_scope_store(label: &str) -> (TestDbPath, RepositoryAgentTraceDb) { + let db_fixture = test_db_path(label); + let db = + RepositoryAgentTraceDb::new_at(db_fixture.path()).expect("repository DB should open"); + insert_worktree(&db, "wt-1", 0); + insert_scope(&db, "scope-1", "wt-1", ScopeStatus::Active); + (db_fixture, db) + } + + #[test] + fn register_scope_provenance_stores_a_known_model_and_reads_it_back() { + let (_fixture, db) = registered_scope_store("provenance-known-model"); + let store = MutationTraceStore::new(&db); + + let expected = provenance("scope-1", "cx_session-1", Some("codex/gpt-5")); + let stored = store + .register_scope_provenance(&expected) + .expect("registering provenance for a registered scope should succeed"); + + assert_eq!(stored, expected); + assert_eq!( + store + .load_scope_provenance(&ScopeId("scope-1".to_string())) + .expect("load_scope_provenance should succeed"), + Some(expected) + ); + } + + #[test] + fn register_scope_provenance_stores_a_null_model_and_reads_it_back() { + let (_fixture, db) = registered_scope_store("provenance-null-model"); + let store = MutationTraceStore::new(&db); + + let expected = provenance("scope-1", "cc_session-1", None); + let stored = store + .register_scope_provenance(&expected) + .expect("provenance with no model should be valid"); + + assert_eq!(stored, expected); + assert_eq!( + store + .load_scope_provenance(&ScopeId("scope-1".to_string())) + .expect("load_scope_provenance should succeed"), + Some(expected) + ); + } + + #[test] + fn register_scope_provenance_replayed_identically_is_an_idempotent_no_op() { + let (_fixture, db) = registered_scope_store("provenance-identical-replay"); + let store = MutationTraceStore::new(&db); + + let incoming = provenance("scope-1", "cx_session-1", Some("codex/gpt-5")); + store + .register_scope_provenance(&incoming) + .expect("first registration should succeed"); + + let replayed = store + .register_scope_provenance(&incoming) + .expect("an identical replay should succeed"); + + assert_eq!(replayed, incoming); + assert_eq!(provenance_row_count(&db), 1); + } + + #[test] + fn register_scope_provenance_keeps_a_stored_null_model_when_one_is_later_discovered() { + let (_fixture, db) = registered_scope_store("provenance-existing-null-incoming-model"); + let store = MutationTraceStore::new(&db); + + let first = provenance("scope-1", "cc_session-1", None); + store + .register_scope_provenance(&first) + .expect("first registration should succeed"); + + let stored = store + .register_scope_provenance(&provenance( + "scope-1", + "cc_session-1", + Some("claude/opus-5"), + )) + .expect("a later model discovery must not fail the registration"); + + assert_eq!(stored, first); + assert_eq!( + store + .load_scope_provenance(&ScopeId("scope-1".to_string())) + .expect("load_scope_provenance should succeed"), + Some(first) + ); + } + + #[test] + fn register_scope_provenance_keeps_a_stored_model_when_the_replay_has_none() { + let (_fixture, db) = registered_scope_store("provenance-existing-model-incoming-null"); + let store = MutationTraceStore::new(&db); + + let first = provenance("scope-1", "cc_session-1", Some("claude/opus-5")); + store + .register_scope_provenance(&first) + .expect("first registration should succeed"); + + let stored = store + .register_scope_provenance(&provenance("scope-1", "cc_session-1", None)) + .expect("a replay without a model must not fail the registration"); + + assert_eq!(stored, first); + assert_eq!( + store + .load_scope_provenance(&ScopeId("scope-1".to_string())) + .expect("load_scope_provenance should succeed"), + Some(first) + ); + } + + #[test] + fn register_scope_provenance_keeps_the_first_model_when_a_later_one_disagrees() { + let (_fixture, db) = registered_scope_store("provenance-model-disagreement"); + let store = MutationTraceStore::new(&db); + + let first = provenance("scope-1", "cc_session-1", Some("claude/sonnet-5")); + store + .register_scope_provenance(&first) + .expect("first registration should succeed"); + + let stored = store + .register_scope_provenance(&provenance( + "scope-1", + "cc_session-1", + Some("claude/opus-5"), + )) + .expect("a model disagreement must not fail the registration"); + + assert_eq!(stored, first); + assert_eq!( + store + .load_scope_provenance(&ScopeId("scope-1".to_string())) + .expect("load_scope_provenance should succeed"), + Some(first) + ); + } + + #[test] + fn register_scope_provenance_errors_on_a_session_conflict_without_rewriting_the_row() { + let (_fixture, db) = registered_scope_store("provenance-session-conflict"); + let store = MutationTraceStore::new(&db); + + let first = provenance("scope-1", "cc_session-1", Some("claude/opus-5")); + store + .register_scope_provenance(&first) + .expect("first registration should succeed"); + + let error = store + .register_scope_provenance(&provenance( + "scope-1", + "cc_session-2", + Some("claude/opus-5"), + )) + .expect_err("a different session for the same scope should error"); + assert!(error.to_string().contains("scope-1")); + assert!(error.to_string().contains("cc_session-1")); + assert!(error.to_string().contains("cc_session-2")); + + assert_eq!( + store + .load_scope_provenance(&ScopeId("scope-1".to_string())) + .expect("load_scope_provenance should succeed"), + Some(first), + "a session conflict must never rewrite the stored row" + ); + assert_eq!(provenance_row_count(&db), 1); + } + + #[test] + fn register_scope_provenance_errors_for_an_unregistered_scope_and_creates_no_rows() { + let db_fixture = test_db_path("provenance-missing-scope"); + let db = + RepositoryAgentTraceDb::new_at(db_fixture.path()).expect("repository DB should open"); + let store = MutationTraceStore::new(&db); + + let error = store + .register_scope_provenance(&provenance( + "scope-1", + "cc_session-1", + Some("claude/opus-5"), + )) + .expect_err("provenance for a scope with no mutation_trace_scopes row should error"); + assert!(error.to_string().contains("scope-1")); + + assert_eq!( + provenance_row_count(&db), + 0, + "a failed registration must not leave an orphan provenance row" + ); + assert!( + store + .load_scope(&ScopeId("scope-1".to_string())) + .expect("load_scope should succeed") + .is_none(), + "registering provenance must never create a scope implicitly" + ); + } + + #[test] + fn load_scope_provenance_returns_none_for_a_scope_without_provenance() { + let (_fixture, db) = registered_scope_store("provenance-absent"); + let store = MutationTraceStore::new(&db); + + assert_eq!( + store + .load_scope_provenance(&ScopeId("scope-1".to_string())) + .expect("load_scope_provenance should succeed"), + None + ); + } + fn healthy_worktree_state(revision: u64) -> WorktreeState { WorktreeState { cursor_tree: TreeId("tree0".to_string()), diff --git a/context/architecture.md b/context/architecture.md index 54cc0170..60035231 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -10,6 +10,12 @@ Codex setup/merge/doctor path. OpenCode and Pi remain unwired. The Codex adapter's tracked-tool coverage and MCP boundary are documented in [`context/cli/codex-mutation-scope-integration.md`](cli/codex-mutation-scope-integration.md). +Both adapters attach optional `ScopeProvenance` at admission. The verified +mutation protocol still decides scope ownership and `AiExclusive(scope)`; +provenance is observational metadata resolved later into mutation-derived +Agent Trace evidence. Real Claude/Codex `Bash` regressions cover this boundary +through commit and persisted `agent_traces.trace_json`. + ## Config generation boundary (current approved design) The repository keeps no committed OpenCode, Claude, Pi, or Codex generated target trees. `config/.opencode`, `config/.claude`, `config/.pi`, `config/.agents`, and `config/.codex` are logical payload layouts emitted only beneath temporary generation roots, Cargo `OUT_DIR`, and packaging-only fallback directories. diff --git a/context/cli/claude-mutation-scope-integration.md b/context/cli/claude-mutation-scope-integration.md index ea09afbe..3256f88c 100644 --- a/context/cli/claude-mutation-scope-integration.md +++ b/context/cli/claude-mutation-scope-integration.md @@ -106,16 +106,17 @@ can form. The adapter may call `checkout::resolve_git_dir` but not ## PreToolUse: write-ahead Start, fail-closed -For a tracked mutation-capable tool, `handle_pre_tool_use` runs: -explicit-background-shell check -> resolve `git_dir` -> recovery barrier -> -write-ahead `Start` — persist `phase=pending_start`, call the seam with -`{"operation":"start","scope_id":,"event_id":|start,"actor_kind":"claude_code"}`, -persist `pending_start -> active`, return empty success. The seam receives the -raw `cwd` as its `repository_root` and SCE derives the `WorktreeId`, so durable -generic-ingress `Start` is reached before the hook returns success to Claude. - -**Fail-closed via Claude's deny decision.** Claude treats ordinary non-2 hook -failures as non-blocking, so a generic non-zero exit would let the tool run +For a tracked mutation-capable tool, `handle_pre_tool_use` runs: explicit-background-shell check -> resolve `git_dir` -> recovery barrier -> +write-ahead `Start` — persist `phase=pending_start`, resolve the canonical +`cc_` and the exact model-state key (`agent_id = ""` for the main +agent), call the seam with the optional provenance snapshot, persist +`pending_start -> active`, return empty success. The resolver is injectable for tests; production reads `claude_model_state_by_session_and_agent` from the +repository Agent Trace DB. Missing state or resolver errors become a null model +and never deny the mutation-capable tool. The seam receives the raw `cwd` as its +`repository_root` and SCE derives the `WorktreeId`, so durable generic-ingress +`Start` is reached before the hook returns success to Claude. + +**Fail-closed via Claude's deny decision.** Claude treats ordinary non-2 hook failures as non-blocking, so a generic non-zero exit would let the tool run without its `Start`. Therefore **any** failure in the mutation-capable `PreToolUse` path — state-allocation failure, seam `Start` failure, unresolvable `cwd`, or a barrier denial — returns (`Ok`, never `Err`): @@ -126,9 +127,10 @@ without its `Start`. Therefore **any** failure in the mutation-capable The detailed error is logged via `Logger::warn` (`sce.hooks.claude_mutation_scope.pre_tool_use_fail_closed`); Claude's deny -reason never carries it. The adapter never returns `allow`, so SCE cannot bypass -Claude's permission system. A read-only or `Agent` `PreToolUse` returns empty -stdout, no scope. +reason never carries it. Model-state lookup failures are logged separately as +model-unavailable metadata and do not enter this deny path. The adapter never +returns `allow`, so SCE cannot bypass Claude's permission system. A read-only or +`Agent` `PreToolUse` returns empty stdout, no scope. ## PostToolUse / PostToolUseFailure: close the scope @@ -149,7 +151,7 @@ rules: observation time is lost, so the adapter must not retry that `Close` later as the original observation; it abandons and arms `recovery_pending`. The ingress carried-success variants (`MarkerClearAfterCommit` / - `MarkerClearAfterCompletion`) are durable success and do not enter this path. + `MarkerClearAfterCompletion`) are durable success and do not enter this path. The admission snapshot is carried into the mutation-derived Agent Trace path: the real `Bash` regression persists the exact `(cc_, "")` model state captured before the tool runs, then verifies the model and related session URL in `agent_traces.trace_json` after commit. ## Abandonment cleanup signals @@ -230,18 +232,16 @@ generated Claude settings state. ## Dependency boundary -Dependency direction is strictly `claude_mutation_scope -> hooks::mutation_scope +Dependency direction remains `claude_mutation_scope -> hooks::mutation_scope -> mutation_trace::runtime`. Production Claude-adapter code (outside -`#[cfg(test)]` in `claude_mutation_scope/`) imports no -`crate::services::mutation_trace::{runtime,protocol,store}` and names no -`RepositoryAgentTraceDb`, `WorktreeId`, or `GitSnapshotService`; its only -dependency into the mutation stack is the single -`super::mutation_scope::run_mutation_scope_from_payload` seam import — the -generic-ingress entrypoint made `pub(crate)` by this plan (T05), reused verbatim -with no second `RuntimeBoundary` path and no spawned `sce` subprocess, inheriting -that seam's strict parser, `RuntimeBoundary` mapping, lazy DB acquisition, -durable-completion error classification, and empty-stdout semantics. T08 proves -the whole path against real Git repositories and a real Agent Trace DB. +`#[cfg(test)]`) imports no mutation-trace module and names no +`RepositoryAgentTraceDb`, `WorktreeId`, or `GitSnapshotService`. Its only +mutation-stack dependency is the reused +`super::mutation_scope::run_mutation_scope_from_payload` seam, with no second +`RuntimeBoundary` path or spawned `sce` subprocess. The production resolver +uses the hooks-layer Agent Trace DB opener only for the exact +`claude_model_state` read; the seam retains strict parsing, lazy DB acquisition, +durable-completion classification, and empty-stdout semantics. ## Related context diff --git a/context/cli/codex-mutation-scope-integration.md b/context/cli/codex-mutation-scope-integration.md index c1e0e740..710fc233 100644 --- a/context/cli/codex-mutation-scope-integration.md +++ b/context/cli/codex-mutation-scope-integration.md @@ -7,11 +7,10 @@ the mutation stack is the in-process `hooks::mutation_scope::run_mutation_scope_from_payload` seam; it does not call runtime, protocol, or database modules directly. -The adapter was tested against codex-cli **0.153.4**, upstream -`openai/codex` tag `rust-v0.153.4`, commit -`3d2ee51ca2d5db578f328aa75e20aa22c0197c9a`; raw lifecycle evidence is in -[`codex_mutation_scope/fixtures`](../../cli/src/services/hooks/codex_mutation_scope/fixtures/) -and `fixtures/NOTES.md`. +The adapter was tested against codex-cli **0.153.4** (upstream `openai/codex` +tag `rust-v0.153.4`, commit `3d2ee51ca2d5db578f328aa75e20aa22c0197c9a`); raw +lifecycle evidence is in `fixtures/NOTES.md` and +[`codex_mutation_scope/fixtures`](../../cli/src/services/hooks/codex_mutation_scope/fixtures/). ## Scope model and coverage @@ -89,15 +88,17 @@ cx-tool-v1|n=|s=:|a=:|t=:|start` and -`|close`. Live duplicates reuse attempt, scope, and event IDs, while a -later execution never reuses a terminal scope, even if Codex reuses -`tool_use_id`. +`|close`. Live duplicates reuse attempt, scope, and event IDs; a later +execution never reuses a terminal scope, even if Codex reuses `tool_use_id`. The raw hook `cwd` is passed as `repository_root`; the runtime derives Git directory, checkout identity, snapshots, and revisions. The adapter never constructs `worktree_id`, and sends `actor_kind: "codex"` through the existing seam without spawning `sce`. +A tracked `Start` also carries scope provenance (the `cx_`-prefixed session and +normalized `model`); see [mutation-scope provenance](mutation-scope-provenance.md). The real `Bash` regression drives this adapter through generic `Start`, scope close, Git commit, mutation reconstruction, and Agent Trace persistence, verifying that the normalized model and `cx_` session survive as mutation provenance without writing direct `diff_traces` evidence. + ## Write-ahead admission and failure posture Tracked `PreToolUse` follows this ordering: @@ -105,7 +106,7 @@ Tracked `PreToolUse` follows this ordering: ```text boundary lock -> state lock -> allocate sequence -> persist pending_start - -> generic ingress Start(scope, |start, codex) + -> generic ingress Start(scope, |start, codex, provenance) -> state lock -> mark active -> empty stdout / Codex continue ``` @@ -230,14 +231,13 @@ that excluded tools are read-only, harmless, or immediately detectable. ## Verification evidence -T06 exercised the adapter through real temporary Git repositories, real -repository Agent Trace databases, and the production entrypoint. The adapter -suite passed with 146 tests; mutation-trace passed with 336. Regressions cover -write-ahead admission, duplicate and reused -identities, tracked success/failure, all cleanup signals, linked worktrees, -MCP pass-through and mutate-then-error behavior, parallel MCP, tracked-tool plus -MCP overlap, arbitrary-hook denial recovery, crash points, and both directions -of the boundary-aware Codex attribution rule. +The adapter is exercised through real temporary Git repositories, real +repository Agent Trace databases, and the production entrypoint. Regressions +cover write-ahead admission, duplicate and reused identities, tracked +success/failure, cleanup signals, linked worktrees, MCP pass-through, +mutate-then-error and parallel MCP, tracked-tool plus MCP overlap, denial +recovery, crash points, both directions of the boundary-aware attribution rule, +and scope provenance for both tracked tools. The frozen protocol/Quint/runtime baseline and the Agent Trace SQL/schema boundary remain unchanged after the accepted D14 follow-up: diff --git a/context/cli/mutation-scope-hook-ingress.md b/context/cli/mutation-scope-hook-ingress.md index e9552676..7145b318 100644 --- a/context/cli/mutation-scope-hook-ingress.md +++ b/context/cli/mutation-scope-hook-ingress.md @@ -44,7 +44,7 @@ exactly five operations are supported. | `operation` | Other accepted keys | Maps to | | --- | --- | --- | -| `start` | `scope_id`, `event_id`, `actor_kind` (all required, non-blank) | `RuntimeBoundary::Start` | +| `start` | `scope_id`, `event_id`, `actor_kind` (all required, non-blank), plus an optional [`provenance`](mutation-scope-provenance.md#optional-on-the-start-ingress) object | `RuntimeBoundary::Start` | | `advance` | `scope_id`, `event_id`, `actor_kind` (all required, non-blank) | `RuntimeBoundary::Advance` | | `close` | `scope_id`, `event_id`, `actor_kind` (all required, non-blank) | `RuntimeBoundary::Close` | | `flush` | *(none)* | `RuntimeBoundary::Flush` | @@ -67,20 +67,22 @@ The parser (`parse_mutation_scope_payload`) is strict and rejects, each with a (`field 'worktree_id' is not accepted; worktree identity is derived from the invoking checkout`); - `flush` carrying any `scope_id` / `event_id` / `actor_kind` field; -- `abandon` carrying anything but `scope_id`. - -Unexpected fields and `worktree_id` are validated explicitly against each -operation's allowed key set. The hook transport remains local to -`mutation_scope.rs`; no serde representation is added to the mutation-domain -types. +- `abandon` carrying anything but `scope_id`; +- a `provenance` key on any operation but `start`, a non-object `provenance`, an + unexpected `provenance.`, or a blank `session_id` / `model_id` inside it. +The hook transport remains local to `mutation_scope.rs`; no serde representation is added to the mutation-domain types. End-to-end regressions verify that this remains the harness-neutral transport: Claude and Codex supply canonical provenance at `Start`, while the ingress only forwards it and does not decide authorship or mutation attribution. ## Operation mapping `start` / `advance` / `close` build the matching `RuntimeBoundary` variant, forwarding `ScopeId(scope_id)`, `EventId(event_id)`, and the mapped `ActorKind` **verbatim** — no trimming, prefixing, hashing, normalization, UUID generation, or timestamping. A `scope_id` of `" scope-A "` reaches the runtime as -`ScopeId(" scope-A ")` unchanged. +`ScopeId(" scope-A ")` unchanged. `start` forwards its optional `provenance` +the same way, as `StartProvenance`; whether that provenance is durably persisted +is the runtime's admission-bounded decision, not the ingress's — a `start` +replayed against an already-admitted scope that has no provenance row still +succeeds and still persists none. `flush` builds `RuntimeBoundary::Flush`, which carries no scope, event, or actor identity. It drives the runtime's real observed-flush behavior: against a @@ -228,10 +230,7 @@ and left as future work for the remaining harnesses: - `session → ScopeId` or `tool-call → EventId` derivation for OpenCode and Pi; - PID tracking, process supervisors, staleness detection, automatic scope abandonment; -- harness settings generation or `sce setup` integration for OpenCode/Pi - (Claude's and Codex's registrations now ship — the Codex adapter lives in - `cli/src/services/hooks/codex_mutation_scope/` and is installed by - `sce setup --codex`; OpenCode/Pi remain unregistered). +- harness settings generation or `sce setup` integration for OpenCode/Pi. Each adapter still owns its own `ScopeId` / `EventId` / `actor_kind` derivation and its own stale-process detection, and targets this ingress (or, @@ -247,4 +246,5 @@ The Codex mapping and partial coverage are in [`codex-mutation-scope-integration - [Mutation-trace runtime coordinator](mutation-trace-runtime-coordinator.md) - [Mutation-trace scope abandonment](mutation-trace-scope-abandonment.md) - [Mutation-trace protected worktree](mutation-trace-protected-worktree.md) +- [Mutation-scope provenance](mutation-scope-provenance.md) - [Agent Trace hooks command routing](../sce/agent-trace-hooks-command-routing.md) diff --git a/context/cli/mutation-scope-provenance.md b/context/cli/mutation-scope-provenance.md new file mode 100644 index 00000000..866ab993 --- /dev/null +++ b/context/cli/mutation-scope-provenance.md @@ -0,0 +1,221 @@ +# Mutation-scope provenance (`mutation_trace_scope_provenance`) + +Durable, insert-once metadata answering one question the verified +mutation-cursor protocol deliberately does not: given a `ScopeId`, which session +and model did that scope represent? It is stored beside — never inside — the +protocol state persisted by the +[mutation-trace store](mutation-trace-store.md), and reached through that same +`MutationTraceStore` seam. + +## Table shape + +Migration `005_mutation_scope_provenance.sql` adds a sixth table alongside +`004_mutation_trace_protocol.sql`'s five, keyed `scope_id TEXT PRIMARY KEY` with +`session_id TEXT NOT NULL`, a nullable `model_id TEXT`, and the same `created_at` +default the `004` tables use. It deliberately does not duplicate `actor_kind`, +`worktree_id`, or `status` — those remain owned by `mutation_trace_scopes` — and +it stores no `agent_id`. + +`session_id` holds a canonical SCE session identity verbatim (`cc_` for Claude, +`cx_` for Codex); `model_id` is already normalized by its producer and may be +`NULL`. Unknown model information is `NULL`, never guessed. + +## Provenance is not protocol state + +Provenance is metadata *about* an already-established scope. It is not part of +`ProtocolState`, never enters a `DurableTransition` or the CAS batch, and never +participates in deciding `IneligibleUnscoped` / `AiExclusive` / `AiContended`. +The pure protocol works identically when it is entirely absent. `ScopeId` proves +ownership; scope provenance only describes the owning scope. + +## Seams + +`MutationTraceStore::register_scope_provenance(&ScopeProvenance { scope_id, +session_id, model_id }) -> Result` is the write seam, returning +whichever row is stored afterwards. +`MutationTraceStore::load_scope_provenance(scope_id) -> +Result>` is the read seam; a scope with no provenance +reads back `None`, which is an ordinary absence of metadata rather than an error. + +## Insert-once semantics + +The first persisted row always wins: + +- `session_id` is immutable identity. A `scope_id` belongs to exactly one + session, permanently. +- `model_id` is immutable first-observed descriptive metadata. A stored `NULL` + is never backfilled by a later discovery, and a stored model is never cleared + by a later `None` or overwritten by a disagreeing one. No `UPDATE` path exists. + +Every replay carrying the same `session_id` therefore succeeds and returns the +stored row unchanged, whatever its model says. The **only** provenance condition +that returns `Err` is a `scope_id` already bound to a different `session_id`, and +that failure leaves the stored row untouched — session identity is never silently +rewritten. A model disagreement is not a conflict: no attribution decision +depends on this metadata, so a disagreement about it is not a reason to deny a +mutation-capable tool. + +## Optional on the `Start` ingress + +The generic [mutation-scope hook ingress](mutation-scope-hook-ingress.md) +accepts provenance as one optional `provenance` object, on `start` only: + +```json +{ + "operation": "start", + "scope_id": "...", + "event_id": "...", + "actor_kind": "codex", + "provenance": { "session_id": "cx_...", "model_id": "..." } +} +``` + +Omitting it is a first-class case: a producer that supplies no provenance keeps +the exact pre-provenance behavior and persists no row. When present, the strict +parser requires a JSON object, accepts only `session_id` and `model_id` +(anything else is `unexpected field 'provenance.'`), requires a non-blank +`session_id`, and reads `model_id` as an optional value where an absent key and +an explicit `null` both mean "no model". A blank or whitespace-only `model_id` +is rejected rather than coerced: both shipped producers normalize an unknown +model to an absent value, so a blank string is malformed input, not an +expression of "unknown". `advance`, `close`, `flush`, and `abandon` reject the +key outright, so provenance is only ever established at admission and never +updated by a later boundary. Every rejection uses the ingress's existing +`Invalid mutation-scope payload from STDIN: .` diagnostic. + +The ingress stays harness-neutral: it forwards the already-canonical +`session_id` and already-normalized `model_id` verbatim onto +`RuntimeBoundary::Start`, whose optional `StartProvenance { session_id, +model_id }` carries them into the runtime. That value omits `scope_id` because +the boundary already names the scope; the +[runtime](mutation-scope-runtime.md) composes the stored `ScopeProvenance` from +both. + +## Producers + +Provenance is supplied by the harness adapter, never by the generic ingress, +which stays harness-neutral. Each producer canonicalizes its own session and +normalizes its own model before the `Start` leaves the adapter. + +The [Codex adapter](codex-mutation-scope-integration.md) reads both values off +the same tracked `PreToolUse` payload it already parses: `session_id` becomes +the `cx_`-prefixed canonical session, and `model` is normalized (trimmed, blank +means absent). Both tracked tools — `Bash` and `apply_patch` — establish `Start` +through one path, so both carry provenance. That `model` read is deliberately +the one lenient field in an otherwise strict parser: an absent, `null`, blank, +or non-string `model` yields no model rather than a rejected event, because the +model is descriptive metadata and a malformed one must never fail-closed-deny a +mutation-capable tool. Untracked and delegation tools never reach the `Start` +path, so they establish neither a scope nor provenance. + +A Codex session-identity conflict is unreachable in practice: the +`cx-tool-v1|…` scope identity embeds the session length-prefixed, so one +`scope_id` cannot name two sessions. + +The [Claude adapter](claude-mutation-scope-integration.md) resolves provenance +through an injectable `ClaudeModelStateResolver` with the shape +`(repository_root, canonical_session_id, agent_id) -> Result>`. +The production resolver uses the existing repository Agent Trace DB lookup for +`claude_model_state_by_session_and_agent`, after canonicalizing the raw session +to `cc_`. It passes `agent_id = ""` for the main agent and the exact +Claude `agent_id` for a subagent; a subagent never inherits the main-agent row. +The lookup runs when the scope is admitted, before the generic `Start` ingress +is called, and the returned Claude model is normalized before being sent as +provenance. Missing state, `Ok(None)`, an unusable model, or a resolver error +all produce `model_id = NULL` while retaining the canonical session and +allowing `Start` to succeed. A later `PostModelSwitch` changes only current +model state; insert-once scope provenance remains the original snapshot. + +## Durable `Start` ordering + +A `Start` carrying provenance runs these steps in order, inside the existing +protected-worktree boundary: + +```mermaid +flowchart TD + W[initialize worktree] --> S[register scope
scope_id, worktree_id, actor_kind] + S --> P[conditionally register provenance
scope_id, session_id, model_id?] + P --> C[pure protocol prepare/commit for Start] +``` + +Provenance registration therefore always has its owning `mutation_trace_scopes` +row, including on a scope's very first `Start`, and the durable row is in place +before the boundary reports success. It sits outside the CAS retry loop, so a +CAS conflict retries only the protocol transition and never re-registers +provenance — which is safe in either direction, because registration is +insert-once and idempotent. + +## Provenance creation is admission-bounded + +Provenance describes the owning scope **as observed at admission**, so it is +never attached retroactively. `register_scope` already returns the durable +`ScopeState`, and the runtime uses that returned `status` together with the +existing provenance row to decide what a `Start` carrying provenance may do: + +| stored row | durable scope status | behavior | +| --- | --- | --- | +| present | any | register as usual — the insert-once matrix above stays authoritative | +| absent | `NeverSeen` | register the incoming provenance | +| absent | `Active` or terminal | do nothing; provenance stays absent | + +A provenance row may therefore only be **created** while the durable scope is +still `NeverSeen`. Once a scope has crossed protocol admission, absence of +provenance is permanent, and a later `Start` replay cannot backfill it. That +replay is not an error: it continues through the protocol's normal replay and +guard behavior and simply persists no provenance. + +This closes a retroactive-attachment hole. A scope admitted by a `Start` +carrying no provenance could otherwise gain one from a later replay, which for +Claude means a replay after a `PostModelSwitch` could resolve the *newer* model +and attach it to an *older* scope — provenance that no longer describes +admission. + +The rule is deliberately not "skip provenance whenever the scope is past +`NeverSeen`". An **existing** provenance row is still checked on every `Start` +carrying provenance, including for a long-admitted scope, so a replay naming a +different `session_id` remains a fail-closed identity conflict. Only *creation* +is admission-bounded; *validation* is not. + +The rule is also deliberately keyed on durable scope status rather than on "the +scope row already exists", which preserves the legitimate retry: a first attempt +that registered the scope but never committed the protocol `Start` leaves the +scope `NeverSeen`, so the retry may still register provenance and then commit. + +## Failure is fail-closed and pre-commit + +Any provenance failure — in practice only a `scope_id` already bound to a +different `session_id` — aborts the `Start` as +`CoordinateError::ScopeProvenanceRegistration` before the pure protocol commits. +The durable scope keeps whatever status it already had (at most a freshly +created `NeverSeen` row when the failing `Start` was the scope's first), the +triggering event is never recorded as processed, the worktree revision is +unchanged, and the stored provenance row is byte-unchanged. This mirrors the +runtime's existing register-before-protocol behavior for +`CoordinateError::ScopeIdentityConflict`. Because a model disagreement is never +a conflict, the only way provenance denies a mutation-capable tool is a +session-identity contradiction, which neither shipped producer can generate: +both `ScopeId` formats embed the session length-prefixed in the identity +itself. + +## Owning-scope requirement + +Like `register_scope`, registration requires its owning row to already exist: the +`mutation_trace_scopes` row for `scope_id` is checked before any insert, so +provenance never creates a scope implicitly. Through the `MutationTraceStore` +write seam, provenance therefore cannot be registered without an existing owning +`mutation_trace_scopes` row, and a missing scope leaves no orphan row behind. +That requirement is enforced in the store rather than by a `FOREIGN KEY`, leaving +`004_mutation_trace_protocol.sql` unchanged. + +End-to-end regressions in `hooks/mod.rs` drive both shipped adapters through a +real temporary Git repository and repository Agent Trace DB. Claude and Codex +`Bash` scopes preserve their canonical session and model into the final +mutation-derived Agent Trace; `ScopeId` proves ownership, while +`ScopeProvenance` describes the owning scope. + +## Related context + +- [Mutation-scope hook ingress](mutation-scope-hook-ingress.md) +- [Mutation-scope runtime: the harness-adapter contract](mutation-scope-runtime.md) +- [Mutation-trace store](mutation-trace-store.md) +- [Agent Trace DB](../sce/agent-trace-db.md) diff --git a/context/cli/mutation-scope-runtime.md b/context/cli/mutation-scope-runtime.md index 5c907ea6..34a19029 100644 --- a/context/cli/mutation-scope-runtime.md +++ b/context/cli/mutation-scope-runtime.md @@ -1,31 +1,24 @@ # Mutation-scope runtime: the harness-adapter contract -The crate-visible surface of `cli/src/services/mutation_trace/runtime/` and the -lifecycle contract every Codex, Claude Code, OpenCode, and Pi adapter must uphold. +The crate-visible surface of `cli/src/services/mutation_trace/runtime/` and the lifecycle contract every Codex, Claude Code, OpenCode, and Pi adapter must uphold. -Built by the `mutation-scope-runtime-integration` plan -(`context/plans/mutation-scope-runtime-integration.md`). The generic +Built by the `mutation-scope-runtime-integration` plan (`context/plans/mutation-scope-runtime-integration.md`). The generic [`sce hooks mutation-scope` ingress](mutation-scope-hook-ingress.md), shipped -Claude Code adapter, and Codex adapter (`sce hooks codex-mutation-scope`, -registered by `sce setup --codex`; OpenCode/Pi: none yet) drive this seam. This +Claude Code adapter, and Codex adapter (`sce hooks codex-mutation-scope`, registered by `sce setup --codex`; OpenCode/Pi: none yet) drive this seam. This file records the adapter contract; the Codex-specific mapping is in [`codex-mutation-scope-integration.md`](codex-mutation-scope-integration.md). -The mechanics live in [`mutation-trace-runtime-coordinator.md`](mutation-trace-runtime-coordinator.md) -(`coordinate()`), [`mutation-trace-scope-abandonment.md`](mutation-trace-scope-abandonment.md) -(`abandon_scope()`), [`mutation-trace-protected-worktree.md`](mutation-trace-protected-worktree.md) -(safety prefix), and [`mutation-trace-protocol.md`](mutation-trace-protocol.md) -(pure protocol). This file records what adapters must do and why. +The mechanics live in [`mutation-trace-runtime-coordinator.md`](mutation-trace-runtime-coordinator.md) (`coordinate()`), [`mutation-trace-scope-abandonment.md`](mutation-trace-scope-abandonment.md) (`abandon_scope()`), [`mutation-trace-protected-worktree.md`](mutation-trace-protected-worktree.md) (safety prefix), and [`mutation-trace-protocol.md`](mutation-trace-protocol.md) (pure protocol). This file records what adapters must do and why. ## The exported seam -`runtime/mod.rs` re-exports exactly nine names at `pub(crate)`, reachable as -`crate::services::mutation_trace::runtime::*`: +`runtime/mod.rs` re-exports exactly ten names at `pub(crate)`, reachable as `crate::services::mutation_trace::runtime::*`: | Name | From | Role | | --- | --- | --- | | `coordinate` | `coordinator` | the observed-boundary entrypoint | | `RuntimeBoundary` | `coordinator` | `Start` / `Advance` / `Close` / `Flush` | +| `StartProvenance` | `coordinator` | the optional `session_id` + `model_id?` a `Start` may carry ([contract](mutation-scope-provenance.md)) | | `CoordinateOutcome` | `coordinator` | its success value | | `CoordinateError` | `coordinator` | its error surface | | `ExternalTaintOperation` | `coordinator` | `Inspect` / `Persist`, carried by `CoordinateError::ExternalTaintMarker` | @@ -34,26 +27,17 @@ The mechanics live in [`mutation-trace-runtime-coordinator.md`](mutation-trace-r | `AbandonRecoveryReason` | `scope_runtime` | the reason inside `RecoveryRequired` | | `AbandonScopeError` | `scope_runtime` | its error surface | -`ExternalTaintOperation` crosses the boundary because it is part of -`CoordinateError`'s public shape. It lives in `protected_worktree.rs` and is -re-exported by `coordinator.rs` without making that module public. +`ExternalTaintOperation` crosses the boundary because it is part of `CoordinateError`'s public shape. It lives in `protected_worktree.rs` and is re-exported by `coordinator.rs` without making that module public. -Every runtime `mod` stays private, including `ProtectedWorktree`, its error, -`WORKTREE_LOCK_TIMEOUT`, and `reconcile_worktree`. Adapters drive only the two -entrypoints and never assemble the safety prefix. +Every runtime `mod` stays private, including `ProtectedWorktree`, its error, `WORKTREE_LOCK_TIMEOUT`, and `reconcile_worktree`. Adapters drive only the two entrypoints and never assemble the safety prefix. -The re-exports are the intentional crate-visible seam consumed by the generic -ingress; runtime internals stay private. The two re-export statements retain -`#[allow(unused_imports)]` because no consumer names the completing types yet. +The re-exports are the intentional crate-visible seam consumed by the generic ingress; runtime internals stay private. The two re-export statements retain `#[allow(unused_imports)]` because no consumer names the completing types yet. ## What a mutation scope is -**A scope is one independently mutation-capable execution**, not a session, -process, or harness. +**A scope is one independently mutation-capable execution**, not a session, process, or harness. -A main agent and subagent that can edit concurrently are two scopes with -**distinct `ScopeId`s**; sharing one would collapse their intervals and hide -`AiContended`. +A main agent and subagent that can edit concurrently are two scopes with **distinct `ScopeId`s**; sharing one would collapse their intervals and hide `AiContended`. A `ScopeId` is durably bound to one worktree. `abandon_scope()` rejects a target whose durable identity differs from the invoking checkout @@ -61,16 +45,15 @@ whose durable identity differs from the invoking checkout ## `Start` / `Advance` / `Close` -Each is a `RuntimeBoundary` passed to `coordinate()`, which captures a Git -snapshot, drives the protocol, and advances the worktree cursor to the observed +Each is a `RuntimeBoundary` passed to `coordinate()`, which captures a Git snapshot, drives the protocol, and advances the worktree cursor to the observed tree. The interval between two consecutive observed boundaries is what the protocol can attribute. -- **`Start { scope, event, actor_kind }`** — the scope's first boundary. The - protocol observes it only from `ScopeStatus::NeverSeen`; an accepted, observing - `Start` transitions the scope to `Active`. The event it emits attributes to the - scopes live *before* the activation, so a `Start` never attributes the - preceding interval to the scope it is starting. +- **`Start { scope, event, actor_kind, provenance? }`** — the scope's first + boundary, observed by the protocol only from `ScopeStatus::NeverSeen`; an + accepted, observing `Start` transitions the scope to `Active`. The event it + emits attributes to the scopes live *before* the activation, so a `Start` + never attributes the preceding interval to the scope it is starting. - **`Advance { scope, event, actor_kind }`** — every subsequent mutation boundary. Accepted only while the scope is live. - **`Close { scope, event, actor_kind }`** — the terminal observed boundary, @@ -82,7 +65,24 @@ protocol can attribute. All three scope-carrying variants supply `actor_kind`, and `coordinate()` registers the scope's durable `(worktree_id, actor_kind)` identity on every one of them, not only on `Start` — a mismatch against an existing row is -`CoordinateError::ScopeIdentityConflict`. +`CoordinateError::ScopeIdentityConflict`. Only `Start` may carry +`StartProvenance`, registered after that scope registration and before the +protocol commits, never inside `ProtocolState` or the CAS transition; a failure +aborts the `Start` as `CoordinateError::ScopeProvenanceRegistration`. + +That provenance step is conditional, and `coordinate()` decides using the +durable `ScopeState` its own `register_scope` call just returned. A provenance +row may only be **created** while that status is still `ScopeStatus::NeverSeen`, +so provenance is always an admission-time snapshot: once a scope has been +admitted by a committed protocol `Start`, absent provenance stays absent +permanently and a later replay carrying provenance persists nothing. Because the +rule keys on durable status rather than on the scope row's existence, the +legitimate retry still works — a first attempt that registered the scope but +never committed leaves it `NeverSeen`, so the retry may still register +provenance. An **existing** provenance row is loaded and re-registered on every +provenance-carrying `Start` regardless of status, so a replay naming a different +`session_id` remains an identity conflict. Contract in +[mutation-scope provenance](mutation-scope-provenance.md). Two obligations follow, and both are easy to get wrong: @@ -220,13 +220,10 @@ or needs rebaseline). ## Status -The seam is exported and the contract is recorded. It is now driven by the -generic `sce hooks mutation-scope` CLI ingress, which reads one normalized JSON -lifecycle object from STDIN and calls `coordinate()` -(`start` / `advance` / `close` / `flush`) or `abandon_scope()` (`abandon`) with a -lazy DB provider, translating `scope_id` / `event_id` / `actor_kind` verbatim, -refusing any `worktree_id` key, and classifying results by durable completion -rather than failing open. Full transport/normalization contract in +The seam is exported and driven by the generic `sce hooks mutation-scope` CLI +ingress, which strictly parses one normalized JSON lifecycle object from STDIN +into one `coordinate()` or `abandon_scope()` call with a lazy DB provider, and +classifies results by durable completion rather than failing open. Contract in [`mutation-scope-hook-ingress.md`](mutation-scope-hook-ingress.md); routing in [agent-trace hooks command routing](../sce/agent-trace-hooks-command-routing.md). @@ -234,9 +231,8 @@ A generic ingress existing is not full harness integration existing. The shipped Claude Code adapter (`cli/src/services/hooks/claude_mutation_scope/`, hidden `sce hooks claude-mutation-scope`) maps Claude's hook events onto this contract via the `pub(crate)` in-process seam -`mutation_scope::run_mutation_scope_from_payload`, is registered by `sce setup`, -and is covered by real-repository regressions against a real Agent Trace DB — its -full contract is in +`mutation_scope::run_mutation_scope_from_payload` and is registered by +`sce setup`; its full contract is in [`claude-mutation-scope-integration.md`](claude-mutation-scope-integration.md). A Codex adapter (`cli/src/services/hooks/codex_mutation_scope/`, hidden `sce hooks codex-mutation-scope`) also maps onto this contract through the same @@ -247,3 +243,8 @@ coverage boundary, and the checkout-local recovery bookkeeping) is in OpenCode and Pi have no adapter; each remaining harness still owns the `ScopeId` / `EventId` derivation and stale-process detection this contract requires, and repository-scoped unowned-checkout cleanup is still open. + +Real Claude and Codex `Bash` regressions exercise the complete runtime path +through commit and persisted Agent Trace JSON. They confirm that +`AiExclusive(scope)` supplies mutation attribution while `ScopeProvenance` +supplies the separately resolved session/model metadata. diff --git a/context/cli/mutation-trace-agent-attribution.md b/context/cli/mutation-trace-agent-attribution.md index 3539ba18..50fbc9fe 100644 --- a/context/cli/mutation-trace-agent-attribution.md +++ b/context/cli/mutation-trace-agent-attribution.md @@ -92,6 +92,12 @@ alongside `diff_trees`). added line is looked up at its exact committed-tree position: `MutationAi -> mutation AI coverage`, `MutationNonAi -> resolved non-AI`, `Unknown` / missing / content mismatch -> unresolved. + Mutation-AI coverage retains the contributing `ScopeId` through this lookup, + then resolves its durable scope provenance once per distinct scope. A + resolved session is copied to each contributing `TouchedLine`; a mutation-AI + hunk receives a model only when every contributing line has the same known + model. Missing provenance, a NULL model, or disagreement leaves the hunk + model unset without changing its AI classification. - **Conservative failure.** A page-query failure truncates history (the window is simply smaller and the baseline older). A tree-diff / patch-parse / structural-apply failure reloads the affected files to an all-`Unknown` @@ -133,12 +139,14 @@ no mutation-cursor state. `direct_coverage`; only committed lines it does not cover reach mutation history. `post_commit_patch_intersections` keeps its direct-only meaning and mutation evidence never enters `diff_traces`. -- **No fabricated provenance.** The mutation-AI patch is target-shaped and - carries no model, session, tool, or tool-version metadata. `ScopeId`, - `ActorKind`, and `AiExclusive(scope)` are never translated into direct - provenance. Hunk model/session and the top-level `tool` object still derive - from direct evidence only; mutation-only coverage merely widens `ai` / `mixed` - classification. See +- **No fabricated direct provenance.** The mutation-AI patch is target-shaped + and carries only the resolved observational session/model metadata described + above; it carries no tool or tool-version metadata. `ScopeId`, `ActorKind`, + and `AiExclusive(scope)` are never translated into direct provenance. The + current Agent Trace builder still derives hunk model/session and the + top-level `tool` object from direct evidence only; mutation-only coverage + widens `ai` / `mixed` classification while preserving the resolved metadata + for the combined attribution step. See [../sce/agent-trace-minimal-generator.md](../sce/agent-trace-minimal-generator.md). - **Final persistence.** The single combined Agent Trace (direct + mutation AI coverage) is validated against the embedded schema and stored in @@ -183,3 +191,7 @@ observable tree difference. persistence layers stay separated (`diff_traces` and `post_commit_patch_intersections` direct-only, `agent_traces.trace_json` combined). +- `hooks/mod.rs` (`mutation_provenance_e2e`) — real Claude and Codex `Bash` + adapter admission through generic mutation scope, commit, causal projection, + and persisted Agent Trace JSON; mutation protocol attribution remains + distinct from scope provenance. diff --git a/context/cli/mutation-trace-store.md b/context/cli/mutation-trace-store.md index d5e7f4ad..c1822f58 100644 --- a/context/cli/mutation-trace-store.md +++ b/context/cli/mutation-trace-store.md @@ -5,7 +5,10 @@ Durable persistence for the verified mutation-cursor protocol `mutation-cursor-store-persistence` plan. `store.rs` is the protocol's first real database call site: it stores worktree/scope/processed-event/mutation-event state in the repository-scoped Agent Trace DB (`RepositoryAgentTraceDb`) via -migration `004_mutation_trace_protocol.sql`. +migration `004_mutation_trace_protocol.sql`. A sixth, separate table added by +migration `005_mutation_scope_provenance.sql` holds +[mutation-scope provenance](mutation-scope-provenance.md) — observational +metadata about a scope, not protocol state. ## Boundary shape @@ -193,6 +196,20 @@ row and never auto-creates it. An existing scope is returned unchanged only when its stored `worktree_id` and `actor_kind` match the request; a mismatch returns `Err`. +`register_scope_provenance`/`load_scope_provenance` are the matching insert-once +seams for the separate `mutation_trace_scope_provenance` table; registration +requires an already-registered scope and never creates one implicitly. Both +seams are status-blind — deciding *whether* a `Start` may create provenance from +the `ScopeState` `register_scope` returns is the runtime's admission-bounded +rule, not the store's. See +[mutation-scope provenance](mutation-scope-provenance.md). + +The store boundary is covered end to end by adapter regressions: the mutation +event is written through the protocol tables, scope provenance is read during +post-commit projection, and only the final Agent Trace row receives the +combined mutation evidence. The direct `diff_traces` and +`post_commit_patch_intersections` layers remain separate. + ## Non-goals - No Git or filesystem I/O — `store.rs` itself calls neither Git nor the diff --git a/context/context-map.md b/context/context-map.md index 248e3497..99445183 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -28,12 +28,13 @@ Feature/domain context: - `context/cli/mutation-trace-revision-refinement.md` (the Quint `revision: int` → Rust `WorktreeState::revision: u64` bounded-integer refinement: the private `next_revision` checked-arithmetic helper `commit`/`taint`/`abandon`/`recover` all route through instead of a raw `+ 1`, so a worktree at `revision: u64::MAX` is a guarded no-op/rejection rather than a silent wrap to `0`) - `context/cli/mutation-trace-quint-connect.md` (`#[cfg(test)]`-only Quint Connect model-based-testing harness in `cli/src/services/mutation_trace/mbt/` continuously checking `protocol.rs` against `spec/mutation_cursor.qnt`: the verification-only `mbtAction`/`MbtAction` record-payload transport excluded from comparison, the operation-identity-vs-`MbtStutter` distinction on guarded/no-op branches with its two deterministic regressions, finite ID mapping, the AC5 comparable-state field list, `randomPrepare` staying a single `step` branch, deterministic/generated (500×30, seed-reproducible) test coverage, and the two Nix checks — generic `checks.cli-tests` and dedicated `checks.mutation-trace-quint-connect` — that both require the pinned Quint binary plus the top-level `spec/` directory in `workspaceSrc`'s Nix fileset) - `context/cli/mutation-trace-store.md` (durable persistence for the mutation-cursor protocol in `cli/src/services/mutation_trace/store.rs`, built by the `mutation-cursor-store-persistence` plan: the one-directional `protocol.rs` -> `DurableTransition::between` (pure structural diff) -> `store.rs` (SQL translation) -> `RepositoryAgentTraceDb` boundary; migration `004_mutation_trace_protocol.sql`'s five tables; `AttemptState`/`external_taint` non-persistence; the 8-byte big-endian `BLOB` revision encoding plus explicit non-`Debug` enum codecs; the bounded hot-path `load_worktree` read vs. the cold-path `load_mutation_event` and descending, exact-worktree, cursor-paged `load_mutation_event_page` reader capped at 32 rows; the public cold-path single-row `load_scope` scope seam that returns one `mutation_trace_scopes` row as `Option` without widening into a projection and, unlike `load_worktree`, never adjudicates worktree identity (a cross-worktree scope is returned as-is, and the mismatch is the caller's decision); the cold-path read-only `load_tree_roots` (one worktree) / `load_all_tree_roots` (repository-wide) durable-tree-SHA queries for ref reconciliation, each a single-statement `UNION` of `cursor_tree`/`before_tree`/`after_tree` read from one DB snapshot; `commit`'s single-`BEGIN IMMEDIATE` CAS batch via `TursoDb::execute_transactional_cas_batch`, distinguishing `Conflict`/retryable-transient/deterministic-`Err` outcomes; and the store's non-goals — no Git/filesystem I/O, no attribution/boundary-kind decisions, no retry-after-`Conflict` loop, no row deletion) -- `context/cli/mutation-trace-runtime-coordinator.md` (imperative-shell runtime layer in `cli/src/services/mutation_trace/runtime/`, built by the `mutation-cursor-runtime-coordinator` plan: the per-worktree OS advisory lock, `runtime::worktree_lock::WorktreeLock::acquire(git_dir, timeout)` at `/sce/mutation-cursor.lock`, bounded `try_lock()` polling with a distinct matchable timeout error and RAII release, and its distinction from the separate checkout-identity-creation lock; the isolated Git snapshot service `runtime::git_snapshot::GitSnapshotService` (documented in `mutation-trace-snapshot-service.md`); and `runtime::coordinator`'s protocol-integration pipeline (`RuntimeBoundary`/`CoordinateOutcome`/`CoordinateError`, the load → recover-if-needed → prepare/commit CAS-retry loop, and its own bounded snapshot-failure taint-retry loop) plus the public `coordinate(repository_root, boundary, open_db)` entrypoint that runs the shared `runtime::protected_worktree` prefix (`WorktreeLock` -> external-taint write-ahead fence -> `WorktreeId`, extracted so a second entrypoint cannot drift from it and documented in `mutation-trace-protected-worktree.md`), invokes the caller-supplied `open_db` provider so DB acquisition falls inside that fence, drives the pipeline under one held lock, and clears the marker through `ProtectedWorktree::complete()` only on success; `runtime/tests.rs` covers the public `coordinate()` API end to end against real linked worktrees and a real Agent Trace DB; an inherited external-taint marker is overlaid onto `protocol::database_failure` recovery on the next invocation, against the single captured snapshot and re-injected across a losing recovery CAS; the private `runtime::ref_reconciliation` per-worktree snapshot-ref maintenance pass (documented in `mutation-trace-ref-reconciliation.md`) shares the same `WorktreeLock`; `coordinate()` and `abandon_scope()` are now `pub(crate)` re-exported from `runtime/mod.rs` (documented in `mutation-scope-runtime.md`) while the runtime submodules themselves stay private; a generic `sce hooks mutation-scope` CLI ingress now drives both entrypoints, joined by a first Claude Code adapter driver, registered by `sce setup` and reachable, documented in `mutation-scope-runtime.md`) +- `context/cli/mutation-scope-provenance.md` (the insert-once `mutation_trace_scope_provenance` table from migration `005_mutation_scope_provenance.sql` and its `MutationTraceStore::register_scope_provenance` / `load_scope_provenance` seams: `scope_id -> session_id + model_id?` metadata *about* an established scope, stored beside but never inside protocol state — outside `ProtocolState` and the CAS batch, and never an input to `IneligibleUnscoped` / `AiExclusive` / `AiContended`; `session_id` is immutable identity and `model_id` immutable first-observed metadata, so the first persisted row wins in both directions and only a differing `session_id` for an existing `scope_id` errors; the supported `MutationTraceStore` write seam requires an existing owning `mutation_trace_scopes` row and never creates one implicitly, enforced in the store rather than by a `FOREIGN KEY`, leaving `004_mutation_trace_protocol.sql` unchanged; creation is additionally admission-bounded in the runtime — a row may be created only while the durable scope is `NeverSeen`, so after admission absent provenance stays absent permanently and a later `Start` replay cannot backfill it, while an existing row is still validated on every provenance-carrying `Start`; producers own canonicalization — both Codex and Claude adapters supply provenance today, with Claude resolving exact `(cc_, agent_id)` model state at admission and degrading unavailable models to `NULL`) +- `context/cli/mutation-trace-runtime-coordinator.md` (imperative-shell runtime layer in `cli/src/services/mutation_trace/runtime/`, built by the `mutation-cursor-runtime-coordinator` plan: the per-worktree OS advisory lock, `runtime::worktree_lock::WorktreeLock::acquire(git_dir, timeout)` at `/sce/mutation-cursor.lock`, bounded `try_lock()` polling with a distinct matchable timeout error and RAII release, and its distinction from the separate checkout-identity-creation lock; the isolated Git snapshot service `runtime::git_snapshot::GitSnapshotService` (documented in `mutation-trace-snapshot-service.md`); and `runtime::coordinator`'s protocol-integration pipeline (`RuntimeBoundary`/`CoordinateOutcome`/`CoordinateError`, the load → recover-if-needed → prepare/commit CAS-retry loop, and its own bounded snapshot-failure taint-retry loop) plus the public `coordinate(repository_root, boundary, open_db)` entrypoint that runs the shared `runtime::protected_worktree` prefix (`WorktreeLock` -> external-taint write-ahead fence -> `WorktreeId`, extracted so a second entrypoint cannot drift from it and documented in `mutation-trace-protected-worktree.md`), invokes the caller-supplied `open_db` provider so DB acquisition falls inside that fence, drives the pipeline under one held lock, and clears the marker through `ProtectedWorktree::complete()` only on success; `runtime/tests.rs` covers the public `coordinate()` API end to end against real linked worktrees and a real Agent Trace DB; an inherited external-taint marker is overlaid onto `protocol::database_failure` recovery on the next invocation, against the single captured snapshot and re-injected across a losing recovery CAS; the private `runtime::ref_reconciliation` per-worktree snapshot-ref maintenance pass (documented in `mutation-trace-ref-reconciliation.md`) shares the same `WorktreeLock`; `coordinate()` and `abandon_scope()` are now `pub(crate)` re-exported from `runtime/mod.rs` (documented in `mutation-scope-runtime.md`) while the runtime submodules themselves stay private; a generic `sce hooks mutation-scope` CLI ingress now drives both entrypoints, joined by current Claude Code and Codex adapter drivers, registered by `sce setup`/`sce setup --codex` and reachable, documented in `mutation-scope-runtime.md`) - `context/cli/mutation-trace-protected-worktree.md` (the shared safety prefix every mutation-cursor runtime entrypoint runs behind, in `cli/src/services/mutation_trace/runtime/protected_worktree.rs`, extracted from `coordinate()` by the `mutation-scope-runtime-integration` plan so a second entrypoint cannot drift from it: `ProtectedWorktree::acquire(repository_root)` running the safety-critical fixed order resolve `git_dir` → `WorktreeLock` (module-owned 10s `WORKTREE_LOCK_TIMEOUT`) → `ExternalTaintMarker::exists()` → `persist()` (fence armed write-ahead of every fallible step that follows, including DB acquisition) → `get_or_create_checkout_id` as `WorktreeId`; the `worktree_id()` / `inherited_external_taint()` / consuming `complete()` surface, where `complete()` clears the marker under the still-held lock and is the only thing that ever clears it while `Drop` releases only the lock; and the one-variant-per-prefix-step `ProtectedWorktreeError` (`GitDirResolution` | `LockAcquisition` | `ExternalTaintMarker { operation, source }` | `CheckoutIdentity`) each entrypoint maps onto its own error surface — `coordinate()` onto exactly the `CoordinateError` variants that step produced before the extraction) - `context/cli/mutation-trace-scope-abandonment.md` (the mutation-cursor runtime's second protected entrypoint in `cli/src/services/mutation_trace/runtime/scope_runtime.rs`, built by the `mutation-scope-runtime-integration` plan and the first production call site for `protocol::abandon`: `abandon_scope(repository_root, scope, open_db) -> Result` retires a scope whose final worktree boundary was never observed, sharing `coordinate()`'s `ProtectedWorktree` prefix but deliberately capturing **no** Git snapshot, pin, diff, reconciliation, scope registration, or worktree initialization — so abandonment is not a `RuntimeBoundary` and needs no Quint change; the classify-before-transition order that recovers what `protocol::abandon`'s uniform guarded no-op cannot report (`load_scope` first for the missing-row and cross-worktree cases the projection seam treats as errors, then `load_worktree` for the `NeverSeen` / terminal / `Active` split); the `Abandoned` / `AlreadyTerminal` / `RecoveryRequired` outcomes and the `InheritedExternalTaint` | `MissingScope` | `NeverSeenScope` | `MissingWorktreeState` recovery reasons; the inherited-marker short-circuit that returns before the DB provider is ever invoked; the fence-completion rule that clears the marker only for a settled abandonment or proven-terminal no-op and leaves it armed for every recovery-required outcome and every error, with `MarkerClearAfterCompletion` carrying the already-settled outcome; the CAS retry bounded by the coordinator's shared `MAX_CAS_RETRY_ATTEMPTS`, settling on a competitor's terminal status rather than overwriting it; and the deliberate false-negative tradeoff whereby a missing or `NeverSeen` target forces conservative strong recovery that may abandon unrelated live scopes, because attribution safety outranks preserving potentially valid evidence) -- `context/cli/mutation-scope-runtime.md` (the crate-visible mutation-trace runtime seam and the lifecycle contract every current or future harness adapter must uphold, recorded by the `mutation-scope-runtime-integration` plan: the nine `pub(crate)` re-exports in `runtime/mod.rs` (`coordinate`, `RuntimeBoundary`, `CoordinateOutcome`, `CoordinateError`, `ExternalTaintOperation`, `abandon_scope`, `AbandonScopeOutcome`, `AbandonRecoveryReason`, `AbandonScopeError`), with `ExternalTaintOperation` riding through `coordinator.rs`'s own `pub use` because `CoordinateError::ExternalTaintMarker` carries it — so the type becomes crate-visible without `protected_worktree` becoming a public module — while every `mod` declaration stays private and `ProtectedWorktree`/`ProtectedWorktreeError`/`WORKTREE_LOCK_TIMEOUT`/`reconcile_worktree` stay internal, kept clippy-clean by `#[allow(unused_imports)]` on the re-exports per the `services/style.rs` precedent rather than a placeholder consumer; and the adapter obligations themselves — a scope is one independently mutation-capable execution so a concurrent main agent and subagent need distinct `ScopeId`s, `Start`/`Advance`/`Close` semantics including that a failed tool still requires `Advance` (the boundary is an observation, not a successful edit) and that a `ScopeId` is never reused after a terminal status (the `NeverSeen` guard silently refuses to reactivate it), `abandon_scope()` requiring positive staleness evidence and never inferring it from `ActorKind`, the `abandon` → `coordinate(Start(successor))` sequence with what each outcome implies for it including that a failed abandonment must never be treated as a safely started successor, that abandonment is not a `RuntimeBoundary` and needs no Quint change, the D1 tradeoff whereby a missing or `NeverSeen` target deliberately forces conservative strong recovery that may invalidate unrelated live scopes because attribution safety outranks preserving potentially valid evidence, and the attribution boundary that `AiExclusive(scope)` means scope exclusivity only and is not standalone proof that no human edited the worktree; a generic `sce hooks mutation-scope` ingress now drives the seam, joined by current Claude Code and Codex adapter drivers, both registered by `sce setup` and reachable — OpenCode/Pi remain unwired) -- `context/cli/mutation-scope-hook-ingress.md` (the one harness-neutral CLI ingress that drives the mutation-scope runtime, in `cli/src/services/hooks/mutation_scope.rs`, built by the `mutation-scope-hook-ingress` plan: the hidden `sce hooks mutation-scope` command routing through the normal `cli_schema::HooksSubcommand::MutationScope` → `convert_hooks_subcommand_request` → `services::hooks::HookSubcommand::MutationScope` → `run_hooks_subcommand_in_repo` stack, reading one normalized JSON lifecycle object from STDIN; the strict `parse_mutation_scope_payload` contract supporting exactly `start`/`advance`/`close`/`flush`/`abandon` with a local `MutationScopePayload` transport enum, `claude_code`/`codex`/`opencode`/`pi` actor mapping, non-blank `scope_id`/`event_id`, exact per-operation key sets, and a dedicated hard rejection for any `worktree_id` key; the operation mapping to `RuntimeBoundary::Start`/`Advance`/`Close`/`Flush` through `coordinate()` or a direct `abandon_scope()` call, forwarding `ScopeId`/`EventId`/`ActorKind` verbatim because `EventId` equality is the runtime replay/idempotency key; identity ownership — the adapter owns `scope_id`/`event_id`/`actor_kind`, SCE owns `worktree_id`/Git tree identities/revisions/attempt IDs, and worktree identity is derived only by the runtime from the invoking checkout; the lazy `FnOnce` DB provider reusing `open_agent_trace_db_for_hook_runtime` so DB acquisition stays inside the runtime's protected-worktree ordering; the non-fail-open error classification by durable completion — malformed payload or any pre-completion `CoordinateError`/`AbandonScopeError` → `CliError`/non-zero, while `CoordinateError::MarkerClearAfterCommit` and `AbandonScopeError::MarkerClearAfterCompletion` are treated as durable success with empty stdout, the marker-cleanup failure logged via `sce.hooks.mutation_scope.marker_clear_after_durable_completion`, and the transition not retried; the empty-stdout success contract; abandonment ownership keeping no-snapshot semantics; and the generic-ingress vs harness-adapter boundary — a Claude Code adapter driver (`cli/src/services/hooks/claude_mutation_scope/`) and a Codex adapter driver (`cli/src/services/hooks/codex_mutation_scope/`, hidden `sce hooks codex-mutation-scope`) now exist and are registered by `sce setup`, both consuming this seam's own `pub(crate)` in-process entrypoint; OpenCode/Pi still have no lifecycle adapter, no session→`ScopeId` / tool-call→`EventId` derivation, no PID/staleness detection) -- `context/cli/claude-mutation-scope-integration.md` (the first concrete harness lifecycle adapter, `cli/src/services/hooks/claude_mutation_scope/`, hidden command `sce hooks claude-mutation-scope`, built by the `claude-mutation-scope-integration` plan: one independently mutation-capable Claude tool execution = one SCE mutation `ScopeId` (a session/prompt/main agent/subagent is never a scope); the `classify_tool` table (mutation-capable including unknown names, read-only `Read`/`Glob`/`Grep`/`WebFetch`/`WebSearch`/`AskUserQuestion`, `Agent` = delegation) plus the model-only `is_explicit_background_shell` predicate; the length-prefixed hash-free `cc-tool-v1|n=|s=..|a=..|t=..` `ScopeId` keyed on a monotonic checkout-local `attempt_seq` (never reused after terminal) with deterministic `|start` / `|close` `EventId`s; the `/sce/claude-mutation-scope-state.json` bookkeeping store (never attribution evidence, never synced) with its own separate lock never held across a seam call; `PreToolUse` write-ahead `pending_start` → seam `start` → `active` and its fail-closed Claude `permissionDecision: "deny"` on any failure (never `allow`, detail logged via `sce.hooks.claude_mutation_scope.pre_tool_use_fail_closed`); `PostToolUse`/`PostToolUseFailure` → `close`, with `pending_start`+terminal → abandon-not-late-start (D11) and failed-`close` → abandon-not-replay (D12); the abandonment cleanup signals (`PermissionDenied`, `Stop`/`StopFailure`, `UserPromptSubmit`, `SubagentStop`, `SessionEnd`, best-effort `WorktreeRemove` — the last two not observed to fire on Claude Code `2.1.258`); the `recovery_pending` barrier that denies new mutation-capable `PreToolUse` until quiescent then runs one seam `flush`; raw hook `cwd` (or `worktree_path` for `WorktreeRemove`) as authoritative repository root with no adapter-constructed `WorktreeId`; the `run_in_background = true` denial and the separate self-detaching-descendant unsupported boundary (D20, with T04's Git-observable evidence); the ten unmatched `sce setup` registrations; and the strict `claude_mutation_scope → hooks::mutation_scope → mutation_trace::runtime` dependency direction through the single `run_mutation_scope_from_payload` seam import (T05), proven against real Git repositories and a real Agent Trace DB by T08) +- `context/cli/mutation-scope-runtime.md` (the crate-visible mutation-trace runtime seam and the lifecycle contract every current or future harness adapter must uphold, recorded by the `mutation-scope-runtime-integration` plan: the ten `pub(crate)` re-exports in `runtime/mod.rs` (`coordinate`, `RuntimeBoundary`, `StartProvenance`, `CoordinateOutcome`, `CoordinateError`, `ExternalTaintOperation`, `abandon_scope`, `AbandonScopeOutcome`, `AbandonRecoveryReason`, `AbandonScopeError`), with `ExternalTaintOperation` riding through `coordinator.rs`'s own `pub use` because `CoordinateError::ExternalTaintMarker` carries it — so the type becomes crate-visible without `protected_worktree` becoming a public module — while every `mod` declaration stays private and `ProtectedWorktree`/`ProtectedWorktreeError`/`WORKTREE_LOCK_TIMEOUT`/`reconcile_worktree` stay internal, kept clippy-clean by `#[allow(unused_imports)]` on the re-exports per the `services/style.rs` precedent rather than a placeholder consumer; and the adapter obligations themselves — a scope is one independently mutation-capable execution so a concurrent main agent and subagent need distinct `ScopeId`s, `Start`/`Advance`/`Close` semantics including that only `Start` may carry the optional `StartProvenance`, registered after scope registration and before the protocol commits and never inside `ProtocolState` or the CAS transition, with that registration conditional on the `ScopeState` `register_scope` returns so a provenance row may only be created while the scope is `NeverSeen` (an admission-time snapshot that a post-admission replay cannot backfill) while an existing row is still validated on every provenance-carrying `Start`, that a failed tool still requires `Advance` (the boundary is an observation, not a successful edit) and that a `ScopeId` is never reused after a terminal status (the `NeverSeen` guard silently refuses to reactivate it), `abandon_scope()` requiring positive staleness evidence and never inferring it from `ActorKind`, the `abandon` → `coordinate(Start(successor))` sequence with what each outcome implies for it including that a failed abandonment must never be treated as a safely started successor, that abandonment is not a `RuntimeBoundary` and needs no Quint change, the D1 tradeoff whereby a missing or `NeverSeen` target deliberately forces conservative strong recovery that may invalidate unrelated live scopes because attribution safety outranks preserving potentially valid evidence, and the attribution boundary that `AiExclusive(scope)` means scope exclusivity only and is not standalone proof that no human edited the worktree; a generic `sce hooks mutation-scope` ingress now drives the seam, joined by current Claude Code and Codex adapter drivers, both registered by `sce setup` and reachable — OpenCode/Pi remain unwired) +- `context/cli/mutation-scope-hook-ingress.md` (the one harness-neutral CLI ingress that drives the mutation-scope runtime, in `cli/src/services/hooks/mutation_scope.rs`, built by the `mutation-scope-hook-ingress` plan: the hidden `sce hooks mutation-scope` command routing through the normal `cli_schema::HooksSubcommand::MutationScope` → `convert_hooks_subcommand_request` → `services::hooks::HookSubcommand::MutationScope` → `run_hooks_subcommand_in_repo` stack, reading one normalized JSON lifecycle object from STDIN; the strict `parse_mutation_scope_payload` contract supporting exactly `start`/`advance`/`close`/`flush`/`abandon` with a local `MutationScopePayload` transport enum, `claude_code`/`codex`/`opencode`/`pi` actor mapping, non-blank `scope_id`/`event_id`, exact per-operation key sets, an optional `provenance` object accepted on `start` only (a required non-blank `session_id`, an optional `model_id` where an absent key and an explicit `null` both mean no model, and no other key), and a dedicated hard rejection for any `worktree_id` key; the operation mapping to `RuntimeBoundary::Start`/`Advance`/`Close`/`Flush` through `coordinate()` or a direct `abandon_scope()` call, forwarding `ScopeId`/`EventId`/`ActorKind` and `start`'s optional `StartProvenance` verbatim because `EventId` equality is the runtime replay/idempotency key and provenance values arrive already canonical; identity ownership — the adapter owns `scope_id`/`event_id`/`actor_kind`, SCE owns `worktree_id`/Git tree identities/revisions/attempt IDs, and worktree identity is derived only by the runtime from the invoking checkout; the lazy `FnOnce` DB provider reusing `open_agent_trace_db_for_hook_runtime` so DB acquisition stays inside the runtime's protected-worktree ordering; the non-fail-open error classification by durable completion — malformed payload or any pre-completion `CoordinateError`/`AbandonScopeError` → `CliError`/non-zero, while `CoordinateError::MarkerClearAfterCommit` and `AbandonScopeError::MarkerClearAfterCompletion` are treated as durable success with empty stdout, the marker-cleanup failure logged via `sce.hooks.mutation_scope.marker_clear_after_durable_completion`, and the transition not retried; the empty-stdout success contract; abandonment ownership keeping no-snapshot semantics; and the generic-ingress vs harness-adapter boundary — a Claude Code adapter driver (`cli/src/services/hooks/claude_mutation_scope/`) and a Codex adapter driver (`cli/src/services/hooks/codex_mutation_scope/`, hidden `sce hooks codex-mutation-scope`) now exist and are registered by `sce setup`, both consuming this seam's own `pub(crate)` in-process entrypoint; OpenCode/Pi still have no lifecycle adapter, no session→`ScopeId` / tool-call→`EventId` derivation, no PID/staleness detection) +- `context/cli/claude-mutation-scope-integration.md` (the first concrete harness lifecycle adapter, `cli/src/services/hooks/claude_mutation_scope/`, hidden command `sce hooks claude-mutation-scope`, built by the `claude-mutation-scope-integration` plan: one independently mutation-capable Claude tool execution = one SCE mutation `ScopeId` (a session/prompt/main agent/subagent is never a scope); the `classify_tool` table (mutation-capable including unknown names, read-only `Read`/`Glob`/`Grep`/`WebFetch`/`WebSearch`/`AskUserQuestion`, `Agent` = delegation) plus the model-only `is_explicit_background_shell` predicate; the length-prefixed hash-free `cc-tool-v1|n=|s=..|a=..|t=..` `ScopeId` keyed on a monotonic checkout-local `attempt_seq` (never reused after terminal) with deterministic `|start` / `|close` `EventId`s; the `/sce/claude-mutation-scope-state.json` bookkeeping store (never attribution evidence, never synced) with its own separate lock never held across a seam call; `PreToolUse` write-ahead `pending_start` → seam `start` → `active` and its fail-closed Claude `permissionDecision: "deny"` on any failure (never `allow`, detail logged via `sce.hooks.claude_mutation_scope.pre_tool_use_fail_closed`); `PostToolUse`/`PostToolUseFailure` → `close`, with `pending_start`+terminal → abandon-not-late-start (D11) and failed-`close` → abandon-not-replay (D12); the abandonment cleanup signals (`PermissionDenied`, `Stop`/`StopFailure`, `UserPromptSubmit`, `SubagentStop`, `SessionEnd`, best-effort `WorktreeRemove` — the last two not observed to fire on Claude Code `2.1.258`); the `recovery_pending` barrier that denies new mutation-capable `PreToolUse` until quiescent then runs one seam `flush`; raw hook `cwd` (or `worktree_path` for `WorktreeRemove`) as authoritative repository root with no adapter-constructed `WorktreeId`; the `run_in_background = true` denial and the separate self-detaching-descendant unsupported boundary (D20, with T04's Git-observable evidence); the ten unmatched `sce setup` registrations; the strict `claude_mutation_scope → hooks::mutation_scope → mutation_trace::runtime` dependency direction through the single `run_mutation_scope_from_payload` seam import (T05); admission-time exact model-state snapshot into `ScopeProvenance`; and real Git/Agent Trace persistence coverage shared with the Codex path) - `context/cli/mutation-trace-ref-reconciliation.md` (the conservative per-worktree snapshot-ref reconciliation pass in `cli/src/services/mutation_trace/runtime/ref_reconciliation.rs`, built by the `mutation-cursor-ref-reconciliation` plan: `reconcile_worktree(repository_root, open_db)` → `pub(super) reconcile_worktree_inner(.., on_lock_contention)`, both returning `ReconciliationOutcome` (`Reconciled(ReconciliationReport { local_required, retained, deleted })` for a pass that ran | `SkippedNoCheckoutIdentity` — an `Ok`, not an `Err` — when no current checkout identity could be derived), a variant-per-fallible-step `ReconcileError` with no `Other`, and the module-owned `RECONCILIATION_LOCK_TIMEOUT`; the two-invariant model — a strictly per-worktree fail-closed local-consistency check via `load_tree_roots(W)` vs. a repository-wide deletion-safety set via `load_all_tree_roots()` so an `A`-owned ref is retained whenever any worktree still durably needs its tree — run entirely under the same `/sce/mutation-cursor.lock` `WorktreeLock` `coordinate()` holds, with the repository-wide read kept coherent by being one SQL statement / one DB snapshot rather than a repository-global lock; deletes only SCE-owned refs via one atomic `git update-ref --no-deref --stdin`, writes no `mutation_trace_*` row, never arms `ExternalTaintMarker`, runs no `git gc`; imperative durability maintenance below the verified Quint protocol; reclaims orphan/unreferenced refs only for the namespace of a checkout id a current worktree still derives — a namespace no current worktree owns (identity-based: a deleted linked worktree, or checkout-id metadata loss followed by `get_or_create_checkout_id` minting a fresh id on a still-present worktree) is beyond every per-worktree pass and left to a recorded future repository-scoped unowned-namespace operation, so the current pass does not bound all orphan-ref growth; `reconcile_worktree` has no `pub(crate)` re-export and no harness/command wiring yet) - `context/cli/mutation-trace-snapshot-service.md` (the isolated Git snapshot and ref-pinning service `runtime::git_snapshot::GitSnapshotService` in `cli/src/services/mutation_trace/runtime/git_snapshot.rs`: `new` resolving an absolute `git_dir`, `capture_tree` snapshotting staged/unstaged/untracked/deleted worktree state into the repository's normal object database via a throwaway temp index, `pin_tree` protecting a durable tree with a create-only idempotent **direct** `refs/sce/mutation-cursor//` ref, `diff_trees` emitting `patch.rs`-parseable raw diff text; plus the callerless reconciliation substrate — worktree-scoped `list_pins` inventory returning `Result, PinInventoryError>` that rejects a symbolic ref inside the namespace (mutation-cursor pins are direct refs) as `MalformedRef`, matchable separately from a `git for-each-ref` execution failure, and conditional-atomic `delete_pins` running one `git update-ref --no-deref --stdin` transaction of SHA-conditioned deletes — no-dereference so an inventory→delete direct-ref→symref race cannot escape the inventoried namespace, plus a fail-closed pre-check — that aborts whole if any ref changed since inventory; `REF_NAMESPACE` + `pin_ref_prefix` as the single source of truth for the pin path) - `context/cli/mutation-trace-external-taint.md` (the worktree-local mutation-cursor durability boundary in `cli/src/services/mutation_trace/runtime/external_taint.rs`, built by the `mutation-cursor-external-taint` plan: the `ExternalTaintMarker` primitive — `new(git_dir)`/`exists()`/`persist()`/`clear()` over an empty file at `/sce/mutation-cursor-tainted` whose existence is its entire state, `checkout::persist_checkout_id_inner`-style durability (`sync_data` plus best-effort `#[cfg(unix)]` parent-dir `sync_all`), idempotent persist/clear, `NotFound`-on-clear as success, no `Drop` deletion — as the concrete runtime refinement of the abstract `ProtocolState.external_taint`; armed by the reshaped `coordinate()` entrypoint write-ahead after the `WorktreeLock` and before Agent Trace DB acquisition (a caller-supplied DB provider closure), cleared only on a successful `CoordinateOutcome`, with dedicated fail-closed pre-commit `CoordinateError::ExternalTaintMarker` (`Inspect`/`Persist` only)/`AgentTraceDbUnavailable` variants plus a post-commit `MarkerClearAfterCommit { source, committed }` that carries the durable outcome so a failed trailing clear never hides a committed `MutationEvent`; an inherited marker seeds an invocation-local `external_taint_pending` flag that overlays `protocol::database_failure` onto each freshly loaded projection so `recover` runs once against the captured snapshot, held across a losing recovery CAS and cleared once it lands) @@ -102,7 +103,8 @@ Feature/domain context: Additional mutation-scope integration context: -- `context/cli/codex-mutation-scope-integration.md` (the second concrete harness adapter: Codex tracked/delegation/untracked classification, partial-by-tool-surface coverage, identity and checkout-local recovery state, write-ahead fail-closed lifecycle, cleanup signals, boundary-aware attribution confirmation, and Codex setup/doctor ownership) +- `context/cli/mutation-scope-provenance.md` (observational `ScopeProvenance` keyed by `ScopeId`: insert-once canonical session plus nullable first-observed model, admission-bounded creation while the owning scope is `NeverSeen`, exact producer snapshots for Claude and Codex, read-time enrichment of mutation-AI lines and conservative Agent Trace hunk model agreement, and the explicit boundary that mutation protocol attribution proves ownership while scope provenance describes the owning scope; real Claude/Codex `Bash` persistence regressions cover the full path) +- `context/cli/codex-mutation-scope-integration.md` (the second concrete harness adapter: Codex tracked/delegation/untracked classification, partial-by-tool-surface coverage, identity and checkout-local recovery state, write-ahead fail-closed lifecycle, cleanup signals, boundary-aware attribution confirmation, Codex setup/doctor ownership, and the scope provenance it sends with every tracked `Start` — the `cx_`-prefixed canonical session plus the normalized `model` read off the same `PreToolUse` payload, for both `Bash` and `apply_patch`, with a deliberately lenient `model` read so an absent, blank, or non-string model records no model instead of denying a mutation-capable tool) - `context/sce/codex-apply-patch-diff-runtime.md` (the complementary Codex `PostToolUse(apply_patch)` parsing, path containment, normalization, and `diff_traces` evidence contract) Working areas: diff --git a/context/glossary.md b/context/glossary.md index 1dd25440..b798bedc 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -1,7 +1,6 @@ # Glossary - `pkl-check-generated`: Flake app exposed as `nix run .#pkl-check-generated`; canonical ephemeral-generation check that rejects committed target/schema/mirror outputs, evaluates exact workflow metadata, the generated artifact contract, semantic layout/path/inventory/content/parity/observational checks, and the optional-workflow manifest's content against the catalog, requires the shared helper-composition rule and SCE-scoped workflow prohibitions, enforces ordered catalog-derived OpenCode skill permissions plus explicit-permission artifact integrity, rejects stale sibling-package references or unresolved internalization tokens in workflow entrypoint `SKILL.md` documents, proves contract failures through checked-in negative fixtures, and delegates deterministic generation plus payload/input inventories to the generated-input producer while preserving its established inventory report. -- `repo-level verification preference`: Current repository guidance that contributor-facing validation/check flows should prefer `nix flake check`; direct Cargo verification commands are secondary and used only when explicitly requested or for narrow targeted debugging, while `cargo fmt` remains the explicit autofix path. -- lightweight post-task verification baseline: Required quick checks after each completed task in this repo: `nix run .#pkl-check-generated` and `nix flake check`. +- `repo-level verification preference`: Current repository guidance that contributor-facing validation/check flows should prefer `nix flake check`; direct Cargo verification commands are secondary and used only when explicitly requested or for narrow targeted debugging, while `cargo fmt` remains the explicit autofix path. Required quick checks after each completed task in this repo are `nix run .#pkl-check-generated` and `nix flake check`. - disposable plan lifecycle: Policy where `context/plans/` holds active execution artifacts only; completed plans are disposable and durable outcomes must be reflected in current-state context files and/or `context/decisions/`. - important change (context sync): A completed task change that affects cross-cutting behavior, repository-wide policy/contracts, architecture boundaries, or canonical terminology; these changes require root context edits in `context/overview.md`, `context/architecture.md`, and/or `context/glossary.md` instead of verify-only handling. `setup config preflight` is the Git-root-gated check that validates an existing repo-local `.sce/config.json` before prompts, context bootstrap, lifecycle initialization, hooks, or target asset installation; invalid config fails setup closed, absent config remains eligible for create-if-missing bootstrap, Agent Trace storage has the parallel strict rule for invalid discovered config layers, and ordinary startup consumers retain degraded-default behavior. See [the fail-closed boundary decision](decisions/2026-08-26-setup-storage-fail-closed-on-invalid-config.md). - verify-only root context pass: Context-sync mode for localized tasks where root-level behavior, architecture, and terminology are unchanged; root shared files are checked against code truth but are not edited by default. @@ -83,6 +82,7 @@ - `structured patch service`: Pure synchronous Rust service in `cli/src/services/structured_patch.rs` that derives supported structured editor hook payloads into canonical `ParsedPatch` values. The current implemented source is Claude `PostToolUse` payloads for `Write` creates and `Edit` structured patches; wired into `sce hooks diff-trace` for Claude payload classification at intake and into `RepositoryAgentTraceDb::recent_diff_trace_patches` for post-commit structured payload parsing, where persisted row `model_id` is assigned to every hunk and persisted canonical row `session_id` to every touched line before downstream reconstruction. - `MutationLineage` / `causal mutation-lineage attribution`: `cli/src/services/mutation_trace/lineage.rs` is a pure module holding, per repo path, a vector of `(content, LineProvenance)` where `LineProvenance` is `Unknown`, `MutationAi { scope_id }`, or `MutationNonAi`. `MutationLineage::apply(patch, origin)` advances it one reconstructed tree transition at a time, applying hunks structurally: context lines carry provenance forward, removed lines are deleted permanently, added lines take only the introducing `TransitionOrigin` (`MutationAi` for healthy untainted `AiExclusive`, `MutationNonAi` for any other recorded event, `Unobserved` → `Unknown` for gap reloads and the latest-tree→commit-tree tail); a structurally inconsistent transition is a `LineageError` and the caller reloads that file to an all-`Unknown` baseline. Historical mutation patches are never matched independently against the committed patch — that earlier `MutationPatchEvidence` / strict-matcher model let stale evidence resurrect and is removed. `attribution.rs` now only supplies `exclude_direct_coverage` (drop directly covered lines) and `patch_for_locations`. The store supplies events through an exact-worktree-scoped `mutation-event page` ordered by descending 8-byte big-endian revision, continued with an exclusive revision cursor, capped at 32 rows; plus `latest_mutation_event_revision` for the commit attribution cut. The `bounded mutation-history consumer` (`resolve_bounded_mutation_attribution` in `cli/src/services/mutation_trace/runtime/mutation_attribution.rs`) loads the newest ≤128 in-cut events through `MutationEventPageSource` + `TreeReadSource` (`diff_trees` + `file_at_tree`) seams, replays them oldest-to-newest from a conservative baseline through the tail into `commit_tree`, then projects surviving provenance onto the committed patch; it is current-worktree-only and timestamp-independent, owns `MAX_MUTATION_ATTRIBUTION_EVENTS = 128` (event 128 may contribute, 129 is never loaded), tracks database (`loaded_pages`/`loaded_rows`) apart from Git (`inspected_events`/`reconstructed_events`/`gap_resets`) work, and records a `mutation-attribution barrier` on any page-query or reconstruction/tail failure while still returning conservative results. See [mutation-trace store](cli/mutation-trace-store.md) and [mutation-trace Agent Trace attribution](cli/mutation-trace-agent-attribution.md). - `Agent Trace SCE metadata`: Implementation-owned top-level metadata emitted by `build_agent_trace(...)` under `metadata.sce`, carrying `version` (sourced from the compiled `sce` CLI package version via `env!("CARGO_PKG_VERSION")`) and `line_changes` (exact `{ai,mixed,unknown}` × `{added,removed}` `u64` touched-line attribution counts derived from canonical `post_commit_patch` hunks, reusing each hunk's existing `Conversation.contributor.type` classification with no independent second classification pass, `#[serde(default)]` for backward-compatible deserialization of pre-existing payloads); the whole object is schema-validated with the rest of the payload and persisted in AgentTraceDb `agent_traces.trace_json` without changing the top-level Agent Trace payload/schema `version`. +- `ScopeProvenance`: Optional insert-once metadata keyed by a mutation `ScopeId`, storing canonical SCE `session_id` and nullable first-observed `model_id`. It is registered only at `Start` admission while the owning scope is `NeverSeen`, remains outside `ProtocolState` and the CAS transition, and is resolved during mutation projection. `ScopeId` proves mutation ownership; `ScopeProvenance` describes the owning scope. Model/session provenance is observational and never changes `AiExclusive`/`AiContended`/`IneligibleUnscoped` protocol decisions. - `Agent Trace range content_hash`: Per-range `content_hash` emitted by `build_agent_trace(...)` inside every `ranges[]` entry as `murmur3:`, computed from the touched-line kind/content of the `post_commit_patch` or embedded-patch hunk used to emit that range while excluding positions, paths, metadata, and database IDs. - `Claude diff-trace attribution`: Diff-trace enrichment rule where one Claude `PostToolUse` event resolves its model with `direct > exact transcript > exact session/agent state > NULL`: direct top-level/nested metadata first, then that event's `transcript_path` matched by `tool_use_id` to an assistant envelope's `tool_use.id`, then one exact `(cc_, agent_id)` lookup in local `claude_model_state`; model sources receive one `claude/` normalization step, ephemeral agent context is never exported, and subagents do not inherit main-session state. - `DiffTraceInsert`: Insert payload in `cli/src/services/agent_trace_db/mod.rs` carrying `time_ms`, tool-prefixed `session_id`, `patch`, `model_id`, `tool_name`, nullable `tool_version`, and `payload_type` for parameterized writes to the `diff_traces` table; `payload_type` uses `PAYLOAD_TYPE_PATCH` (`"patch"`) for `OpenCode` unified-diff payloads and `PAYLOAD_TYPE_STRUCTURED` (`"structured"`) for `Claude` `PostToolUse` structured payloads. @@ -234,7 +234,7 @@ - `AgentTrace`: Top-level struct in `cli/src/services/agent_trace.rs` representing the minimal agent-trace payload, carrying top-level `version` (fixed to `0.1.0`, strict numeric `x.y.z`), `id` (UUIDv7 string derived from the same commit-time moment used for `timestamp` in `build_agent_trace(...)`), `timestamp` (caller-provided commit timestamp via `AgentTraceMetadataInput.commit_timestamp`, validated as RFC 3339), optional `vcs` (`Option`, omitted from serialized JSON when `None`), and `files` (`Vec`, one per `post_commit_patch` file); `serde`-serializable with `snake_case` field naming - `classify_hunk`: Public function in `cli/src/services/agent_trace.rs` implementing the direct-only slot rule (retained primitive; the builder itself classifies through the internal combined direct+mutation line-coverage rule): matches a `post_commit_patch` hunk against `intersection_patch` hunks on `old_start` slot, returning `HunkContributor::Ai` for exact line-by-line match, `Mixed` for same-slot-but-different-content, or `Unknown` when no matching slot exists - `AgentTraceMetadataInput`: Metadata input struct in `cli/src/services/agent_trace.rs` that carries `commit_timestamp` (RFC 3339 commit-time value used as `AgentTrace.timestamp`), `commit_revision` (mapped to `AgentTrace.vcs.revision` when VCS metadata is emitted), and optional `vcs_type` (`Option`, mapped to `AgentTrace.vcs.type` and controlling whether top-level `vcs` is emitted); `AgentTraceVcsType` is the schema-aligned `Git`/`Jj`/`Hg`/`Svn` enum serialized as `git`/`jj`/`hg`/`svn`. -- `build_agent_trace`: Public function in `cli/src/services/agent_trace.rs` — the direct-only compatibility path — that delegates to `build_agent_trace_from_evidence` with an empty `mutation_ai_patch`: computes `intersection_patch = intersect_patches(constructed_patch, post_commit_patch)`, iterates over `post_commit_patch` files and hunks, classifies each hunk, validates `AgentTraceMetadataInput.commit_timestamp` as RFC 3339, derives UUIDv7 `AgentTrace.id` from that same commit-time moment, and returns `Result` with top-level metadata fields plus one `Conversation` per `post_commit_patch` hunk; consumed by the active post-commit hook flow, with no standalone `sce agent-trace` command surface. `build_agent_trace_from_evidence(AgentTraceEvidence { direct_patch, mutation_ai_patch }, post_commit_patch, metadata)` is the separated-evidence entrypoint: identical top-level metadata behavior, combined direct+mutation hunk classification, and `model_id` / `related` / top-level `tool` provenance derived from the direct intersection only. +- `build_agent_trace`: Public function in `cli/src/services/agent_trace.rs` — the direct-only compatibility path — that delegates to `build_agent_trace_from_evidence` with an empty `mutation_ai_patch`: computes `intersection_patch = intersect_patches(constructed_patch, post_commit_patch)`, iterates over `post_commit_patch` files and hunks, classifies each hunk, validates `AgentTraceMetadataInput.commit_timestamp` as RFC 3339, derives UUIDv7 `AgentTrace.id` from that same commit-time moment, and returns `Result` with top-level metadata fields plus one `Conversation` per `post_commit_patch` hunk; consumed by the active post-commit hook flow, with no standalone `sce agent-trace` command surface. `build_agent_trace_from_evidence(AgentTraceEvidence { direct_patch, mutation_ai_patch }, post_commit_patch, metadata)` is the separated-evidence entrypoint: identical top-level metadata behavior, combined direct+mutation hunk classification, deduplicated direct+mutation session links, and a contributor model only when the present evidence sources agree; top-level `tool` remains derived from the direct intersection only. - `agent-trace plugin diff extraction seam`: Helper `extractDiffTracePayload` in `config/lib/agent-trace-plugin/opencode-sce-agent-trace-plugin.ts` that accepts a typed `message` event and returns `{ sessionID, diff, time, model_id }` only for user-role messages with non-empty `summary?.diffs`; it joins present object-entry `patch` fields with `\n`, skips entries without `patch`, returns `undefined` when no usable patches remain, uses `Date.now()` for `time`, and builds `model_id` as `providerID/modelID` from `event.properties.info.model`. - `get_or_create_encryption_key`: Public keyring-backed helper in `cli/src/services/db/encryption_key.rs` that retrieves or generates a 64-character hex encryption key from the OS credential store (macOS Keychain, Linux Secret Service via zbus, Windows Credential Store); uses `keyring_core::Entry` with service name `"sce"` and the database name as username. Actively consumed by `EncryptedTursoDb::new()` via the shared adapter constructor. - `conversation-trace mixed batch`: Rust `sce hooks conversation-trace` STDIN contract accepting `{ payloads: [{ type: "message" | "message.part", ... }] }` with top-level `type` ignored and malformed-item skipping. See `context/sce/agent-trace-hooks-command-routing.md` and `context/sce/agent-trace-db.md`. diff --git a/context/overview.md b/context/overview.md index f2e79b6e..8d24e2bd 100644 --- a/context/overview.md +++ b/context/overview.md @@ -80,7 +80,7 @@ The current supported automated release target matrix is `x86_64-unknown-linux-m Context sync uses an important-change gate: cross-cutting/policy/architecture/terminology changes require root shared-file edits, while localized tasks run verify-only root checks without default churn. OpenCode and Claude no longer generate legacy bootstrap or context-sync skills; `/commit` and `/handover` are generated only as catalog-registered composite workflow packages. OpenCode retains only thin routing agents, while Claude emits no agents. The superseded grouped Markdown catalog and automated OpenCode profile have been removed from Pkl ownership and generated outputs. The prior no-git-wrapper Agent Trace design artifacts under `context/sce/agent-trace-*.md` are retained only as historical reference; the current CLI runtime no longer wires the removed Agent Trace schema adaptation, payload building, retry replay, or rewrite handling paths into local hook execution. - The hooks service now uses a minimal attribution-only runtime: `commit-msg` is the only hook that mutates behavior, conditionally injecting exactly one canonical SCE trailer when the attribution-hooks gate is enabled, `SCE_DISABLED` is false, and the staged-diff AI-overlap preflight confirms AI/editor evidence (`StagedDiffAiOverlapResult::Overlap`); when the preflight returns `NoOverlap` or `Error` (including DB open failure, schema not ready, query error, staged diff read failure, or zero overlap), the trailer is not appended and errors are logged via `sce.hooks.commit_msg.ai_overlap_error`; `pre-commit` and `post-rewrite` remain deterministic no-op entrypoints; `post-commit` requires validated `--remote-url`, threads that URL through the Agent Trace flow, prints it to stderr, captures current commit patch, queries recent `diff_traces` from past 7 days (dispatching `patch` rows through existing unified-diff parsing and `structured` rows through `structured_patch::derive_claude_structured_patch` at read time, then assigning each structured hunk the persisted row model and every structured touched line the persisted canonical `cc_...` row session), combines/intersects patches, persists the direct-only intersection metadata to `post_commit_patch_intersections`, then — for committed touched lines that direct intersection did not cover — resolves bounded, read-only mutation-history AI coverage scoped to the invoking worktree's existing identity (newest 128 events replayed oldest-to-newest as one causal tree-transition lineage, bounded also by a commit attribution cut — `revision <= latest_mutation_event_revision` read under the worktree lock — with a committed line attributed AI only if an AI event's line survives every later transition into the committed tree; no provenance, no `diff_traces` or mutation-cursor write) and passes direct and mutation-AI evidence separately into the Agent Trace builder, and persists the schema-validated built Agent Trace payload, including optional top-level `tool` metadata from recent diff-trace rows (direct evidence only), top-level `metadata.sce.version` from the compiled `sce` CLI package version, always-emitted `metadata.sce.line_changes` touched-line attribution counts over the combined direct+mutation coverage, and range-level `content_hash` values, to AgentTraceDb `agent_traces` (DB-only, no post-commit Agent Trace file artifact); `diff-trace` currently validates/persists required non-empty `sessionID`/`diff`/`tool_name`, optional `model_id` (absent or `null` → `None`, present+non-empty → `Some`, present+empty → error), required nullable/non-empty `tool_version`, plus required `u64` millisecond `time`, uses `direct > exact transcript > exact session/agent state > NULL` Claude `model_id` resolution plus direct `tool_version`, with ephemeral agent scope and no generic `session_models` runtime, and continues with `None` when all sources cannot resolve attribution, with same-tool-idempotent stored `session_id` prefixing (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi), non-lossy AgentTraceDb `time_ms` conversion, and no parsed-payload artifact fallback. Claude structured `PostToolUse` diff-trace intake prefers direct top-level or nested model metadata, then matches the event's `tool_use_id` in its JSONL `transcript_path`, consults exact local lifecycle state only when both event-local sources fail, normalizes model values once with the `claude/` prefix, and fails open to `None` when no source resolves. + The hooks service now uses a minimal attribution-only runtime: `commit-msg` is the only hook that mutates behavior, conditionally injecting exactly one canonical SCE trailer when the attribution-hooks gate is enabled, `SCE_DISABLED` is false, and the staged-diff AI-overlap preflight confirms AI/editor evidence (`StagedDiffAiOverlapResult::Overlap`); when the preflight returns `NoOverlap` or `Error` (including DB open failure, schema not ready, query error, staged diff read failure, or zero overlap), the trailer is not appended and errors are logged via `sce.hooks.commit_msg.ai_overlap_error`; `pre-commit` and `post-rewrite` remain deterministic no-op entrypoints; `post-commit` requires validated `--remote-url`, threads that URL through the Agent Trace flow, prints it to stderr, captures current commit patch, queries recent `diff_traces` from past 7 days (dispatching `patch` rows through existing unified-diff parsing and `structured` rows through `structured_patch::derive_claude_structured_patch` at read time, then assigning each structured hunk the persisted row model and every structured touched line the persisted canonical `cc_...` row session), combines/intersects patches, persists the direct-only intersection metadata to `post_commit_patch_intersections`, then — for committed touched lines that direct intersection did not cover — resolves bounded, read-only mutation-history AI coverage scoped to the invoking worktree's existing identity (newest 128 events replayed oldest-to-newest as one causal tree-transition lineage, bounded also by a commit attribution cut — `revision <= latest_mutation_event_revision` read under the worktree lock — with a committed line attributed AI only if an AI event's line survives every later transition into the committed tree; scope provenance enriches surviving mutation lines when available, without writing `diff_traces` or mutation-cursor state) and passes direct and mutation-AI evidence separately into the Agent Trace builder, which unions their related sessions and emits a model only when present evidence agrees, and persists the schema-validated built Agent Trace payload, including optional top-level `tool` metadata from recent diff-trace rows (direct evidence only), top-level `metadata.sce.version` from the compiled `sce` CLI package version, always-emitted `metadata.sce.line_changes` touched-line attribution counts over the combined direct+mutation coverage, and range-level `content_hash` values, to AgentTraceDb `agent_traces` (DB-only, no post-commit Agent Trace file artifact); `diff-trace` currently validates/persists required non-empty `sessionID`/`diff`/`tool_name`, optional `model_id` (absent or `null` → `None`, present+non-empty → `Some`, present+empty → error), required nullable/non-empty `tool_version`, plus required `u64` millisecond `time`, uses `direct > exact transcript > exact session/agent state > NULL` Claude `model_id` resolution plus direct `tool_version`, with ephemeral agent scope and no generic `session_models` runtime, and continues with `None` when all sources cannot resolve attribution, with same-tool-idempotent stored `session_id` prefixing (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi), non-lossy AgentTraceDb `time_ms` conversion, and no parsed-payload artifact fallback. Claude structured `PostToolUse` diff-trace intake prefers direct top-level or nested model metadata, then matches the event's `tool_use_id` in its JSONL `transcript_path`, consults exact local lifecycle state only when both event-local sources fail, normalizes model values once with the `claude/` prefix, and fails open to `None` when no source resolves. The CLI now also includes an approved operator-environment doctor contract documented in `context/sce/agent-trace-hook-doctor.md`; the runtime includes `sce doctor --fix` parsing/help, stable problem/fix-result reporting, canonical hook-repair reuse, bounded doctor-owned local-DB directory bootstrap for the missing SCE-owned DB parent path, and target-scoped integration inventory: Claude reports `Plugins`, `Commands`, and `Skills`; OpenCode reports `Plugins`, `Agents`, `Commands`, and `Skills`; Pi reports `Extensions`, `Prompts`, and `Skills`; and Codex reports `Skills` and `Hooks` (see `context/sce/doctor-human-text-contract.md`). Its non-launching post-commit Agent Trace auto-sync fact reports enabled/current, explicit disabled, not-ready, and not-applicable states using canonical managed-block currency and resolved configuration; existing hook remediation and readiness semantics remain unchanged. The local DB service now provides `LocalDb` as a thin `TursoDb` alias in `cli/src/services/local_db/mod.rs`; `LocalDbSpec` resolves the canonical local DB path from the shared default-path catalog and currently declares zero migrations. Shared Turso infrastructure lives in `cli/src/services/db/mod.rs`, where `DbSpec` and generic `TursoDb` support local or remote sync-mode opens, parent-directory creation, connection setup, synchronous query helpers, embedded migration execution, and shared DB lifecycle helpers. Auth DB persistence uses encrypted `AuthDb = EncryptedTursoDb` and token storage persists credentials through the `auth_credentials` table. Agent Trace persistence uses the sole `RepositoryAgentTraceDb = TursoDb` adapter at `/sce/repos//agent-trace.db`, with a one-file repository schema for `repository_metadata`, `diff_traces`, `post_commit_patch_intersections`, `agent_traces`, `messages`, `parts`, indexes/triggers, and no `checkout_id` columns on trace rows. The checkout-scoped `AgentTraceDb = TursoDb` adapter, its `agent_trace_db_path()`/`agent_trace_db_path_for_checkout()` helpers, and the 15-file `cli/migrations/agent-trace/` chain were removed by the `retire-legacy-agent-trace-db` plan; active hook runtime writes nullable event-local diff-trace attribution without a `session_models` API/table dependency. The hooks command surface now also supports concrete runtime subcommand routing (`pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, `diff-trace`, `conversation-trace`, and `claude-model-state`) with deterministic argument/STDIN validation; `session-model` is no longer supported. Claude settings install the model-state command for both `SessionStart` and `PostModelSwitch`; compatibility smoke against Claude Code 2.1.251 and immediately older 2.1.250 showed that the older client safely ignores the unknown event, so installation is unconditional without a raised minimum or capability gate. Current runtime behavior keeps commit-msg attribution enabled by default unless explicitly opted out: the attribution gate enables canonical trailer insertion in `commit-msg` only when the staged-diff AI-overlap preflight confirms AI/editor evidence (no trailer is appended when the preflight finds no overlap or encounters any error); `pre-commit`/`post-rewrite` remain deterministic no-ops, `post-commit` requires validated `--remote-url`, threads that URL into the Agent Trace flow, prints it to stderr, remains the active bounded recent-diff-trace intersection path, and after successful Agent Trace persistence optionally launches the detached sync-owned `sync --format json` child when config-file-only `agent_trace.auto_sync` is true; `diff-trace` is the active intake path for parsed STDIN `{ sessionID, diff, time, model_id?, tool_name, tool_version }` payload persistence with optional `model_id`, required non-empty `tool_name`, required nullable/non-empty `tool_version`, `direct > exact transcript > exact session/agent state > NULL` Claude `model_id` plus direct `tool_version` values (exact local state only; no generic session-model fallback or cache), required `u64` millisecond `time`, same-tool-idempotent stored `session_id` prefixing (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi), non-lossy AgentTraceDb `time_ms` conversion, and no parsed-payload artifact fallback. This behavior is documented in `context/sce/agent-trace-hooks-command-routing.md`. The removed `sce hooks claude-capture` raw capture route is documented in `context/sce/claude-raw-hook-capture.md` as a removed feature. diff --git a/context/plans/mutation-scope-provenance.md b/context/plans/mutation-scope-provenance.md new file mode 100644 index 00000000..a11b1f77 --- /dev/null +++ b/context/plans/mutation-scope-provenance.md @@ -0,0 +1,966 @@ +# Plan: mutation-scope-provenance + +## Change summary + +Add durable **scope provenance** to the mutation-scope attribution pipeline so +AI-authored filesystem mutations discovered through mutation tracing keep the +session and model that produced them. Today the mutation runtime proves that a +filesystem transition belongs to one exclusive AI scope +(`AiExclusive(scope_id)` -> `LineProvenance::MutationAi { scope_id }`), but +`cli/src/services/mutation_trace/runtime/mutation_attribution.rs:369` matches +that variant with `{ .. }` and discards the `ScopeId`. The mutation-derived +patch therefore reaches Agent Trace as anonymous AI evidence, and +`cli/src/services/agent_trace.rs` binds both `Conversation.contributor.model_id` +and `Conversation.related` to the direct intersection only. A file created +through `Bash` is classified `ai`, but carries no model and no session link. + +This plan preserves enough metadata to resolve that `ScopeId` later. A new +insert-once table `mutation_trace_scope_provenance` +(`005_mutation_scope_provenance.sql`) maps `scope_id -> session_id + model_id?`. +Both shipped producers populate it: Codex reads `model` straight off its +`PreToolUse` payload (already present in the probe fixtures), and Claude +snapshots the existing exact `claude_model_state(cc_, agent_id)` +register at admission through an injectable resolver seam. Post-commit, the +mutation projection resolves the scope against that provenance and annotates +`TouchedLine.session_id` plus a conservatively derived `PatchHunk.model_id`; +Agent Trace then unions direct and mutation session links and selects a model +only when the contributing evidence agrees. + +This extends existing behavior. The verified mutation protocol, the Quint model, +the mutation attribution algorithm, `mutation_trace_scopes`, and +`config/schema/agent-trace.schema.json` are all unchanged. Provenance is +observational metadata about an already-established scope; it never participates +in deciding `AiExclusive` / `AiContended` / `IneligibleUnscoped`. + +## Stack and base + +- **Predecessor:** PR #268 `Codex mutation-scope integration`, branch + `codex-mutation-scope-integration`. +- **This branch:** `mutation-scope-provenance`, created from `c3bee66c`, which + was PR #268's head at plan creation. `c3bee66c` is the base the branch was cut + from, not its current tip. +- **PR base while the stack is unmerged:** `codex-mutation-scope-integration`, + not `main`. +- Compare the completed PR against `origin/codex-mutation-scope-integration`. If + #268's head moves during implementation, rebase `mutation-scope-provenance` + onto the latest #268 head before final validation. +- **PR:** #275, already open and titled `Mutation scope provenance`. No title + change is required. + +## Design + +### D1 — Scope provenance is metadata about a scope, not mutation-protocol state + +The verified protocol keeps owning `ScopeId`, `WorktreeId`, `ActorKind`, +`ScopeStatus`, and the `IneligibleUnscoped` / `AiExclusive(scope_id)` / +`AiContended` decision. Provenance answers a different question: given +`scope_id`, which session and model did that scope represent? + +```rust +struct ScopeProvenance { + scope_id: ScopeId, + session_id: String, + model_id: Option, +} +``` + +Do **not** add `session_id`, `model_id`, `agent_id`, or any harness-specific +field to `ScopeState`, `MutationEvent`, `Attribution`, `ProtocolState`, or the +Quint model. The pure protocol must keep working when provenance is entirely +absent. + +### D2 — One shared durable provenance table + +`cli/migrations/agent-trace-repository/005_mutation_scope_provenance.sql`: + +```sql +CREATE TABLE mutation_trace_scope_provenance ( + scope_id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + model_id TEXT, + created_at TEXT NOT NULL ... +); +``` + +`scope_id` is the identity boundary. Do not duplicate `actor_kind`, +`worktree_id`, or `status` — those stay owned by `mutation_trace_scopes`. Do not +persist `agent_id`; Claude uses it only to resolve the correct model state +before the snapshot is taken. + +`session_id` is the canonical SCE session identity produced by the existing +`prefixed_diff_trace_session_id` helper (`cc_` for Claude, `cx_` for Codex). +`model_id` is already normalized by the producer-specific helper and may be +`NULL`. + +### D3 — Session identity is immutable; the model is first-observed metadata + +Provenance has two parts with different strengths: + +- `session_id` is the **immutable identity fact**. A `scope_id` belongs to + exactly one session, permanently. +- `model_id` is **immutable first-observed descriptive metadata**. It describes + the actor as it appeared when the scope was first admitted, and is *not* part + of scope identity. + +Persistence is insert-once and the first persisted provenance row wins for +`model_id`. A scope created while Claude used Sonnet stays Sonnet after a switch +to Opus; a later `claude_model_state` lookup never rewrites it. Symmetrically, a +later *discovery* of a model for a scope first recorded with `model_id = NULL` +never backfills it. + +Exact replay/conflict matrix for an incoming provenance registration: + +```text +existing: session S, model X +incoming: session S, model X +=> success, idempotent no-op + +existing: session S, model NULL +incoming: session S, model X +=> success, keep NULL + +existing: session S, model X +incoming: session S, model NULL +=> success, keep X + +existing: session S, model X +incoming: session S, model Y +=> success, keep X + +existing: session S1, ... +incoming: session S2, ... +=> identity conflict / error +``` + +That matrix governs an incoming registration for a scope that **already has** a +provenance row. Whether a row may be *created* in the first place is D4's +admission-bounded rule. + +A model disagreement or a later model discovery must **never** cause +mutation-scope admission to fail. This matters because provenance does not +participate in the correctness of `AiExclusive`, `AiContended`, or +`IneligibleUnscoped`: a disagreement about descriptive metadata is not a reason +to deny a mutation-capable tool. + +The **only** provenance conflict that fails a `Start` is a `scope_id` being +associated with a different `session_id`. That returns an error and never +rewrites the stored row — session identity is never silently rewritten. + +`model_id = NULL` is valid provenance. Missing model information never +invalidates otherwise valid AI mutation attribution. + +### D4 — Provenance is optional on the generic Start contract, with explicit durable ordering + +Extend only the generic `start` ingress shape in +`cli/src/services/hooks/mutation_scope.rs`: + +```json +{ + "operation": "start", + "scope_id": "...", + "event_id": "...", + "actor_kind": "codex", + "provenance": { "session_id": "cx_...", "model_id": "..." } +} +``` + +`provenance` itself is optional, so producers that do not yet supply it keep +working. When present: `session_id` is required and non-blank; `model_id` is +optional/nullable; unexpected provenance keys are rejected by the existing +`reject_unexpected_keys` discipline; provenance is accepted only for `start`. +`advance`, `close`, `flush`, and `abandon` never update provenance. + +`RuntimeBoundary::Start` may carry this optional metadata, but the pure protocol +`Boundary::Start` is unchanged. + +**Durable Start ordering.** A `Start` carrying provenance runs in exactly this +order, and this ordering stays inside the existing protected-worktree boundary: + +```text +initialize worktree + ↓ +register scope(scope_id, worktree_id, actor_kind) -> ScopeState + ↓ +conditionally register provenance(scope_id, session_id, model_id?) + ↓ +run pure protocol prepare/commit for Start +``` + +Invariants: + +- provenance registration requires that `mutation_trace_scopes` already contains + the owning `scope_id`; +- provenance registration must not create a scope implicitly; +- a provenance row must never exist without an owning `mutation_trace_scopes` + row; +- if provenance registration fails before the protocol `Start` commits, the + `Start` fails; +- an already-created `NeverSeen` scope row is acceptable after such a failure, + matching the runtime's existing register-before-protocol behavior; +- provenance remains outside `ProtocolState` and is not part of the CAS + transition; +- replaying a `Start` with identical session provenance remains safe and + idempotent. + +**Provenance creation is admission-bounded.** `ScopeId` proves mutation +ownership; `ScopeProvenance` describes the owning scope **as observed at +admission**. It is therefore insert-once *and* creation-bounded, and must never +be attached retroactively to a scope whose protocol `Start` already committed. + +The middle step is conditional, decided from the `ScopeState` that +`register_scope` returns together with the existing provenance row: + +```text +provenance row exists + -> register as usual; D3's replay/conflict matrix is authoritative + (same session -> success, first persisted model wins; + different session -> identity conflict / error) + +no provenance row + scope.status == NeverSeen + -> register the incoming provenance + +no provenance row + scope.status == Active or terminal + -> do nothing; provenance stays absent + the Start continues through normal replay / guarded behavior +``` + +Once a scope has transitioned beyond `NeverSeen`, absence of provenance is +permanent. A later `Start` replay carrying provenance is not an error — it +follows the protocol's ordinary replay semantics and simply persists nothing. + +This closes a retroactive-attachment hole. Without it, a `Start` that admitted a +scope with no provenance could gain one from any later replay; for Claude that +means a replay after a `PostModelSwitch` could resolve the *newer* model and +attach it to an *older* scope. + +Two deliberate consequences: + +- The rule keys on **durable scope status**, not on "the scope row already + exists", so the legitimate retry survives: a first attempt that registered the + scope but never committed the protocol `Start` leaves it `NeverSeen`, so the + retry may still register provenance and then commit. +- Only *creation* is bounded, not *validation*. An existing provenance row is + loaded and re-registered on every provenance-carrying `Start` whatever the + scope's status, so a replay of an already-admitted scope naming a different + `session_id` still fails as an identity conflict. + +None of this changes the pure protocol or the Quint design; the ordering lives +entirely in the runtime adapter layer. + +A missing model is not an error, and neither is a model that disagrees with an +already persisted row (D3). The `Start` fails only on a malformed provenance +payload, a `scope_id` already bound to a different `session_id`, or a failure to +durably register provenance for a registered scope. + +### D5 — Codex populates provenance directly from PreToolUse + +Codex `PreToolUse` already carries `session_id`, `model`, and `tool_use_id` — +confirmed present in +`cli/src/services/hooks/codex_mutation_scope/fixtures/*.pre_tool_use.json`. +Extend the Codex parser so `CodexToolExecution` retains the model (it currently +holds only `identity`, `agent_type`, and `tool_input`). + +On first admission of a tracked Codex mutation tool, canonicalize the session to +`cx_` via `prefixed_diff_trace_session_id` and normalize the model via +the existing `normalize_codex_model_id` in `cli/src/services/hooks/mod.rs:1103`, +then send that `ScopeProvenance` with the generic `Start`. Both `Bash` and +`apply_patch` receive provenance because both are tracked mutation scopes. + +Direct `apply_patch -> diff_traces` attribution is unchanged and keeps taking +precedence during post-commit direct-coverage exclusion; scope provenance never +causes the same lines to be counted twice. + +### D6 — Claude resolves its model through an explicit injectable seam + +Claude `PreToolUse` gives the adapter `session_id`, optional `agent_id`, and +`tool_use_id`, but normally not the model. The Claude mutation adapter does not +directly own Agent Trace DB access — the model-state lookup currently lives in +the normal Claude diff-trace path — so the model is resolved through an explicit +injectable lookup seam rather than the adapter querying the database itself: + +```rust +type ClaudeModelStateResolver = + Fn(repository_root, canonical_session_id, agent_id) -> Result>; +``` + +The exact Rust type and name may vary during implementation. What matters is +that it is an injectable seam handed to the adapter, so tests can supply a +resolver returning a model, `None`, or an error without a real database. + +Flow: + +```text +Claude PreToolUse + │ + ├─ raw session_id + └─ agent_id? + │ + ▼ +canonicalize to cc_ + │ + ▼ +ClaudeModelStateResolver + │ + ▼ +existing repository Agent Trace DB +claude_model_state_by_session_and_agent( + cc_, + exact agent_id or "" +) + │ + ▼ +model_id? + │ + ▼ +ScopeProvenance + │ + ▼ +generic mutation Start +``` + +Semantics: + +- the main agent uses `agent_id = ""`; a subagent uses the exact `agent_id`; +- the lookup is exact — a subagent never inherits main-agent state, mirroring + the existing `resolve_diff_trace_model_id` rule at + `cli/src/services/hooks/mod.rs:1388`; +- `Ok(None)` means `model_id = NULL`; +- missing model state is not an admission failure; +- model normalization and `claude_model_state` semantics reuse the existing + Claude machinery unchanged; +- a later `PostModelSwitch` changes current Claude state but never historical + `ScopeProvenance`. + +**Resolver failures.** Keep these two conditions separate: + +```text +model unavailable +``` + +and + +```text +mutation-scope Start could not be established +``` + +The inability to establish a *model value* — no row, a blank row, an +unnormalizable value — always degrades to `model_id = NULL`. A genuine +infrastructure failure while performing the local lookup (the repository +database cannot be opened or read at all) is a different condition, reported as +such by the resolver's `Err`; the adapter still admits the scope and records +`model_id = NULL`. Model availability is never a requirement for safe mutation +attribution, and failure to identify a model is never a reason to deny a +mutation-capable tool. Only the failures named in D3/D4 deny a `Start`. + +The generic `mutation_scope` ingress stays harness-neutral: all Claude-specific +DB and model-state resolution happens before the generic `Start` payload is +constructed. + +### D7 — Preserve ScopeId until mutation evidence is enriched + +`mutation_attribution.rs:369` currently matches `LineProvenance::MutationAi { .. }` +and inserts a bare `PatchLineLocation`, and `attribution::patch_for_locations` +then clones lines straight off the committed target patch. Neither step can +carry provenance. Keep the `scope_id` alongside each selected location, resolve +`ScopeProvenance(scope_id)` once per distinct scope, and build the mutation +patch with the resolved metadata: + +- `TouchedLine.session_id = provenance.session_id` for every mutation-attributed + line. +- `PatchHunk.model_id` is set only when **all** mutation-attributed lines + contributing to that hunk resolve to the same non-null model. + +```text +scope A -> X, scope A -> X => hunk.model_id = X +scope A -> X, scope B -> X => hunk.model_id = X +scope A -> X, scope B -> Y => hunk.model_id = NULL +scope A -> X, scope B -> ? => hunk.model_id = NULL +``` + +The attribution algorithm itself is unchanged; only the projection of already +attributed `MutationAi(scope)` lines gains metadata. A provenance row that is +absent is simply an unknown model with no session, and must not downgrade the +line's AI classification. + +### D8 — Agent Trace combines direct and mutation provenance + +`build_trace_file(...)` at `cli/src/services/agent_trace.rs:556` already receives +both `intersection_patch` and `mutation_ai_patch` and locates the matching file +for each. Extend it so mutation evidence contributes provenance as well. + +Related sessions become the distinct union of session IDs found on the matched +direct/intersection hunk and the matched mutation hunk, so a mutation-only Bash +hunk can emit a session link. + +Model attribution follows one conservative agreement rule across both sources: + +```text +direct-only X -> X +mutation-only X -> X +mutation-only unknown -> NULL +direct X + mutation X -> X +direct X + mutation Y -> NULL +direct X + mutation unknown -> NULL +``` + +An **absent** evidence source does not count as unknown, so a direct-only hunk +keeps its current `model_id` exactly. + +`direct X + mutation unknown -> NULL` is settled and deliberate. +`Contributor.model_id` describes the contributor for the whole attributed +hunk/range. If some mutation-attributed AI lines in that range have an unknown +model, preserving direct model `X` would overclaim that *all* AI contribution +came from `X`. Dropping the model is the honest answer; the hunk keeps its `ai` +classification and its related sessions. Mixed direct + mutation hunks therefore +gain stricter model semantics by design — see AC7. + +Do not change `Contributor`, `ConversationRelated`, `AgentTrace`, or +`config/schema/agent-trace.schema.json`. + +### D9 — Direct evidence remains authoritative for direct coverage + +The two evidence paths stay separate: direct tool evidence through `diff_traces` +and post-commit intersection, filesystem mutation evidence through mutation +scopes, events, and lineage replay. `attribution::exclude_direct_coverage` stays +in place before mutation attribution, so a Codex `apply_patch` change already +proven by its `diff_traces` row is not duplicated merely because the same tool +execution also had a mutation scope. Scope provenance exists for the scope; +mutation attribution still only contributes the portion not already covered +directly. + +## Acceptance criteria + +How this plan is proven complete. Each criterion is observable and names the +check that proves it. `/validate` runs these checks; no task in the stack +performs final validation. + +- [x] AC1: Durable scope provenance exists independently of verified scope + state. Migration `005_mutation_scope_provenance.sql` provides insert-once + `scope_id -> session_id + model_id?` storage that accepts a nullable model and + stores canonical prefixed sessions verbatim. D3's matrix holds exactly: + identical replay is an idempotent no-op; `existing NULL + incoming X` keeps + `NULL`; `existing X + incoming NULL` keeps `X`; `existing X + incoming Y` + succeeds and keeps `X`; only a different `session_id` for an existing + `scope_id` errors, and it never rewrites the stored row. Inserting provenance + for a `scope_id` unknown to `mutation_trace_scopes` fails, registration never + creates a scope implicitly, and the supported provenance write path through + `MutationTraceStore` requires an existing owning scope row and leaves no orphan + row when that scope is missing. `mutation_trace_scopes` is structurally + unchanged. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path + cli/Cargo.toml services::agent_trace_db` and + `services::mutation_trace::store`; `git diff + origin/codex-mutation-scope-integration -- + cli/migrations/agent-trace-repository/004_mutation_trace_protocol.sql` is + empty. +- [x] AC2: The generic mutation `Start` accepts optional provenance without + changing protocol semantics, in D4's order. `start` with valid provenance + initializes the worktree, registers the scope, registers provenance, then runs + the pure protocol prepare/commit, all inside the existing protected-worktree + boundary; a valid registered scope accepts provenance; the protocol `Start` is + not committed if provenance registration fails, and that failure leaves at + most an owning `NeverSeen` scope row and no provenance row without a + registered scope; `start` without provenance still works; a replayed `start` + with identical session provenance is idempotent and still succeeds; a `start` + whose provenance disagrees only on the model still succeeds. Provenance + creation is admission-bounded: a row is created only while the durable scope + is `NeverSeen`, so a `start` replayed against an already-admitted scope with + no provenance row succeeds and still persists none, while a scope that is + still `NeverSeen` after a failed earlier attempt may gain provenance on + retry; an existing provenance row is still validated on every + provenance-carrying `start` whatever the scope's status, so a different + `session_id` remains a conflict. Provenance on + `advance` / `close` / `flush` / `abandon` is rejected by the strict parser; a + blank `session_id` or an unexpected provenance key is rejected. Provenance + stays outside `ProtocolState` and the CAS transition, and pure mutation + protocol tests are unchanged and passing. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path + cli/Cargo.toml services::hooks::mutation_scope` and + `services::mutation_trace::`; `git diff + origin/codex-mutation-scope-integration -- + cli/src/services/mutation_trace/protocol.rs spec/` is empty. +- [x] AC3: Codex tracked mutations persist exact scope provenance. A + `PreToolUse(Bash)` fixture produces a scope whose provenance row holds the + expected `cx_`-prefixed session and the normalized Codex model; `apply_patch` + behaves identically; a `PreToolUse` with no usable model produces + `model_id = NULL` while keeping session attribution. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path + cli/Cargo.toml services::hooks::codex_mutation_scope`. +- [x] AC4: Claude tracked mutations persist scope provenance from exact Claude + model state. A main-agent scope resolves `(cc_, "")`; a subagent scope + resolves `(cc_, exact agent_id)`; a subagent with no exact row gets + `model_id = NULL` rather than the main agent's model; a `PostModelSwitch` + after scope creation leaves the existing provenance row untouched. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path + cli/Cargo.toml services::hooks::claude_mutation_scope` and + `services::hooks::claude_model_state`. +- [x] AC5: Mutation lineage preserves provenance into the mutation-derived + patch. `MutationAi(scope_id)` lines carry their scope's canonical + `session_id`; a hunk carries a model only when every mutation-attributed line + in it resolves to the same known model; conflicting-model, + partially-unknown, and missing-provenance hunks leave `model_id` unset; + existing AI / non-AI / unresolved classification results are unchanged. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path + cli/Cargo.toml services::mutation_trace::runtime::mutation_attribution` and + `services::mutation_trace::`. +- [x] AC6: Agent Trace emits mutation-derived session and model attribution. A + Codex `Bash`-created file yields `contributor.type = "ai"`, the normalized + Codex `model_id`, and a related `cx_` session URL; a Claude `Bash`-created + file yields the equivalent Claude model and `cc_` session URL; mutation-only + evidence works with no direct `diff_traces` match; mixed direct + mutation + evidence unions related sessions and emits a model only when the contributing + evidence agrees. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path + cli/Cargo.toml services::agent_trace` and `services::hooks::`. +- [x] AC7: Direct-only attribution and direct-coverage precedence are unchanged. + Direct-only Codex `apply_patch` evidence behaves exactly as before, producing + its existing `diff_traces` session and model attribution; direct-only Claude + structured evidence behaves exactly as before; the existing Claude model + precedence remains `direct > exact transcript > exact session/agent state > + NULL`; a hunk with direct evidence and no mutation evidence keeps exactly the + `model_id` and `related` it emits today, and direct-only golden output is + byte-identical; direct coverage is still excluded before mutation-derived + attribution. Mixed direct + mutation hunks are deliberately outside this + criterion: they follow D8's agreement rule and may therefore lose `model_id` + when mutation provenance is conflicting or unknown. That is an intended + semantic change, not a regression. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path + cli/Cargo.toml services::hooks::` and `services::agent_trace`; the + `direct_only` golden fixture under + `cli/src/services/agent_trace/fixtures/` is byte-unchanged. +- [x] AC8: No verified-protocol or Agent Trace schema expansion is introduced. + `spec/mutation_cursor.qnt`, `spec/mutation_cursor.md`, + `cli/src/services/mutation_trace/protocol.rs`, the pure protocol `ScopeState` / + `MutationEvent` attribution types, and + `config/schema/agent-trace.schema.json` have no semantic change. Migration + `005` is the only schema addition and is observational metadata only. + - Validate: `git diff origin/codex-mutation-scope-integration -- spec/ + cli/src/services/mutation_trace/protocol.rs + config/schema/agent-trace.schema.json` is empty; `git diff --name-only + origin/codex-mutation-scope-integration -- + cli/migrations/agent-trace-repository/` lists only + `005_mutation_scope_provenance.sql`. + +### Full validation + +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::mutation_trace::` +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::mutation_scope` +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::claude_mutation_scope` +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::codex_mutation_scope` +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::agent_trace` +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::agent_trace_db` +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::` +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml` +- `nix develop -c ./scripts/run-cli-cargo.sh clippy --manifest-path cli/Cargo.toml --all-targets -- -D warnings` +- `nix develop -c ./scripts/run-cli-cargo.sh fmt --manifest-path cli/Cargo.toml -- --check` +- `nix run .#pkl-check-generated` +- `nix flake check` +- `git diff origin/codex-mutation-scope-integration -- spec/ cli/src/services/mutation_trace/protocol.rs config/schema/agent-trace.schema.json` must be empty (AC8). + +Final branch comparison is against `origin/codex-mutation-scope-integration` +(#268 head), not `main`, while this PR remains stacked on #268. + +### Context sync + +- Update `context/cli/mutation-scope-hook-ingress.md` — the `start` operation's + accepted key set now includes optional `provenance`, with its validation rules + and the "only `start`" restriction. +- Update `context/cli/mutation-scope-runtime.md` — `RuntimeBoundary::Start` + carries optional provenance, plus D4's durable `Start` ordering (worktree init + -> scope registration -> conditional provenance registration -> pure protocol + prepare/commit) and its invariants: provenance needs an existing owning scope, + never creates one implicitly, never outlives one, and stays outside + `ProtocolState` and the CAS transition. +- Update `context/cli/mutation-scope-provenance.md` — D4's admission-bounded + creation rule: a provenance row may be created only while the durable scope is + `NeverSeen`; after admission, absent provenance stays absent permanently and a + later `Start` replay cannot backfill it; an existing row is still validated on + every provenance-carrying `Start`, so a different `session_id` remains an + identity conflict. +- Update `context/cli/claude-mutation-scope-integration.md` — the injectable + `ClaudeModelStateResolver` seam, the exact `(cc_, agent_id)` + model-state snapshot at admission, the no-inheritance rule for subagents, and + the separation between `model unavailable` (always `model_id = NULL`) and + `mutation-scope Start could not be established`. +- Update `context/cli/codex-mutation-scope-integration.md` — `PreToolUse.model` + is now retained and normalized into scope provenance for both tracked tools. +- Update `context/cli/mutation-trace-agent-attribution.md` — the "No fabricated + provenance" paragraph changes: the mutation-AI patch now carries resolved + session and conservatively derived model metadata, while `ScopeId`, + `ActorKind`, and `AiExclusive(scope)` still never become direct provenance. +- Update `context/cli/mutation-trace-store.md` — the new provenance read/write + seam beside the existing mutation-trace persistence. +- Update `context/sce/agent-trace-db.md` — migration `005`, the provenance table + shape, its insert-once semantics including D3's replay/conflict matrix and the + owning-scope requirement, and that it is local attribution state outside the + four export streams. +- Update `context/sce/agent-trace-minimal-generator.md` — `model_id` and + `related` are no longer bound to the direct intersection only; record D8's + combined agreement rule, including that a mixed direct + mutation hunk with + conflicting or unknown mutation provenance intentionally emits no `model_id`. +- Update `context/sce/agent-trace-hooks-command-routing.md` — the `start` + payload's new optional field. +- Update `context/architecture.md`, `context/glossary.md`, + `context/context-map.md` — add `ScopeProvenance` and state the boundary + explicitly: mutation protocol attribution `!=` scope provenance; `ScopeId` + proves ownership, `ScopeProvenance` describes the owning scope. +- ADR: only if implementation reveals a genuinely new system-wide constraint. + Adding one observational metadata table beside a verified protocol does not by + itself meet the repository's ADR threshold. + +## Task context synchronization lifecycle + +Persist this field in every plan; this is durable plan state, not chat state: + +- **Task context synchronization:** every task carries `pending | synced | blocked`. + A completed task must be `synced` before another task can start or the plan can + finish. +- For `blocked`, record **Blocker**, **Required action**, and **Retry condition** + beside the status. Never infer `synced` from conversation history; write every + lifecycle transition to the plan file. + +## Constraints and non-goals + +- **In scope:** `cli/migrations/agent-trace-repository/005_mutation_scope_provenance.sql`; + `cli/src/services/agent_trace_db/repository.rs`; + `cli/src/services/mutation_trace/store.rs`; + `cli/src/services/mutation_trace/runtime/` (`coordinator.rs`, + `mutation_attribution.rs`, `mod.rs` re-exports); + `cli/src/services/mutation_trace/attribution.rs`; + `cli/src/services/hooks/mutation_scope.rs`; + `cli/src/services/hooks/claude_mutation_scope/`; + `cli/src/services/hooks/codex_mutation_scope/`; + `cli/src/services/agent_trace.rs` and its fixtures; the durable context files + named under **Context sync**. +- **Out of scope:** changing mutation exclusivity semantics or the + `AiExclusive` / `AiContended` rules; adding model/session fields to the + verified mutation protocol; adding `agent_id` to Agent Trace or `diff_traces`; + exporting or synchronizing `claude_model_state` or the provenance table; + model history; OpenCode/Pi mutation adapters; attributing currently untracked + Codex MCP mutations; changing direct diff-trace capture; changing the Agent + Trace JSON schema; changing mutation attribution of removed lines beyond + current behavior. +- **Constraints:** provenance is insert-once per `ScopeId` — `session_id` is + immutable identity and `model_id` is immutable first-observed metadata, with + the first persisted row winning; only a `session_id` conflict fails a `Start`; + canonical session IDs reuse the existing `prefixed_diff_trace_session_id` + prefixes; model normalization reuses the existing producer helpers + (`normalize_codex_model_id`, the Claude `claude/`-prefix normalization); + unknown model means `NULL`, never guessed, and never blocks admission; the + Claude subagent model lookup stays exactly scoped and runs behind an + injectable resolver seam so the generic ingress stays harness-neutral; direct + evidence stays excluded before mutation-derived attribution; provenance + metadata never affects the formal attribution decision; the Codex adapter's + only mutation-stack dependency remains the in-process + `run_mutation_scope_from_payload` seam. +- **Non-goal:** merging the direct diff-trace system and mutation tracing into + one evidence path. They stay two independent sources with direct coverage + excluded first. + +## Assumptions + +- Every tracked Claude/Codex mutation scope has a stable harness session ID. +- Codex model information observed on `PreToolUse` describes that exact tool + execution. +- Claude's best available scope-time model is the exact current + `claude_model_state` value for `(session_id, agent_id)`, read through the + injectable `ClaudeModelStateResolver` seam rather than by the mutation adapter + querying the repository database itself. `claude_model_state` can legitimately + be missing or stale because of Claude lifecycle timing; provenance records what + SCE could establish at admission and claims no stronger causal ordering. +- A conflicting provenance insert (same `scope_id`, different `session_id`) + returns an error to the caller rather than being silently ignored, and a + fail-closed `Start` therefore denies the tool. This is the **only** provenance + condition that denies a mutation-capable tool. It cannot occur in production + for either shipped producer, because both `ScopeId` formats + (`cc-tool-v1|…|s=:|…`, `cx-tool-v1|…|s=:|…`) + embed the session length-prefixed in the identity itself. +- A model disagreement — same `scope_id` and `session_id`, a different or newly + discovered `model_id` — is **not** a conflict. It succeeds, keeps the + first-persisted value, and never denies the tool, because `model_id` is + descriptive metadata that no attribution decision depends on. +- Model availability is not a precondition for safe mutation attribution. + Neither a missing Claude model-state row nor an infrastructure failure while + reading local model state can become a denied `Start`; both degrade to + `model_id = NULL`. +- The existing `ParsedPatch` provenance fields are sufficient to carry mutation + evidence to Agent Trace: `TouchedLine.session_id` (`patch.rs:84`) and + `PatchHunk.model_id` (`patch.rs:66`). +- `Conversation.related` already represents multiple sessions, so the union in + D8 needs no schema change. +- The single `Contributor.model_id` field means model disagreement inside one + final Agent Trace hunk degrades to no model rather than an arbitrary pick. + +## Task stack + +- [x] T01: `Add durable mutation-scope provenance storage` (status:done) + - Task ID: T01 + - Scope: In — `cli/migrations/agent-trace-repository/005_mutation_scope_provenance.sql`; + the typed `ScopeProvenance` value and its insert/read API on + `MutationTraceStore`, backed by `RepositoryAgentTraceDb`'s generic + `execute`/`query_map` primitives; migration-readiness wiring. Out — any + ingress, adapter, attribution, or Agent Trace change. + - Dependencies: none + - Done when: provenance can be inserted with a known model and with a `NULL` + model and read back by `scope_id`; D3's matrix is proven row by row — an + identical replay is an idempotent no-op, `existing NULL + incoming X` keeps + `NULL`, `existing X + incoming NULL` keeps `X`, `existing X + incoming Y` + succeeds and keeps `X`, and only a differing `session_id` for an existing + `scope_id` returns an error without mutating the stored row; inserting + provenance for a `scope_id` that `mutation_trace_scopes` does not contain + fails, and registration never creates a scope implicitly, so the supported + provenance write path requires an existing owning scope row and leaves no + orphan row behind when that scope is missing. + `AGENT_TRACE_REPOSITORY_MIGRATIONS` includes `005_mutation_scope_provenance` + and schema readiness accounts for it. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::agent_trace_db`; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::mutation_trace::store`. + - Completed: 2026-09-09 + - Files changed: `cli/migrations/agent-trace-repository/005_mutation_scope_provenance.sql` (new); `cli/src/services/mutation_trace/store.rs`; `cli/src/services/agent_trace_db/repository.rs` + - Result: Added migration `005_mutation_scope_provenance.sql` defining `mutation_trace_scope_provenance` (`scope_id TEXT PRIMARY KEY`, `session_id TEXT NOT NULL`, nullable `model_id TEXT`, `created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))` matching `004`'s convention), discovered automatically by `build.rs`'s migrations directory scan, so `AGENT_TRACE_REPOSITORY_MIGRATIONS` gains `005_mutation_scope_provenance` and `TursoDb::ensure_schema_ready` derives its expected-migration set from that same list with no constant to edit. Added the typed `ScopeProvenance { scope_id: ScopeId, session_id: String, model_id: Option }` value plus `MutationTraceStore::register_scope_provenance` and `MutationTraceStore::load_scope_provenance` in `store.rs`, alongside `SELECT_SCOPE_PROVENANCE_SQL`, `INSERT_SCOPE_PROVENANCE_IF_ABSENT_SQL` (`ON CONFLICT (scope_id) DO NOTHING`), and `scope_provenance_row_from_turso`, following the same access pattern the five `mutation_trace_*` tables from `004` already use. `register_scope_provenance` mirrors `register_scope`'s shape: it pre-checks the owning `mutation_trace_scopes` row via `load_scope` and bails before any insert when it is absent, performs the idle insert, then re-reads the stored row and returns it, erring only when the stored `session_id` differs from the incoming one. Insert-once therefore holds in both directions of `model_id` (stored `NULL` is not backfilled; a stored model is neither cleared nor overwritten) without any `UPDATE` path existing at all. Enforcing the owning-scope requirement in Rust rather than by a `FOREIGN KEY` left `004_mutation_trace_protocol.sql` byte-unchanged. `ScopeProvenance` lives in `store.rs`, not `types.rs`, so the pure-protocol type module is untouched; `#[allow(clippy::struct_field_names)]` follows the existing repository convention for `-D clippy::pedantic` (`attribution.rs`, `claude_mutation_scope/mod.rs`, `codex_mutation_scope/mod.rs`). Extended the three `repository.rs` migration-list assertions and the three schema-table lists to cover `005` / `mutation_trace_scope_provenance`, including the hook-runtime test proving the no-migration path still creates neither. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::agent_trace_db` — passed, 29/29; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::mutation_trace::store` — passed, 97/97 (9 new provenance tests). Also ran, though not required by the task: `nix develop -c ./scripts/run-cli-cargo.sh clippy --manifest-path cli/Cargo.toml --all-targets -- -D warnings` — passed; `nix develop -c ./scripts/run-cli-cargo.sh fmt --manifest-path cli/Cargo.toml -- --check` — passed. + - Done checks: provenance inserts with a known model and with a `NULL` model and reads back by `scope_id` (verified — `register_scope_provenance_stores_a_known_model_and_reads_it_back`, `register_scope_provenance_stores_a_null_model_and_reads_it_back`); D3's matrix proven row by row — identical replay is an idempotent no-op leaving exactly one row (verified — `register_scope_provenance_replayed_identically_is_an_idempotent_no_op`), `existing NULL + incoming X` keeps `NULL` (verified — `register_scope_provenance_keeps_a_stored_null_model_when_one_is_later_discovered`), `existing X + incoming NULL` keeps `X` (verified — `register_scope_provenance_keeps_a_stored_model_when_the_replay_has_none`), `existing X + incoming Y` succeeds and keeps `X` (verified — `register_scope_provenance_keeps_the_first_model_when_a_later_one_disagrees`), and only a differing `session_id` errors while leaving the stored row byte-identical (verified — `register_scope_provenance_errors_on_a_session_conflict_without_rewriting_the_row`); provenance for a `scope_id` absent from `mutation_trace_scopes` fails, creates no provenance row, and creates no scope implicitly (verified — `register_scope_provenance_errors_for_an_unregistered_scope_and_creates_no_rows`, asserting both a zero provenance row count and `load_scope` still `None`); a scope without provenance reads back `None` rather than erroring (verified — `load_scope_provenance_returns_none_for_a_scope_without_provenance`); `AGENT_TRACE_REPOSITORY_MIGRATIONS` includes `005_mutation_scope_provenance` and schema readiness accounts for it (verified — `open_at_initializes_the_full_repository_schema` asserts the five-migration order and the new table then calls `ensure_schema_ready_for_hooks`, and `baseline_and_source_instance_fixture_migrates_to_mutation_trace_protocol_through_setup` proves a `001`+`002`-only fixture upgrades through `005`); `git diff --stat` over `004_mutation_trace_protocol.sql`, `protocol.rs`, `spec/`, and `config/schema/agent-trace.schema.json` is empty (verified). + - Context impact: local — additive schema-only migration plus one new store seam that nothing consumes yet. No ingress, adapter, attribution, or Agent Trace behavior changed. T01 documented the new durable storage boundary in `context/cli/mutation-scope-provenance.md` (new), `context/cli/mutation-trace-store.md`, `context/sce/agent-trace-db.md`, and `context/context-map.md`. Those files currently document only the storage-layer provenance seam; later tasks extend the relevant context as mutation `Start` ingress, the Claude/Codex producers, mutation reconstruction, and Agent Trace consumption are implemented, and T07 still performs the final cross-system synchronization pass. T02 is the first consumer of this seam. + - Context synchronization: synced + +- [x] T02: `Carry optional provenance through mutation Start` (status:done) + - Task ID: T02 + - Scope: In — `MutationScopePayload::Start` gains an optional provenance field; + `parse_mutation_scope_payload` validation including the per-operation key + sets; `RuntimeBoundary::Start` metadata plumbing through + `runtime/coordinator.rs` and the `runtime/mod.rs` re-exports; durable + registration through T01's seam in D4's order (worktree init -> scope + registration -> conditional provenance registration -> pure protocol + prepare/commit), inside the existing protected-worktree boundary, gated by + D4's admission-bounded creation rule. Out — pure protocol + `Boundary::Start`, `protocol.rs`, Quint semantics, and any harness adapter. + - Dependencies: T01 + - Done when: `start` with valid provenance runs D4's order and durably + registers provenance before the call reports success; provenance for an + unknown `scope_id` fails while a valid registered scope accepts it; a + provenance registration failure leaves the protocol `Start` uncommitted, + leaves at most an owning `NeverSeen` scope row, and leaves no provenance row + without a registered scope; a replayed `start` with identical session + provenance succeeds idempotently; a `start` whose model disagrees with the + stored row still succeeds; only a differing `session_id` for the same + `scope_id` fails the `Start`; `start` without provenance behaves exactly as + today; provenance creation is admission-bounded — a `start` replayed against + an already-admitted scope with no provenance row succeeds under normal replay + semantics and still persists no provenance, leaving the revision and + processed-event set unchanged, while a scope still `NeverSeen` after a failed + earlier attempt can still gain provenance on retry and then commit, and an + existing provenance row is still validated on an admitted scope so a + differing `session_id` still conflicts; provenance on `advance` / `close` / `flush` / `abandon` is rejected + with the existing `Invalid mutation-scope payload from STDIN: .` + diagnostic; a blank `session_id`, a non-object `provenance`, and an + unexpected provenance key are each rejected; `git diff` against + `origin/codex-mutation-scope-integration` for `protocol.rs` and `spec/` is + empty. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::mutation_scope`; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::mutation_trace::`. + - Completed: 2026-09-09 + - Files changed: `cli/src/services/hooks/mutation_scope.rs`; `cli/src/services/mutation_trace/runtime/coordinator.rs`; `cli/src/services/mutation_trace/runtime/mod.rs`; `cli/src/services/mutation_trace/runtime/tests.rs` + - Result: Added `StartProvenance { session_id: String, model_id: Option }` in `runtime/coordinator.rs`, re-exported from `runtime/mod.rs`, and gave `RuntimeBoundary::Start` a `provenance: Option` field. The value deliberately omits `scope_id` — the boundary already names the scope — and `coordinate_boundary_inner` composes T01's `ScopeProvenance` from both. Registration sits between the existing `register_scope` call and the CAS prepare/commit loop, so D4's order (worktree init -> scope registration -> provenance registration -> pure protocol prepare/commit) holds inside the existing protected-worktree boundary, and a retried CAS attempt re-runs no provenance write. Failures map to a new pre-commit `CoordinateError::ScopeProvenanceRegistration(anyhow::Error)`, displayed as its source and grouped with `ScopeIdentityConflict` / `LockAcquisition`; the hook's existing catch-all branch renders it as a boundary failure before durable completion, so a rejected `Start` denies the tool. `into_protocol_boundary` still maps to the unchanged pure `Boundary::Start`, and `provenance` never enters `ProtocolState` or the CAS transition. On the ingress side, `MutationScopePayload::Start` gained the same optional field; `parse_scope_boundary` was split so `start` (`parse_start`) accepts `provenance` in its key set while `advance` / `close` keep today's exact set and `flush` / `abandon` are untouched, all sharing the extracted `parse_scope_boundary_identity`. `parse_provenance` requires a JSON object, rejects any key other than `session_id` / `model_id` with an `unexpected field 'provenance.'` diagnostic, requires a non-blank `session_id`, and reads `model_id` through a new `optional_non_blank_str` helper that treats an absent key and an explicit `null` as `None`. A blank or whitespace-only `model_id` is rejected rather than coerced to `NULL`, matching the strict-parser discipline: both shipped producers normalize an unknown model to `None` (`normalize_codex_model_id` already returns `Option`), so a blank string is malformed input, never a legitimate "no model". Every existing `RuntimeBoundary::Start` construction site in `coordinator.rs` and `runtime/tests.rs` gained `provenance: None`, which is also the exact-behavior path for a producer that sends no provenance. No harness adapter, `protocol.rs`, or `spec/` file was touched. + - Amendment (2026-09-09, same task): the provenance step was made conditional so `ScopeProvenance` stays a true admission-time snapshot. `coordinate_boundary_inner` now binds the `ScopeState` that `register_scope` returns instead of discarding it, and passes it to a new `register_start_provenance(store, boundary, registered_scope)` helper (extracted so `coordinate_boundary_inner` stays under `clippy::too_many_lines`). That helper loads the existing provenance row first: when a row exists it always calls `register_scope_provenance`, so T01's immutable-session-identity and first-observed-model rules stay authoritative on every replay including for a long-admitted scope; when no row exists it registers only while `registered_scope.status == ScopeStatus::NeverSeen` and otherwise returns `Ok(())` without writing, letting the `Start` continue through the protocol's ordinary replay/guard behavior. The rule is keyed on durable scope status rather than on the scope row's existence precisely so a retry whose earlier attempt registered the scope but never committed the protocol `Start` can still register provenance. This closes a retroactive-attachment hole: previously `Start(A, provenance=None)` could admit a scope and a later `Start(A, provenance=Some(..))` replay would insert a row, which for Claude means a replay after a `PostModelSwitch` could attach the newer model to an older scope. Both the load failure and the registration failure map to the existing pre-commit `CoordinateError::ScopeProvenanceRegistration`. `ScopeStatus` was added to `coordinator.rs`'s `types` import; nothing else in the ordering, the error surface, the ingress, `ProtocolState`, the CAS transition, `protocol.rs`, or `spec/` changed. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::mutation_scope` — passed, 50/50 (13 provenance parser/ingress tests plus `test13`); `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::mutation_trace::` — passed, 350/350 (2 coordinator ordering tests plus 3 admission-boundedness regressions), including the unchanged Quint-refinement MBT suites. Also ran, though not required by the task: `nix develop -c ./scripts/run-cli-cargo.sh clippy --manifest-path cli/Cargo.toml --all-targets -- -D warnings` — passed; `nix develop -c ./scripts/run-cli-cargo.sh fmt --manifest-path cli/Cargo.toml -- --check` — passed. + - Done checks: `start` with valid provenance runs D4's order and durably registers provenance before the call reports success (verified — `start_registers_provenance_after_its_owning_scope_and_before_the_protocol_commits` asserts the provenance row exists and the scope reached `Active`, and `test8_start_with_provenance_registers_it_before_the_protocol_start_commits` asserts the scope row, provenance row, and the committed `e1` processed event through the real-git/real-DB ingress); provenance for an unknown `scope_id` fails while a valid registered scope accepts it (verified — the store guard is T01's `register_scope_provenance_errors_for_an_unregistered_scope_and_creates_no_rows`, and the ingress side is proven by the two ordering tests above plus `test12_no_provenance_row_ever_exists_without_its_owning_scope`, whose LEFT JOIN asserts zero orphan rows); a provenance registration failure leaves the protocol `Start` uncommitted, leaves at most an owning `NeverSeen` scope row, and leaves no provenance row without a registered scope (verified — `a_provenance_registration_failure_rejects_the_start_before_the_protocol_commits` asserts `CoordinateError::ScopeProvenanceRegistration`, the scope still `NeverSeen`, empty `processed_events`, revision 0, and the stored row unchanged); a replayed `start` with identical session provenance succeeds idempotently and a `start` whose model disagrees still succeeds (verified — `test10_replayed_start_provenance_is_idempotent_and_keeps_the_first_model` replays identically, then with no model, then with a disagreeing model, asserting one row, the first model kept, and an unchanged revision and processed-event list); only a differing `session_id` for the same `scope_id` fails the `Start` (verified — `test11_conflicting_provenance_session_fails_the_start_without_rewriting_the_row` asserts the `already has provenance for session` diagnostic, the untouched row, and that event `e9` was never processed); `start` without provenance behaves exactly as today (verified — `test9_start_without_provenance_persists_no_provenance_row` asserts an `active` scope, zero provenance rows, and the committed `e1`, with `test1`–`test7` and every pre-existing runtime test unchanged); provenance on `advance` / `close` / `flush` / `abandon` is rejected with the existing diagnostic (verified — `provenance_is_rejected_on_every_operation_other_than_start` asserts the exact `Invalid mutation-scope payload from STDIN: unexpected field 'provenance'.` string for all four); a blank `session_id`, a non-object `provenance`, and an unexpected provenance key are each rejected (verified — `blank_or_non_string_provenance_session_id_is_rejected`, `non_object_provenance_is_rejected`, `unexpected_provenance_key_is_rejected`, plus `provenance_without_a_session_id_is_rejected` and `blank_or_non_string_provenance_model_id_is_rejected`); `git diff origin/codex-mutation-scope-integration -- cli/src/services/mutation_trace/protocol.rs spec/` is empty (verified — zero lines); provenance creation is admission-bounded (verified — `start_without_provenance_then_replay_with_provenance_does_not_backfill` admits scope `A` with `provenance: None`, asserts no provenance row, replays the same `Start` carrying `cc_session-1` / `claude/opus`, and asserts the provenance row is still absent with the worktree revision and processed-event set byte-identical to the admitted projection, and `test13_a_start_admitted_without_provenance_is_never_backfilled_by_a_replay` proves the same through the real-git/real-DB ingress with `SELECT COUNT(*) FROM mutation_trace_scope_provenance = 0`, the scope still `active`, and an unchanged revision); a retry before admission may still register provenance (verified — `never_seen_scope_can_receive_provenance_before_successful_start` seeds a `NeverSeen` scope row with no provenance, asserts that status, drives a `Start` carrying provenance, and asserts both the inserted row and the scope reaching `Active`); an existing provenance row is still validated after admission (verified — `an_admitted_scope_with_provenance_still_rejects_a_different_session` admits the scope with `cc_session-1`, replays with `cc_session-2`, and asserts `CoordinateError::ScopeProvenanceRegistration`, the unchanged stored row, and an unchanged revision and processed-event set; `test11_conflicting_provenance_session_fails_the_start_without_rewriting_the_row` covers the same through the ingress). + - Context impact: local — one optional ingress field, one runtime boundary field, one new pre-commit error variant, and one durable registration step that reuses T01's seam. No harness adapter, attribution, or Agent Trace behavior changed, and the pure protocol and Quint model are byte-unchanged. T02 documents the ingress and runtime halves of the provenance path in `context/cli/mutation-scope-hook-ingress.md`, `context/cli/mutation-scope-runtime.md`, and `context/sce/agent-trace-hooks-command-routing.md`, and owns the admission-bounded creation rule in `context/cli/mutation-scope-provenance.md` (whose insert-once storage semantics remain T01's); the producer, reconstruction, and Agent Trace context files stay owned by T03–T06, and T07 still performs the final cross-system synchronization pass. T03 and T04 are the first producers of this optional field. + - Context synchronization: synced + +- [x] T03: `Populate Codex scope provenance` (status:done) + - Task ID: T03 + - Scope: In — retain `model` on `CodexToolExecution` in the Codex `PreToolUse` + parser; canonicalize the session with `prefixed_diff_trace_session_id` and + normalize the model with `normalize_codex_model_id`; attach the resulting + provenance to the `Start` payload for both tracked tools. Out — Codex tool + classification, cleanup signals, `sce setup` registration, and the existing + `sce hooks codex` diff/conversation pipeline. + - Dependencies: T02 + - Done when: a `PreToolUse(Bash)` fixture and a `PreToolUse(apply_patch)` + fixture each produce a provenance row with the expected `cx_` session and + normalized model; a `PreToolUse` whose model is absent or unnormalizable + produces `model_id = NULL` with the session still recorded; untracked and + delegation tools still create no scope and no provenance. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::codex_mutation_scope`. + - Completed: 2026-09-09 + - Files changed: `cli/src/services/hooks/codex_mutation_scope/mod.rs` + - Result: `CodexToolExecution` gained a `model: Option` field, read by `parse_pre_tool_use` through a new `tolerated_model` helper and the new `MODEL_FIELD` constant. That read is deliberately lenient rather than using the module's strict `optional_non_blank_str`: an absent, `null`, blank, or non-string `model` yields `None` instead of a parse error, because `model` is descriptive metadata and a malformed one must never fail-closed-deny a mutation-capable tool (D3), and because the task's own done check requires an unnormalizable model to produce `model_id = NULL` rather than a rejected event. Every other field on the Codex `PreToolUse` payload keeps its existing strict validation. Added `CodexScopeProvenance { session_id, model_id }` and `codex_scope_provenance(execution)`, which canonicalizes the session with `prefixed_diff_trace_session_id(CODEX_TOOL_NAME, ..)` (`cx_` prefix, already-prefixed sessions passed through) and normalizes the model with `normalize_codex_model_id` (trim, blank -> `None`); both helpers are reached through a new `use crate::services::hooks::{normalize_codex_model_id, prefixed_diff_trace_session_id, CODEX_TOOL_NAME}` — private-to-`hooks` items are visible to this descendant module, so no visibility was widened. `handle_pre_tool_use` builds the provenance once, after the Bash policy preflight and git-dir resolution and before the boundary lock, and hands it to `establish_start`, which now composes the payload through a new `scope_start_payload(scope_id, event_id, provenance)` instead of the shared `scope_boundary_payload`. Both tracked tools (`Bash`, `apply_patch`) take that one path, so both receive provenance. `scope_boundary_payload` is left byte-identical and now serves only `close`, keeping `close` / `abandon` / `flush` payloads unchanged — which matters because T02's ingress rejects `provenance` on every operation other than `start`. An unavailable model is emitted as JSON `null`, which T02's `optional_non_blank_str` parser already reads as `None`; a blank string is never emitted, so the ingress's blank-`model_id` rejection is unreachable from this producer. Whether a provenance row is actually created stays owned by T02's admission-bounded runtime rule; this task only supplies the optional field. No tool classification, cleanup signal, `sce setup` registration, or `sce hooks codex` diff/conversation behavior changed. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::codex_mutation_scope` — passed, 156/156 (10 new provenance tests). Also ran, though not required by the task: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::` — passed, 502/502; `nix develop -c ./scripts/run-cli-cargo.sh clippy --manifest-path cli/Cargo.toml --all-targets -- -D warnings` — passed; `nix develop -c ./scripts/run-cli-cargo.sh fmt --manifest-path cli/Cargo.toml -- --check` — passed. + - Done checks: a `PreToolUse(Bash)` fixture and a `PreToolUse(apply_patch)` fixture each produce a provenance row with the expected `cx_` session and normalized model (verified — `test27_tracked_fixtures_persist_scope_provenance_ac3` drives `probe01`'s shell and apply_patch fixtures through the real-git/real-DB `CodexRepo` harness and asserts the persisted `mutation_trace_scope_provenance` row is exactly `("cx_01a07c1e-e08e-7172-8032-cb9d62af21d9", Some("gpt-5.6-sol"))` with a single row and an `active` codex scope; `ac3_pre_tool_use_fixtures_retain_the_codex_model` and `ac3_scope_provenance_canonicalizes_the_session_and_normalizes_the_model` prove the parser and helper halves, and `ac3_tracked_start_carries_scope_provenance_for_both_tracked_tools` asserts the emitted `start` payload's `provenance` object for every entry of `TRACKED_MUTATION_TOOL_NAMES`); a `PreToolUse` whose model is absent or unnormalizable produces `model_id = NULL` with the session still recorded (verified — `test28_a_tracked_execution_without_a_model_persists_a_null_model_ac3` asserts the persisted row is `("cx_session-no-model", None)`, `ac3_a_start_without_a_usable_model_still_carries_its_session` asserts `{"session_id":"cx_session-1","model_id":null}` for absent, `null`, blank, and non-string models, and `ac3_an_unusable_model_yields_no_model_id_without_rejecting_the_event` additionally covers a numeric and an object `model` without the event being rejected); untracked and delegation tools still create no scope and no provenance (verified — `test29_untracked_and_delegation_tools_persist_no_provenance_ac3` drives the MCP, `spawn_agent`, and `wait_agent` fixtures and asserts zero rows in both `mutation_trace_scopes` and `mutation_trace_scope_provenance`, with the pre-existing `untracked_mcp_pre_tool_use_creates_no_scope_and_never_touches_seam_or_git_dir` and `unknown_and_delegation_pre_tool_use_create_no_scope_ac3` still green). Additionally `ac3_only_the_start_boundary_carries_provenance` asserts the `close` payload carries no `provenance` key, and `ac3_scope_provenance_keeps_an_already_prefixed_session_id` proves an already-`cx_`-prefixed session is not double-prefixed. + - Context impact: local — one producer now populates the optional `Start` field T02 already accepts. No storage, ingress, runtime, attribution, or Agent Trace behavior changed, and the pure protocol and Quint model are untouched. T03 documents the Codex producer half of the provenance path in `context/cli/codex-mutation-scope-integration.md` and extends the producer section of `context/cli/mutation-scope-provenance.md`; the storage semantics stay T01's and the admission-bounded creation rule stays T02's. T04 does the same for Claude, T05–T06 consume the stored rows, and T07 still performs the final cross-system synchronization pass. + - Context synchronization: synced + +- [x] T04: `Populate Claude scope provenance` (status:done) + - Task ID: T04 + - Scope: In — introduce the injectable `ClaudeModelStateResolver` seam + (`(repository_root, canonical_session_id, agent_id) -> Result>`; + exact type and name may differ) and hand it to the Claude mutation adapter; + canonicalize the Claude session to `cc_`; back the production + resolver with the existing repository + `claude_model_state_by_session_and_agent` using the exact `agent_id` (`""` + for the main agent) at admission; attach the snapshot to the `Start` payload + before the harness-neutral generic ingress is entered. Out — + `claude_model_state` write semantics, the `sce hooks claude-model-state` + intake, giving the generic `mutation_scope` ingress any Claude-specific DB + knowledge, and the Claude adapter's scope identity, cleanup, or recovery + behavior. + - Dependencies: T02 + - Done when: the adapter takes the resolver as an injected dependency and + tests drive it without a real database; a main-agent scope resolves + `(cc_, "")`; a subagent scope resolves + `(cc_, exact agent_id)`; a subagent with no exact row records + `model_id = NULL` instead of the main agent's model; a resolver returning + `Ok(None)` records `model_id = NULL`; a resolver returning `Err` — an + infrastructure failure reading local state — also records `model_id = NULL` + and the `Start` still succeeds, keeping `model unavailable` distinct from + `mutation-scope Start could not be established`; a `PostModelSwitch` applied + after a scope is created leaves that scope's provenance row byte-identical. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::claude_mutation_scope`; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::claude_model_state`. + - Completed: 2026-09-09 + - Files changed: `cli/src/services/hooks/claude_mutation_scope/mod.rs` + - Result: Added the injectable `ClaudeModelStateResolver` seam with a production implementation backed by `claude_model_state_by_session_and_agent`. Claude `PreToolUse` now canonicalizes the session to `cc_`, resolves the exact agent ID (`""` for the main agent), normalizes the resolved model, and attaches the snapshot to the generic `start` payload. Resolver errors and unavailable models become `model_id = NULL` while `Start` proceeds; resolver failures are logged with a model-unavailable event. The state-root test path uses the same production lookup against its test database. Added focused injected-resolver tests for main/subagent lookup and null-model degradation, plus a real repository regression proving a later `PostModelSwitch` cannot rewrite existing scope provenance. The generic ingress and Claude lifecycle/cleanup paths remain unchanged. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::claude_mutation_scope` — passed, 111/111; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::claude_model_state` — passed, 12/12; `nix develop -c ./scripts/run-cli-cargo.sh fmt --manifest-path cli/Cargo.toml -- --check` — passed. + - Done checks: the resolver is injected through the Claude adapter's internal driver seam and exercised without a real database (verified — `pre_tool_use_resolves_main_and_subagent_model_state_exactly_at_admission`); main-agent resolution uses `(cc_session-1, "")`, subagent resolution uses `(cc_session-1, "agent-1")`, and each payload carries the matching model (verified by the same test); a missing exact subagent row does not inherit the main-agent model (verified — `subagent_without_exact_model_state_does_not_inherit_main_model`); absent exact model state and resolver infrastructure errors both preserve `cc_session-1`, emit `model_id: null`, and allow successful `Start` (verified — `missing_or_failed_model_resolution_keeps_session_provenance_and_allows_start`); production lookup reads the exact Claude model-state row and a subsequent `PostModelSwitch` leaves the stored provenance row unchanged (verified — `test18_model_switch_does_not_rewrite_scope_provenance`); existing Claude model-state behavior remains passing in the required 12-test suite. + - Context impact: local — Claude is now the second producer of the optional `Start` provenance field. No storage, generic ingress, mutation protocol, attribution, or Agent Trace behavior changed. Context synchronization extended `context/cli/claude-mutation-scope-integration.md` and `context/cli/mutation-scope-provenance.md` with the injectable resolver, exact agent-scoped snapshot, no-inheritance rule, and model-unavailable degradation semantics. + - Context synchronization: synced + +- [x] T05: `Preserve ScopeId provenance through post-commit mutation reconstruction` (status:done) + - Task ID: T05 + - Scope: In — keep the `scope_id` alongside each AI-selected + `PatchLineLocation` in `runtime/mutation_attribution.rs`; resolve + `ScopeProvenance` once per distinct scope through T01's read seam; build the + mutation-AI patch with `TouchedLine.session_id` set and `PatchHunk.model_id` + derived by the all-lines-agree rule (extending or wrapping + `attribution::patch_for_locations` rather than changing its direct-coverage + behavior). Out — the attribution algorithm, the bounded-replay window, the + lineage module's provenance propagation, and Agent Trace construction. + - Dependencies: T01 + - Done when: a single-scope hunk carries that scope's session and model; two + scopes with the same model still carry that model; two scopes with different + models leave `model_id` unset; a scope with `model_id = NULL` and a scope + with a known model leave `model_id` unset; a missing provenance row leaves + session and model unset without downgrading the line's AI classification; + multi-session hunks record each line's own session; existing AI / non-AI / + unresolved classification counts in the current tests are unchanged. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::mutation_trace::runtime::mutation_attribution`; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::mutation_trace::`. + - Completed: 2026-09-09 + - Files changed: `cli/src/services/mutation_trace/attribution.rs`; `cli/src/services/mutation_trace/runtime/mutation_attribution.rs`; `cli/src/services/mutation_trace/runtime/mutation_attribution/tests.rs` + - Result: Preserved `ScopeId` while projecting mutation-AI locations, loaded each distinct scope's `ScopeProvenance` through the mutation event source seam, and enriched the mutation-AI patch with per-line session IDs. Added conservative per-hunk model agreement: a known model is emitted only when every selected mutation-AI line in the hunk has the same known model; missing provenance, NULL models, and disagreements leave the hunk model unset without changing AI classification. Direct coverage exclusion and non-AI/unresolved projections remain unchanged. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::mutation_trace::runtime::mutation_attribution` — passed, 25/25; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::mutation_trace::` — passed, 354/354; `nix develop -c ./scripts/run-cli-cargo.sh fmt --manifest-path cli/Cargo.toml` — passed. + - Done checks: single-scope provenance and distinct-scope read-once behavior are covered by `a_mutation_ai_hunk_carries_scope_session_and_model_provenance` and `mutation_ai_lines_keep_their_sessions_and_agreeing_models_across_scopes`; same-model and per-line multi-session behavior is covered by `mutation_ai_lines_keep_their_sessions_and_agreeing_models_across_scopes`; conflicting models are covered by `mutation_ai_hunk_omits_a_conflicting_model_but_keeps_line_sessions`; NULL and missing provenance are covered by `missing_or_unknown_scope_provenance_does_not_downgrade_ai_lines` and `a_surviving_ai_mutation_line_is_attributed`; existing classification behavior remains green in the 354-test mutation-trace suite. + - Context impact: local — mutation reconstruction now consumes observational scope provenance and enriches only the mutation-AI projection. The mutation algorithm, bounded replay, direct-coverage exclusion, lineage semantics, Agent Trace construction, protocol, and schema are unchanged. Synchronized `context/cli/mutation-trace-agent-attribution.md` with the resolved session/model projection and conservative model-agreement behavior; the five mandatory root context files were verified unchanged. + - Context synchronization: synced + +- [x] T06: `Emit mutation-derived provenance in Agent Trace` (status:done) + - Task ID: T06 + - Scope: In — `build_trace_file(...)` and the related conversation-construction + helpers in `cli/src/services/agent_trace.rs`: union the related session IDs + from the matched direct and matched mutation hunks, and select + `contributor.model_id` by the combined agreement rule where an absent + evidence source does not count as unknown; add evidence fixtures for the new + cases. Out — `Contributor` / `ConversationRelated` / `AgentTrace` type + shapes, `config/schema/agent-trace.schema.json`, the hunk `ai` / `mixed` / + `unknown` classification rule, and `line_changes` bucketing. + - Dependencies: T05 + - Done when: a mutation-only hunk emits its model and related session; a + direct-only hunk emits exactly what it emits today (the `direct_only` golden + fixture is byte-unchanged); `direct X + mutation X` emits `X`; + `direct X + mutation Y` and `direct X + mutation unknown` omit `model_id`; + related sessions are the deduplicated, deterministically ordered union; all + built payloads still validate against the embedded schema. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::agent_trace`. + - Completed: 2026-09-09 + - Files changed: `cli/src/services/agent_trace.rs`; `cli/src/services/agent_trace/tests.rs`; `cli/src/services/agent_trace/fixtures/direct_plus_mutation/golden.json`; `cli/src/services/agent_trace/fixtures/partial_combined/golden.json` + - Result: Updated Agent Trace conversation construction to combine direct and mutation evidence for model and session provenance. Mutation-only AI hunks now emit their resolved model and related session; direct and mutation sessions form a deduplicated deterministic union; a model is emitted for combined evidence only when both present models agree, while a missing or conflicting mutation model clears the model without changing hunk classification. Direct-only evidence remains unchanged. Added focused provenance/agreement/schema-validation regressions and updated mixed-evidence goldens to reflect the deliberate `direct model + mutation unknown` rule. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::agent_trace` — passed, 152/152; `nix develop -c ./scripts/run-cli-cargo.sh fmt --manifest-path cli/Cargo.toml -- --check` — passed; `git diff --check` — passed. + - Done checks: mutation-only evidence emits its model and related session (verified — `mutation_only_evidence_emits_mutation_model_and_session`); direct-only output remains byte-identical (verified — `direct_only_evidence_matches_golden_agent_trace` and `direct_only_evidence_equals_direct_only_build_agent_trace`; the `direct_only` golden fixture was unchanged); direct X + mutation X emits X (verified — `combined_evidence_unions_sessions_and_requires_model_agreement`); direct X + mutation Y and direct X + mutation unknown omit `model_id` (verified by the same regression and updated `direct_plus_mutation` / `partial_combined` goldens); related sessions are deduplicated and deterministically ordered (verified by the same regression's `sess-a`, `sess-direct`, `sess-z` union); built payloads validate against the embedded schema (verified by all evidence regressions, including the new cases). + - Context impact: local — Agent Trace now consumes provenance already carried by the direct intersection and mutation-AI patch, combining model agreement and session links without changing payload type shapes, schema, hunk classification, or line-change bucketing. Context synchronization will update the Agent Trace generator context with the combined agreement rule; root context remains unchanged unless the mandatory pass finds a contradiction. + - Context synchronization: synced + +- [x] T07: `Add end-to-end provenance regressions and synchronize context` (status:done) + - Task ID: T07 + - Scope: In — real temporary-repository, real Agent Trace DB regressions in + `cli/src/services/hooks/mod.rs` covering the full + `Bash -> mutation scope -> commit -> persisted Agent Trace JSON` path for + both producers; the durable context updates named under **Context sync**; + the final branch comparison against `origin/codex-mutation-scope-integration`. + Out — new production behavior; every behavior this task exercises is already + delivered by T01–T06. + - Dependencies: T03, T04, T06 + - Done when: a Claude `Bash`-created file and a Codex `Bash`-created file each + reach persisted `agent_traces.trace_json` with `contributor.type = "ai"`, + their available model, and their canonical `cc_` / `cx_` related session URL; + the existing direct-attribution regressions and the three-layer persistence + separation assertions stay green; each context file named under **Context + sync** describes the implemented behavior, including the explicit + `mutation protocol attribution != scope provenance` boundary and the + `ScopeId` proves ownership / `ScopeProvenance` describes the owning scope + statement. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::`; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml`. + - Completed: 2026-09-10 + - Files changed: `cli/src/services/hooks/claude_mutation_scope/mod.rs`; `cli/src/services/hooks/codex_mutation_scope/mod.rs`; `cli/src/services/hooks/mod.rs`; `context/architecture.md`; `context/cli/claude-mutation-scope-integration.md`; `context/cli/codex-mutation-scope-integration.md`; `context/cli/mutation-scope-hook-ingress.md`; `context/cli/mutation-scope-provenance.md`; `context/cli/mutation-scope-runtime.md`; `context/cli/mutation-trace-agent-attribution.md`; `context/cli/mutation-trace-store.md`; `context/context-map.md`; `context/glossary.md`; `context/sce/agent-trace-db.md`; `context/sce/agent-trace-hooks-command-routing.md`; `context/sce/agent-trace-minimal-generator.md`; `context/plans/mutation-scope-provenance.md` + - Result: Added real temporary-Git and repository Agent Trace DB regressions for Claude and Codex Bash mutations, proving persisted mutation-derived Agent Trace JSON retains AI classification, model, and canonical session provenance while keeping direct diff and mutation persistence layers separate. Synchronized the named mutation-scope, Agent Trace, root architecture, glossary, and context-map records with the ownership-versus-provenance boundary and current end-to-end behavior. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::` — passed, 508 passed, 0 failed, 1 ignored; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml` — passed, 1,371 passed, 0 failed, 1 ignored; `nix develop -c ./scripts/run-cli-cargo.sh fmt --manifest-path cli/Cargo.toml -- --check` — passed; `git diff --check` — passed. + - Done checks: both real Claude and Codex Bash-created files reached persisted `agent_traces.trace_json` with `contributor.type = "ai"`, their available models, and canonical `cc_` / `cx_` related session URLs; direct `diff_traces` remained at zero while `post_commit_patch_intersections`, `mutation_trace_events`, and `agent_traces` each persisted one row per regression; all named context files describe the explicit `mutation protocol attribution != scope provenance` boundary and the `ScopeId` ownership / `ScopeProvenance` description distinction. + - Context impact: root — the completed behavior is cross-cutting across both mutation-scope producers, the mutation attribution consumer, Agent Trace persistence, and shared terminology; root architecture, glossary, and context-map records plus the affected domain records were updated to make the durable provenance contract discoverable. + - Context synchronization: synced + +## Open questions + +None. + +Every semantic choice is decided in the design: `direct X + mutation unknown -> +NULL` is settled in D8, with AC7 rewritten to cover direct-only attribution and +direct-coverage precedence rather than all direct attribution; provenance +replay/conflict semantics are fixed by D3's matrix; durable `Start` ordering and +its invariants are fixed by D4; the Claude model-resolution seam is fixed by D6. +Multiple models in one hunk omit `model_id`; an unknown model keeps the session +and leaves the model null; a Claude subagent without an exact row inherits +nothing; a model switch after scope creation does not rewrite history; direct +coverage exclusion stays authoritative on overlap; OpenCode/Pi keep provenance +optional until those adapters are wired. + +## Validation Report + +**Status:** validated +**Date:** 2026-09-10 + +### Commands run + +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::mutation_trace::` -> exit 0 (354 passed, 0 failed) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::mutation_scope` -> exit 0 (50 passed, 0 failed) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::claude_mutation_scope` -> exit 0 (111 passed, 0 failed) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::codex_mutation_scope` -> exit 0 (156 passed, 1 ignored) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::agent_trace` -> exit 0 (152 passed, 0 failed) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::agent_trace_db` -> exit 0 (29 passed, 0 failed) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::claude_model_state` -> exit 0 (12 passed, 0 failed) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::mutation_trace::store` -> exit 0 (97 passed, 0 failed) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::mutation_trace::runtime::mutation_attribution` -> exit 0 (25 passed, 0 failed) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::` -> exit 0 (508 passed, 1 ignored) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml` -> exit 0 (1,371 passed, 1 ignored) +- `nix develop -c ./scripts/run-cli-cargo.sh clippy --manifest-path cli/Cargo.toml --all-targets -- -D warnings` -> exit 0 (passed) +- `nix develop -c ./scripts/run-cli-cargo.sh fmt --manifest-path cli/Cargo.toml -- --check` -> exit 0 (passed) +- `nix run .#pkl-check-generated` -> exit 0 (ephemeral generation passed; 141 files) +- `nix flake check` -> exit 0 (all checks passed) +- `git diff origin/codex-mutation-scope-integration -- cli/migrations/agent-trace-repository/004_mutation_trace_protocol.sql` -> exit 0 (empty) +- `git diff origin/codex-mutation-scope-integration -- cli/src/services/mutation_trace/protocol.rs spec/` -> exit 0 (empty) +- `git diff origin/codex-mutation-scope-integration -- spec/ cli/src/services/mutation_trace/protocol.rs config/schema/agent-trace.schema.json` -> exit 0 (empty) +- `git diff --name-only origin/codex-mutation-scope-integration -- cli/migrations/agent-trace-repository/` -> exit 0 (only `005_mutation_scope_provenance.sql`) +- `git diff origin/codex-mutation-scope-integration -- cli/src/services/agent_trace/fixtures/direct_only/golden.json` -> exit 0 (empty; byte-unchanged) + +### Success-criteria verification + +- [x] AC1: Durable scope provenance storage, replay/conflict matrix, owning-scope requirement, and unchanged migration `004` -> storage and Agent Trace DB suites passed; migration diff was empty. +- [x] AC2: Optional provenance follows the ordered Start path without protocol changes -> mutation-scope and mutation-trace suites passed; protocol/spec diff was empty. +- [x] AC3: Codex tracked mutations persist canonical session and normalized model provenance -> Codex suite passed, including Bash/apply_patch and null-model regressions. +- [x] AC4: Claude tracked mutations use exact model state and preserve admission-time provenance -> Claude mutation-scope and model-state suites passed. +- [x] AC5: Mutation lineage preserves session IDs and conservative model agreement -> mutation attribution and mutation-trace suites passed. +- [x] AC6: Agent Trace emits mutation-derived sessions/models and combined agreement -> Agent Trace and hooks suites passed, including Claude/Codex end-to-end regressions. +- [x] AC7: Direct-only attribution, golden output, and direct-coverage precedence remain unchanged -> Agent Trace/hooks suites passed and direct-only golden diff was empty. +- [x] AC8: Verified protocol and Agent Trace schema remain unchanged; migration `005` is the only migration addition -> protocol/spec/schema diff was empty and migration listing contained only `005_mutation_scope_provenance.sql`. + +### Failed checks and follow-ups + +- None. + +### Residual risks + +- None identified. diff --git a/context/sce/agent-trace-db.md b/context/sce/agent-trace-db.md index 70715ef4..0edd24f7 100644 --- a/context/sce/agent-trace-db.md +++ b/context/sce/agent-trace-db.md @@ -41,12 +41,18 @@ pub type RepositoryAgentTraceDb = TursoDb; ``` -This adapter has no canonical `DbSpec::db_path()`; callers must resolve `/sce/repos//agent-trace.db` first and use explicit-path `TursoDb` constructors. Its migration list is `generated_migrations::AGENT_TRACE_REPOSITORY_MIGRATIONS`: the fresh multi-statement baseline `cli/migrations/agent-trace-repository/001_repository_schema.sql` plus the additive `002_repository_source_instance_id.sql` (adds `repository_metadata.source_instance_id`) and `003_claude_model_state.sql` (adds the non-exported Claude model-state register). The baseline schema includes `repository_metadata` plus the existing repository-level Agent Trace tables, indexes, and triggers, and intentionally has no `checkout_id` columns on trace tables. `RepositoryAgentTraceDb::verify_or_initialize_repository_metadata(repository_id) -> Result` inserts the singleton metadata row on first initialization, errors if an existing DB stores a different repository ID, and atomically claims `source_instance_id` for this physical database via `UPDATE ... WHERE source_instance_id = ''` (a losing racer's generated candidate is discarded and an already-valid stored value is never overwritten), returning the typed `RepositoryMetadata { repository_id, source_instance_id }`. `source_instance_id` is generated by application code (`generate_source_instance_id()`, UUID v4) and validated with `is_valid_source_instance_id()` (non-empty once trimmed); it is never derived from `repository_id`, remote URL, checkout ID, filesystem path, hostname, or user/workspace identity, and stays stable across reopen and repeated `sce setup` runs. `RepositoryAgentTraceDb::repair_missing_repository_schema_migration_metadata()` is a narrow concurrent-first-open repair seam: it never creates trace tables, but if every required repository schema table already exists and only the one-file baseline migration record is missing, it records `001_repository_schema` and rechecks readiness. +This adapter has no canonical `DbSpec::db_path()`; callers must resolve `/sce/repos//agent-trace.db` first and use explicit-path `TursoDb` constructors. Its migration list is `generated_migrations::AGENT_TRACE_REPOSITORY_MIGRATIONS`: the fresh multi-statement baseline `cli/migrations/agent-trace-repository/001_repository_schema.sql` plus the additive `002_repository_source_instance_id.sql` (adds `repository_metadata.source_instance_id`), `003_claude_model_state.sql` (adds the non-exported Claude model-state register), `004_mutation_trace_protocol.sql`, and `005_mutation_scope_provenance.sql` (the mutation-cursor protocol tables and the scope-provenance table; see [mutation-trace store](../cli/mutation-trace-store.md) and [mutation-scope provenance](../cli/mutation-scope-provenance.md)). The baseline schema includes `repository_metadata` plus the existing repository-level Agent Trace tables, indexes, and triggers, and intentionally has no `checkout_id` columns on trace tables. `RepositoryAgentTraceDb::verify_or_initialize_repository_metadata(repository_id) -> Result` inserts the singleton metadata row on first initialization, errors if an existing DB stores a different repository ID, and atomically claims `source_instance_id` for this physical database via `UPDATE ... WHERE source_instance_id = ''` (a losing racer's generated candidate is discarded and an already-valid stored value is never overwritten), returning the typed `RepositoryMetadata { repository_id, source_instance_id }`. `source_instance_id` is generated by application code (`generate_source_instance_id()`, UUID v4) and validated with `is_valid_source_instance_id()` (non-empty once trimmed); it is never derived from `repository_id`, remote URL, checkout ID, filesystem path, hostname, or user/workspace identity, and stays stable across reopen and repeated `sce setup` runs. `RepositoryAgentTraceDb::repair_missing_repository_schema_migration_metadata()` is a narrow concurrent-first-open repair seam: it never creates trace tables, but if every required repository schema table already exists and only the one-file baseline migration record is missing, it records `001_repository_schema` and rechecks readiness. `RepositoryAgentTraceDb` exposes repository-level write helpers for the current row families by delegating to the same typed insert payloads and parameterized SQL used by the checkout-scoped adapter: `insert_diff_trace`, `insert_post_commit_patch_intersection`, `insert_agent_trace`, `insert_message`, `insert_messages`, `insert_part`, `insert_parts`, and `insert_conversation_text_event`. It also exposes `recent_diff_trace_patches(cutoff_time_ms, end_time_ms)` by delegating to the shared recent diff-trace query/parser helper, so repository-scoped attribution reads use the same chronological inclusive window semantics without a checkout filter. These methods preserve the existing row shapes and do not add checkout provenance columns or checkout-scoped write/query APIs. The repository-scoped adapter is consumed by `agent_trace_storage`, active hook runtime opening, Agent Trace setup/doctor lifecycle, and `sce sync`. Hook writers/readers resolve the current repository storage context before using `RepositoryAgentTraceDb`. The migration-running `new_at(path)` constructor is used by setup/lifecycle; hook runtime uses the no-migration constructor and fails open with `Run 'sce setup'.` guidance when schema readiness is not met. There is no longer a checkout-scoped adapter or trace database inspection service. +The real Claude and Codex `Bash` regressions verify this repository-scoped +adapter boundary end to end: migration `005` stores scope provenance, +post-commit reads it for mutation projection, and `agent_traces.trace_json` +contains the model and canonical session link without adding mutation evidence +to `diff_traces` or the direct intersection table. + ## Non-goals - No read/query helper for loading messages with their joined parts exists in the current runtime; the typed write helpers (`insert_message`, `insert_messages`, `insert_part`, `insert_parts`, `insert_conversation_text_event`) are the only exposed message/part API surface. Message/part query helpers are deferred to a future task. diff --git a/context/sce/agent-trace-hooks-command-routing.md b/context/sce/agent-trace-hooks-command-routing.md index 2eb49e2a..9f83276a 100644 --- a/context/sce/agent-trace-hooks-command-routing.md +++ b/context/sce/agent-trace-hooks-command-routing.md @@ -122,9 +122,16 @@ - `sce hooks claude-model-state` is a silent, local-only lifecycle intake for raw Claude `SessionStart` and `PostModelSwitch` events. A model-bearing `SessionStart` writes normalized `claude/` state, while a `PostModelSwitch` validates `from_model` and `to_model` but writes normalized `to_model`; both use canonical `cc_` plus exact optional `agent_id` scope (`""` for the main conversation). Missing or null `agent_id` means the main scope; a present string is trimmed and must remain non-empty, so malformed empty or non-string values fail open without a state write. Current Claude sources include `command`, `picker`, `sdk`, `auto`, and `resume`; SCE accepts any non-empty source string and stores it opaquely. The command uses the existing guarded latest-locally-observed register and local SCE observation time. SessionStart without a model is a no-op that cannot clear existing state. The command reads and writes directly through the no-migration hook-runtime repository DB path before returning, does not migrate, sync, or access the network, and returns zero stdout bytes with logger-only fail-open diagnostics for input, clock, DB-open, and DB-write failures. Claude's SessionStart invocation is synchronous relative to Claude execution, while PostModelSwitch is asynchronous; overlapping hooks and the post-switch visibility race are accepted and local observation time does not prove Claude causal ordering. Generated Claude settings register both lifecycle events for this command, while the existing five SCE registrations remain unchanged. Claude Code 2.1.250 and 2.1.251 compatibility smoke passed with an unknown PostModelSwitch registration, so installation remains unconditional with no raised minimum or capability gate. - `session-model` is no longer a supported `sce hooks` subcommand and generated Claude settings no longer produce the retired generic session-model route. The `session_models` DB API/table and generic fallback are removed from active code; upgraded databases may still contain the retired table, but runtime paths no longer read or write it. The separate `sce hooks claude-model-state` command is a Claude-specific local register, and `diff-trace` consults only its exact `(cc_, agent_id)` state after direct and transcript attribution fail; this does not restore the generic abstraction. - `sce hooks codex` is a separate single dispatcher subcommand (not routed through `diff-trace`/`conversation-trace`), classifying raw Codex hook JSON internally instead. Its `UserPromptSubmit` and `Stop` arms are each a second writer into the same `messages`/`parts` tables, calling the shared `insert_conversation_text_event` atomic primitive rather than the plain `insert_messages`/`insert_parts` calls described above. Its `PostToolUse(apply_patch)` arm is a second, independent writer into `diff_traces` via the existing `insert_diff_trace`, carrying `tool_name = "codex"` and the same `cx_`-prefixed `session_id`; it is independent of the concrete Codex mutation-scope adapter. Apply_patch persistence requires a trimmed non-empty session, preserves reported model IDs without fabricating a provider prefix, and returns empty stdout on every non-policy success or fail-open path. Before persistence it resolves source and move-destination paths independently from the event `cwd` against the canonical Git root: valid `..` components and absolute-inside paths are accepted, missing Add File targets are allowed through their nearest existing prefix, and outside or symlink-escaping mappings are rejected. The existing Codex command resolves that Git root at invocation time and invokes the installed helper with quoted paths, so root and nested cwd (including spaced repository paths) share one entrypoint; its root-resolution failure remains silent and fail-open where designed. The Codex setup/doctor boundary is separate from evidence intake: `.codex/hooks.json` is merged through the shared structural `codex_hook_config` service, which preserves valid user handlers and recognizes the generated helper path plus either the `sce hooks codex` or `sce hooks codex-mutation-scope` command contract, attributing each handler to whichever it matches and merging the two contracts' registrations independently. The mutation-scope `PreToolUse`/`PostToolUse` registrations use matcher `^(Bash|apply_patch)$`; `Stop`/`Interrupt`/`SubagentStop`/`SessionEnd` are unmatched and feed the generic mutation-scope ingress ([../cli/mutation-scope-hook-ingress.md](../cli/mutation-scope-hook-ingress.md)). -- `sce hooks mutation-scope` (hidden) is a separate single ingress (not routed through `diff-trace`/`conversation-trace`) that drives the mutation-scope runtime rather than writing conversation/diff evidence. It reads one normalized JSON lifecycle object from STDIN via the shared `read_hook_stdin`, strictly parses it into exactly `start`/`advance`/`close`/`flush`/`abandon`, and translates it into one `RuntimeBoundary` (`start`/`advance`/`close` → `Start`/`Advance`/`Close`; `flush` → `Flush`) passed to `mutation_trace::runtime::coordinate(...)`, or a direct `mutation_trace::runtime::abandon_scope(...)` call for `abandon`. `scope_id`/`event_id`/`actor_kind` (`claude_code`/`codex`/`opencode`/`pi`) are forwarded verbatim — no trim, prefix, hash, UUID, or timestamp — because `EventId` equality is the runtime's replay/idempotency key; any `worktree_id` key is a hard rejection (worktree identity is derived by the runtime from the invoking checkout). DB acquisition is lazy: a `FnOnce` provider closure reusing `open_agent_trace_db_for_hook_runtime` is passed into `coordinate()`/`abandon_scope()` so it runs inside the runtime's protected-worktree ordering, never before. **Non-fail-open intake contract, distinct from `diff-trace`/`conversation-trace`:** a lost lifecycle boundary can change which scope stays live and therefore alter attribution, so the dispatch arm is unwrapped like `pre-commit` (no `Ok(...)` fail-open shim) and there is no dropped-boundary / `exit 0` branch. Results are classified by durable completion — a malformed payload or any pre-completion `CoordinateError`/`AbandonScopeError` returns `CliError`/non-zero; `CoordinateError::MarkerClearAfterCommit` and `AbandonScopeError::MarkerClearAfterCompletion` are the two carried-outcome success variants (the durable transition already succeeded and only the trailing external-taint marker cleanup failed), reported as empty-stdout `Ok` with the failure logged via `sce.hooks.mutation_scope.marker_clear_after_durable_completion` and the transition **not** retried. Every successful execution emits empty stdout with no serialized outcome/revision/worktree/scope state. It writes `mutation_trace_*` rows only — never `diff_traces`, `post_commit_patch_intersections`, or `agent_traces`. A first Claude Code adapter driver now consumes it in-process (see below); the Codex adapter also consumes it in-process, while OpenCode and Pi still have no lifecycle adapter. See [mutation-scope-hook-ingress.md](../cli/mutation-scope-hook-ingress.md) for the full contract. +- `sce hooks mutation-scope` (hidden) is a separate single ingress (not routed through `diff-trace`/`conversation-trace`) that drives the mutation-scope runtime rather than writing conversation/diff evidence. It reads one normalized JSON lifecycle object from STDIN via the shared `read_hook_stdin`, strictly parses it into exactly `start`/`advance`/`close`/`flush`/`abandon`, and translates it into one `RuntimeBoundary` (`start`/`advance`/`close` → `Start`/`Advance`/`Close`; `flush` → `Flush`) passed to `mutation_trace::runtime::coordinate(...)`, or a direct `mutation_trace::runtime::abandon_scope(...)` call for `abandon`. `scope_id`/`event_id`/`actor_kind` (`claude_code`/`codex`/`opencode`/`pi`) are forwarded verbatim — no trim, prefix, hash, UUID, or timestamp — because `EventId` equality is the runtime's replay/idempotency key; `start` alone additionally accepts an optional `provenance` object (non-blank `session_id`, optional `model_id`) forwarded the same way as `RuntimeBoundary::Start`'s `StartProvenance` and, when the runtime's admission-bounded rule allows it, durably registered before the protocol commits — a row may only be created while the durable scope is still `NeverSeen`, so a `start` replayed against an already-admitted scope that has no provenance still succeeds and still persists none, while an existing row is validated on every provenance-carrying `start` and a differing `session_id` remains a hard conflict — while `advance`/`close`/`flush`/`abandon` reject the key; any `worktree_id` key is a hard rejection (worktree identity is derived by the runtime from the invoking checkout). DB acquisition is lazy: a `FnOnce` provider closure reusing `open_agent_trace_db_for_hook_runtime` is passed into `coordinate()`/`abandon_scope()` so it runs inside the runtime's protected-worktree ordering, never before. **Non-fail-open intake contract, distinct from `diff-trace`/`conversation-trace`:** a lost lifecycle boundary can change which scope stays live and therefore alter attribution, so the dispatch arm is unwrapped like `pre-commit` (no `Ok(...)` fail-open shim) and there is no dropped-boundary / `exit 0` branch. Results are classified by durable completion — a malformed payload or any pre-completion `CoordinateError`/`AbandonScopeError` returns `CliError`/non-zero; `CoordinateError::MarkerClearAfterCommit` and `AbandonScopeError::MarkerClearAfterCompletion` are the two carried-outcome success variants (the durable transition already succeeded and only the trailing external-taint marker cleanup failed), reported as empty-stdout `Ok` with the failure logged via `sce.hooks.mutation_scope.marker_clear_after_durable_completion` and the transition **not** retried. Every successful execution emits empty stdout with no serialized outcome/revision/worktree/scope state. It writes `mutation_trace_*` rows only — never `diff_traces`, `post_commit_patch_intersections`, or `agent_traces`. A first Claude Code adapter driver now consumes it in-process (see below); the Codex adapter also consumes it in-process, while OpenCode and Pi still have no lifecycle adapter. See [mutation-scope-hook-ingress.md](../cli/mutation-scope-hook-ingress.md) for the full contract. - `sce hooks claude-mutation-scope` (hidden) is the first concrete harness adapter targeting the mutation-scope runtime: it reads one raw Claude hook JSON event from STDIN, classifies the tool, and drives `mutation_scope::run_mutation_scope_from_payload` (the same in-process `pub(crate)` seam above, called directly — not by re-invoking `sce hooks mutation-scope`) to `start`/`close`/`abandon`/`flush` a scope per event. The dispatch arm is unwrapped like `mutation-scope`, not fail-open. Registered by `sce setup` (`config/pkl/renderers/claude-content.pkl`) for `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionDenied`, `UserPromptSubmit`, `Stop`, `StopFailure`, `SubagentStop`, `SessionEnd`, and `WorktreeRemove`, with no `matcher` (the adapter classifies tools in Rust), so a real Claude Code session now reaches it. Full contract, event mapping, and design rationale are in [../cli/claude-mutation-scope-integration.md](../cli/claude-mutation-scope-integration.md). - `sce hooks codex-mutation-scope` (hidden) is the second concrete harness adapter: it reads raw Codex lifecycle events and drives the same in-process seam with one tracked scope per `Bash`/`apply_patch` execution, write-ahead fail-closed admission, proven terminal close, identity-scoped cleanup, and checkout-local recovery bookkeeping. MCP and unknown tools remain usable but are untracked and outside individual mutation attribution; their lifecycle is not treated as read-only. It is registered by `sce setup --codex` alongside the existing `sce hooks codex` conversation/diff contract. Full mapping and the tested Codex 0.153.4 boundary are in [../cli/codex-mutation-scope-integration.md](../cli/codex-mutation-scope-integration.md). +- `sce hooks codex-mutation-scope` (hidden) is the second concrete harness adapter: it reads raw Codex lifecycle events and drives the same in-process seam with one tracked scope per `Bash`/`apply_patch` execution, write-ahead fail-closed admission, proven terminal close, identity-scoped cleanup, and checkout-local recovery bookkeeping. MCP and unknown tools remain usable but are untracked and outside individual mutation attribution; their lifecycle is not treated as read-only. It is registered by `sce setup --codex` alongside the existing `sce hooks codex` conversation/diff contract. Full mapping and the tested Codex 0.153.4 boundary are in [../cli/codex-mutation-scope-integration.md](../cli/codex-mutation-scope-integration.md). + +The `mutation_provenance_e2e` regressions in `cli/src/services/hooks/mod.rs` +exercise both concrete adapters through a real repository, commit, mutation +projection, and repository DB write. They verify the final Agent Trace JSON +contains mutation-derived model/session provenance while direct evidence tables +remain separate. ## Explicit non-goals in the current baseline @@ -143,6 +150,7 @@ - [CLI config precedence contract](../cli/config-precedence-contract.md) - [SCE sync command](../cli/sync-command.md) - [Mutation-scope hook ingress](../cli/mutation-scope-hook-ingress.md) +- [Mutation-scope provenance](../cli/mutation-scope-provenance.md) - [Mutation-scope runtime: the harness-adapter contract](../cli/mutation-scope-runtime.md) - [Claude mutation-scope integration: the first concrete harness adapter](../cli/claude-mutation-scope-integration.md) - [Codex mutation-scope integration: the second concrete harness adapter](../cli/codex-mutation-scope-integration.md) diff --git a/context/sce/agent-trace-minimal-generator.md b/context/sce/agent-trace-minimal-generator.md index 28b6a9db..8f663178 100644 --- a/context/sce/agent-trace-minimal-generator.md +++ b/context/sce/agent-trace-minimal-generator.md @@ -12,16 +12,16 @@ Given a `constructed_patch` (AI candidate) and a `post_commit_patch` (canonical - **`mixed`** — a non-empty proper subset of the hunk's touched lines is covered. - **`unknown`** — no touched line in the hunk is covered. 3. With no mutation evidence this is equivalent to the direct-only slot rule, since a direct-intersection hunk always holds an ordered sub-multiset of its `post_commit_patch` hunk's touched lines: `ai` when the `intersection_patch` hunk has identical touched lines (same count, kind, `line_number`, content, order), `mixed` when that hunk exists but is a proper subset, `unknown` when no `intersection_patch` hunk shares the `old_start`. The direct-only path `build_agent_trace(...)` produces exactly this classification. -4. Map `Conversation.contributor.model_id` from the matched `intersection_patch` hunk when contributor type is `ai` or `mixed`; omit `model_id` when provenance is missing (`None`). A hunk classified `ai`/`mixed` purely through mutation-derived coverage has no matched direct hunk and therefore no `model_id`. -5. For each emitted conversation, derive optional `conversation.related` entries from non-empty `session_id` values on touched lines in the matched `intersection_patch` hunk; emit related entries as `{ "type": "session", "url": "https://sce.crocoder.dev/sessions/" }`, deduplicated by session ID with deterministic ordering, and omit `related` when no included lines provide `session_id`. Structured diff-trace reconstruction supplies the persisted canonical `cc_...` row session on every touched line, so Claude related-session URLs use canonical persisted provenance rather than the raw payload session. +4. Map `Conversation.contributor.model_id` from the matched direct and mutation hunks when contributor type is `ai` or `mixed`: an absent evidence source does not count as unknown, one present model passes through, and both present models must be known and equal. Omit `model_id` when both sources are present but either model is missing or they disagree. A hunk classified `ai`/`mixed` purely through mutation-derived coverage may therefore emit the mutation model. +5. For each emitted conversation, derive optional `conversation.related` entries from non-empty `session_id` values on touched lines in both matched evidence hunks; emit related entries as `{ "type": "session", "url": "https://sce.crocoder.dev/sessions/" }`, deduplicated by session ID with deterministic ordering, and omit `related` when no included lines provide `session_id`. Structured diff-trace reconstruction supplies the persisted canonical `cc_...` row session on direct lines, while mutation reconstruction supplies scope provenance on mutation-attributed lines. 6. Emit one `Conversation` per `post_commit_patch` hunk, each carrying the trace lookup `url`, one `TraceFile` per `post_commit_patch` file, and one range per hunk with a deterministic `content_hash` computed from that hunk's touched-line kind/content. ## Separated direct/mutation evidence `build_agent_trace_from_evidence(evidence, post_commit_patch, metadata)` is the seam that classifies from two independent AI-evidence sources: -- `evidence.direct_patch` — the reconstructed direct patch, intersected against `post_commit_patch` internally exactly as before. It is the sole source of `Conversation.contributor.model_id`, `Conversation.related` session links, and the top-level `tool` object (still omitted when the direct intersection is empty). -- `evidence.mutation_ai_patch` — a target-shaped, provenance-free set of committed touched lines that causal mutation-lineage replay attributed to AI (an AI event's line that survived every later observed tree transition into the committed tree), produced by [../cli/mutation-trace-agent-attribution.md](../cli/mutation-trace-agent-attribution.md). It only widens combined AI coverage for hunk classification and contributes no model, session, tool, or tool-version metadata. +- `evidence.direct_patch` — the reconstructed direct patch, intersected against `post_commit_patch` internally exactly as before. It supplies direct model/session provenance and remains the sole source of the top-level `tool` object (still omitted when the direct intersection is empty). +- `evidence.mutation_ai_patch` — a target-shaped set of committed touched lines with resolved scope provenance that causal mutation-lineage replay attributed to AI (an AI event's line that survived every later observed tree transition into the committed tree), produced by [../cli/mutation-trace-agent-attribution.md](../cli/mutation-trace-agent-attribution.md). It widens combined AI coverage for hunk classification and supplies mutation model/session metadata when available. Per hunk, a `post_commit_patch` touched line is covered when it pairs one-to-one on `(kind, line_number, content)` with a line in the direct intersection hunk or the mutation-AI hunk at the same `old_start`; the covered fraction drives `ai` / `mixed` / `unknown` as in the Contract above. `line_changes` buckets follow that combined classification. @@ -32,7 +32,7 @@ Per hunk, a `post_commit_patch` touched line is covered when it pairs one-to-one | Type | Purpose | | ----------------------- | ------------------------------------------------------------------------------------------------------------ | | `HunkContributor` | Enum: `Ai`, `Mixed`, `Unknown` | -| `AgentTraceEvidence` | Internal builder input pairing borrowed `direct_patch` (reconstructed direct patch) and `mutation_ai_patch` (target-shaped, provenance-free mutation-AI coverage) | +| `AgentTraceEvidence` | Internal builder input pairing borrowed `direct_patch` (reconstructed direct patch) and `mutation_ai_patch` (target-shaped mutation-AI coverage with optional scope provenance) | | `Contributor` | Nested per-conversation object carrying `type: HunkContributor` and optional `model_id` omitted when absent | | `ConversationRelated` | Schema-aligned related-link entry shape (`type` as free-form string + `url`) for optional `conversation.related` | | `LineRange` | New-file line span with `start_line` + `end_line` + `content_hash` | @@ -46,7 +46,12 @@ Per hunk, a `post_commit_patch` touched line is covered when it pairs one-to-one | `LineChangeAttribution` | `metadata.sce.line_changes` shape: `{ ai, mixed, unknown }`, each a `LineChangeCounts`, `#[serde(default)]` | | `AgentTrace` | Top-level payload: `version`, `id`, `timestamp`, optional `vcs`, optional `tool`, `metadata`, `files` | -All types are `serde`-serializable with `snake_case` field naming. `Conversation.url` is always serialized as `https://sce.crocoder.dev/conversations/{agent_trace.id}` for the generated top-level trace ID. `Conversation.contributor` serializes as a nested object with a JSON field named `type`; `model_id` is present only when a concrete value exists. `Conversation.related` is optional and omitted when `None` (`skip_serializing_if = "Option::is_none"`) and populated from matched intersection-line `session_id` provenance as session links. +All types are `serde`-serializable with `snake_case` field naming. `Conversation.url` is always serialized as `https://sce.crocoder.dev/conversations/{agent_trace.id}` for the generated top-level trace ID. `Conversation.contributor` serializes as a nested object with a JSON field named `type`; `model_id` is present only when the combined direct/mutation evidence has one agreed concrete value. `Conversation.related` is optional and omitted when `None` (`skip_serializing_if = "Option::is_none"`) and populated from matched direct and mutation touched-line `session_id` provenance as deduplicated session links. + +The persisted Agent Trace path is covered with real Claude and Codex `Bash` +mutations: mutation-only AI hunks retain their resolved model and canonical +session link, while direct evidence remains a separate source. This preserves +the boundary that mutation protocol attribution is not itself scope provenance. ## Payload shape @@ -107,12 +112,12 @@ Current output includes top-level metadata fields with this contract: - `classify_hunk(post_commit_hunk, intersection_hunks) -> HunkContributor` — the direct-only slot rule, retained as a primitive; the builder itself now classifies through the internal combined direct+mutation line-coverage rule. - `range_content_hash(hunk) -> String` — internal helper that computes the serialized range-level `murmur3:` content fingerprint from `PatchHunk.lines` using versioned, length-delimited touched-line serialization in patch order. The hash input includes touched-line kind and content, and excludes hunk positions, line numbers, file paths, trace metadata, contributor/model metadata, VCS metadata, tool metadata, and database IDs. - `build_agent_trace(constructed_patch, post_commit_patch, metadata) -> Result` — direct-only entrypoint, retained unchanged; delegates to `build_agent_trace_from_evidence` with an empty `mutation_ai_patch`. It validates `metadata.commit_timestamp` as RFC 3339, uses it as top-level `timestamp`, derives a UUIDv7 `id` from that same commit-time moment, derives one conversation URL from that `id`, conditionally emits `vcs` only when `metadata.vcs_type` is present (mapping `vcs.type` from metadata and `vcs.revision` from `metadata.commit_revision`), carries optional tool metadata inputs (`metadata.tool_name`, `metadata.tool_version`) for top-level `tool` mapping, and always emits `metadata.sce.version` from the compiled package version. When the direct `intersection_patch.files` is empty, `tool` is always `None` regardless of metadata values. -- `build_agent_trace_from_evidence(evidence: AgentTraceEvidence, post_commit_patch, metadata) -> Result` — separated-evidence entrypoint (see [Separated direct/mutation evidence](#separated-directmutation-evidence)): identical top-level metadata behavior, but classifies each hunk from the union of direct and mutation-derived AI coverage while keeping `model_id`, `related`, and `tool` bound to the direct intersection only. +- `build_agent_trace_from_evidence(evidence: AgentTraceEvidence, post_commit_patch, metadata) -> Result` — separated-evidence entrypoint (see [Separated direct/mutation evidence](#separated-directmutation-evidence)): identical top-level metadata behavior, classifies each hunk from the union of direct and mutation-derived AI coverage, unions direct/mutation related sessions, and emits a model only when the present evidence sources agree under the combined rule; top-level `tool` remains bound to the direct intersection. ## Test fixture contract - Golden fixtures under `cli/src/services/agent_trace/fixtures/**/golden.json` pin deterministic literal values for top-level `id`, `timestamp`, optional `vcs`, `metadata.sce.version`, `metadata.sce.line_changes`, per-conversation `url`, range-level `content_hash`, and expected file/conversation shapes. -- Reconstruction fixtures pair `incremental_*.patch` inputs with a `post_commit.patch` and drive `build_agent_trace`. Evidence fixtures (`direct_only`, `exclusive_without_direct`, `direct_plus_mutation`, `partial_combined`, `newer_nonexclusive_blocks`, `mutation_only_no_provenance`) instead pair `direct.patch` + `mutation_ai.patch` + `post_commit.patch` and drive `build_agent_trace_from_evidence`, pinning that mutation-only coverage classifies without fabricating `model_id`, `related`, or top-level `tool`, that direct provenance survives a direct+mutation `ai` hunk, and that the empty-mutation path is byte-identical to `build_agent_trace`. +- Reconstruction fixtures pair `incremental_*.patch` inputs with a `post_commit.patch` and drive `build_agent_trace`. Evidence fixtures (`direct_only`, `exclusive_without_direct`, `direct_plus_mutation`, `partial_combined`, `newer_nonexclusive_blocks`, `mutation_only_no_provenance`) instead pair `direct.patch` + `mutation_ai.patch` + `post_commit.patch` and drive `build_agent_trace_from_evidence`, pinning mutation-only coverage with and without resolved provenance, the combined model-agreement and session-union rules, the absence of fabricated metadata when provenance is unknown, and the empty-mutation path's byte identity with `build_agent_trace`. - Tests validate golden fixtures and built payloads against the embedded schema, assert core runtime metadata directly (`version`, `timestamp`, optional `vcs`, and `metadata.sce.version`), and compare `vcs`, optional `tool`, `metadata.sce.line_changes`, and normalized `files` against fixture truth. Expected fixture URLs are normalized to the runtime `AgentTrace.id` before the existing file-shape comparison because UUIDv7 generation includes non-deterministic bits. ## Relationship to existing patch service