diff --git a/cli/src/services/hooks/claude_mutation_scope/fixtures/NOTES.md b/cli/src/services/hooks/claude_mutation_scope/fixtures/NOTES.md index 7f146ee41..6c3638ee8 100644 --- a/cli/src/services/hooks/claude_mutation_scope/fixtures/NOTES.md +++ b/cli/src/services/hooks/claude_mutation_scope/fixtures/NOTES.md @@ -318,3 +318,45 @@ double-fork, or daemonizing patterns this probe did not exercise — those remain unproven, and D20's unsupported-boundary wording stays in place regardless, since this PR implements no detection or supervision either way, and no static shell-command inspection is added. + +## T01 managed-background lifecycle probe (Claude Code 2.1.284) + +### Capture outcome + +T01 was attempted on the installed Claude Code `2.1.284` on 2026-10-01. The +probe used an isolated temporary settings file containing an additive capture +hook and `--setting-sources user`; the repository `.claude/settings.json` was +not changed. The capture hook received one empty-stdin `SessionStart` event at +`2026-10-01T20:39:15.014Z`, then the non-interactive Claude session produced no +model response or tool event for more than 120 seconds and was interrupted. +A no-hook control invocation behaved the same way for 30 seconds. A `--bare` +control exited immediately because this environment's authentication is +Claude.ai OAuth, while bare mode requires an API key. The machine-readable +record is `probe18-t01-current-version.evidence.json`. + +No current-version tool or lifecycle payload was fabricated from the missing +response. The 14 cases are recorded as not capturable for this run because the +session never reached a model turn: successful background Bash; non-zero exit; +cancellation/kill; another tool while active; Stop; StopFailure; +UserPromptSubmit; SubagentStop; SessionEnd; a repository mutation after the +initial acknowledgement; Claude process exit; idle-session completion; +foreground-to-background transition; and mid-session hook-registration +changes. Existing `probe14-*` and `probe17-*` fixtures remain prior-version +evidence from Claude Code `2.1.258`, not current-version T01 captures. + +### G1-G7 and boundary answers + +| Determination | Claude 2.1.284 result | Evidence | +|---|---|---| +| G1: acknowledgement precedes process completion and carries an identifier | unknown | No background tool call reached the hook capture; `probe14` shows this only on 2.1.258. | +| G2: later terminal event for success, failure, and cancellation | unknown | No current-version terminal event was observed. | +| G3: stable identifier joins acknowledgement and terminal event | unknown | No current-version pair was observed. | +| G4: last child write < termination <= terminal event delivery | unknown | No child write, termination, or terminal event timestamps were produced. | +| G5: lifecycle boundaries prove no further mutation | unknown for Stop, StopFailure, UserPromptSubmit, SubagentStop, SessionEnd, WorktreeRemove, and Claude process exit | None of the required current-version boundary sequences ran. | +| G6: terminal event arrives while session is idle | unknown | No background task completed. | +| G7: registration and settings-snapshot behavior | unknown | No lifecycle sequence reached the point where registration behavior could be tested. | + +Under the plan, `unknown` is treated as `no`. The decision gate is therefore +**stop and revise toward SCE-owned process supervision**: the current denial +stays in place, and T02 must not proceed as an implementation of the +hook-driven lifecycle until a later probe establishes the required contract. diff --git a/cli/src/services/hooks/claude_mutation_scope/fixtures/probe18-t01-current-version.evidence.json b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe18-t01-current-version.evidence.json new file mode 100644 index 000000000..ac1d28e8e --- /dev/null +++ b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe18-t01-current-version.evidence.json @@ -0,0 +1,41 @@ +{ + "probe": "T01", + "tested_at_utc": "2026-10-01", + "claude_version": "2.1.284", + "settings": "isolated temporary settings passed with --settings and --setting-sources user; the repository .claude/settings.json was not changed", + "capture_hook": "scratchpad/t01-capture/capture.sh", + "capture_directory": "/tmp/sce-claude-t01/events", + "captured_events": [ + { + "hook_event_name": "SessionStart", + "captured_at": "2026-10-01T20:39:15.014Z", + "payload": "empty stdin" + } + ], + "attempts": [ + { + "kind": "hooked_session", + "command": "claude --print --output-format text --settings --setting-sources user --permission-mode bypassPermissions --dangerously-skip-permissions 'Reply with exactly OK and do not use any tools.'", + "result": "no model response or tool event after more than 120 seconds; interrupted with SIGINT" + }, + { + "kind": "control_session_without_hooks", + "command": "claude --print --no-session-persistence --settings '{\"hooks\":{}}' --setting-sources user --permission-mode auto --max-budget-usd 0.01 'Reply with exactly OK.'", + "result": "no model response after 30 seconds; interrupted with SIGINT" + }, + { + "kind": "bare_control", + "command": "claude --print --bare --no-session-persistence ...", + "result": "exited immediately with 'Not logged in'; bare mode only accepts API-key authentication in this environment" + } + ], + "probe_cases": "The 14 T01 cases were not run in the current-version session because no model turn completed, so no tool or lifecycle payload could be produced. Each case is therefore recorded as not capturable for this run rather than inferred from absent events.", + "prior_evidence": { + "version": "2.1.258", + "fixture": "probe14-run-in-background-true.post_tool_use.json", + "observed": "PostToolUse acknowledged a background Bash after 8ms and carried backgroundTaskId bm2b4006o", + "status": "prior-version evidence only; not sufficient to satisfy the current-version T01 gate" + }, + "decision_gate": "stop_and_revise_toward_sce_owned_process_supervision", + "reason": "The installed version did not produce the terminal lifecycle evidence required to establish G1-G4 and a proven G5 boundary. The existing background denial must remain in place." +} diff --git a/cli/src/services/hooks/claude_mutation_scope/lifecycle.rs b/cli/src/services/hooks/claude_mutation_scope/lifecycle.rs index 1bd0be753..8b620a7e6 100644 --- a/cli/src/services/hooks/claude_mutation_scope/lifecycle.rs +++ b/cli/src/services/hooks/claude_mutation_scope/lifecycle.rs @@ -6,6 +6,7 @@ use crate::services::hooks; use crate::services::mutation_trace::runtime::resolve_git_dir; use crate::services::observability::traits::Logger; +use super::events::BASH_TOOL_NAME; use super::state; use super::{ abandon_payload, classify_tool, claude_scope_close_event_id, claude_scope_start_event_id, @@ -245,6 +246,10 @@ pub(super) fn handle_pre_tool_use( return String::new(); } + if is_untracked_background_bash(execution) { + return String::new(); + } + if is_explicit_background_shell(&identity.tool_name, execution.run_in_background) { return pre_tool_use_deny_json(EXPLICIT_BACKGROUND_SHELL_DENY_REASON); } @@ -281,6 +286,10 @@ pub(super) fn handle_pre_tool_use( } } +fn is_untracked_background_bash(execution: &ClaudeToolExecution) -> bool { + execution.run_in_background && execution.identity.tool_name == BASH_TOOL_NAME +} + pub(super) enum BarrierOutcome { Proceed, Deny, diff --git a/cli/src/services/hooks/claude_mutation_scope/tests.rs b/cli/src/services/hooks/claude_mutation_scope/tests.rs index 2e7e3dcbf..4c244d28a 100644 --- a/cli/src/services/hooks/claude_mutation_scope/tests.rs +++ b/cli/src/services/hooks/claude_mutation_scope/tests.rs @@ -963,34 +963,147 @@ mod driver { } } - #[test] - fn explicit_background_bash_is_denied_with_the_exact_reason_d20() { - let git_dir = unique_test_git_dir("explicit-background-bash"); - let resolver = fixed_resolver(git_dir.clone()); - let payload = pre_tool_use_json(&[ + fn background_bash_pre_tool_use_json(tool_use_id: &str) -> String { + pre_tool_use_json(&[ (TOOL_NAME_FIELD, Value::String("Bash".to_string())), + (TOOL_USE_ID_FIELD, Value::String(tool_use_id.to_string())), ( TOOL_INPUT_FIELD, serde_json::json!({ "run_in_background": true }), ), - ]); + ]) + } + + fn background_bash_terminal_json(event_name: &str, tool_use_id: &str) -> String { + pre_tool_use_json(&[ + (HOOK_EVENT_NAME_FIELD, Value::String(event_name.to_string())), + (TOOL_NAME_FIELD, Value::String("Bash".to_string())), + (TOOL_USE_ID_FIELD, Value::String(tool_use_id.to_string())), + ( + TOOL_INPUT_FIELD, + serde_json::json!({ "run_in_background": true }), + ), + ]) + } + + #[test] + fn explicit_background_bash_is_allowed_untracked_before_any_repository_or_seam_access() { + let resolver = |_: &str| -> Result { + panic!("untracked background Bash must never resolve a git dir") + }; + let model_state_resolver = |_: &Path, _: &str, _: &str| -> Result> { + panic!("untracked background Bash must never resolve model state") + }; + + let output = start_with_model_resolver( + &background_bash_pre_tool_use_json("toolu_background"), + None, + &resolver, + &model_state_resolver, + &unreachable_seam, + ) + .expect("background Bash should be allowed"); + + assert_eq!(output, ""); + } + + #[test] + fn explicit_background_bash_is_allowed_while_the_recovery_barrier_is_armed() { + let git_dir = unique_test_git_dir("background-bash-recovery-barrier"); + let resolver = fixed_resolver(git_dir.clone()); + state::mark_recovery_pending(&git_dir).expect("recovery should arm"); + let state_before = adapter_state_file_bytes(&git_dir); let output = run_claude_mutation_scope_from_payload_with( - &payload, + &background_bash_pre_tool_use_json("toolu_background"), None, &resolver, &unreachable_seam, ) - .expect("an explicit background shell should still return Ok with a deny payload"); + .expect("background Bash should be allowed"); + assert_eq!(output, ""); assert_eq!( - output, - pre_tool_use_deny_json(EXPLICIT_BACKGROUND_SHELL_DENY_REASON) - ); - assert!( - !git_dir.exists(), - "D20: denial must precede any adapter-state I/O" + adapter_state_file_bytes(&git_dir), + state_before, + "untracked background Bash must not read through or clear the recovery barrier" ); + + remove_test_git_dir(&git_dir); + } + + fn adapter_state_file_bytes(git_dir: &Path) -> Vec { + std::fs::read(state::state_path(git_dir)).expect("adapter state file should be readable") + } + + #[test] + fn terminal_hooks_for_untracked_background_bash_are_no_ops_with_no_live_attempt() { + for event_name in [HOOK_EVENT_POST_TOOL_USE, HOOK_EVENT_POST_TOOL_USE_FAILURE] { + let git_dir = unique_test_git_dir("background-bash-terminal-no-attempt"); + let resolver = fixed_resolver(git_dir.clone()); + + let output = run_claude_mutation_scope_from_payload_with( + &background_bash_terminal_json(event_name, "toolu_background"), + None, + &resolver, + &unreachable_seam, + ) + .expect("a terminal hook for untracked background Bash must be a safe no-op"); + + assert_eq!(output, ""); + assert!( + !state::state_path(&git_dir).exists(), + "{event_name} for untracked background Bash must not create adapter state" + ); + + remove_test_git_dir(&git_dir); + } + } + + #[test] + fn terminal_hooks_for_untracked_background_bash_leave_an_unrelated_live_attempt_untouched() { + for event_name in [HOOK_EVENT_POST_TOOL_USE, HOOK_EVENT_POST_TOOL_USE_FAILURE] { + let git_dir = unique_test_git_dir("background-bash-terminal-unrelated-attempt"); + let resolver = fixed_resolver(git_dir.clone()); + + run_claude_mutation_scope_from_payload_with( + &pre_tool_use_json(&[]), + None, + &resolver, + &ok_seam, + ) + .expect("the unrelated foreground PreToolUse should establish an attempt"); + run_claude_mutation_scope_from_payload_with( + &background_bash_pre_tool_use_json("toolu_background"), + None, + &resolver, + &unreachable_seam, + ) + .expect("background Bash should be allowed"); + + let state_before = state::read_state(&git_dir).expect("state should be readable"); + assert_eq!(state_before.attempts.len(), 1); + assert_eq!(state_before.attempts[0].tool_use_id, "toolu_1"); + let state_bytes_before = adapter_state_file_bytes(&git_dir); + + let output = run_claude_mutation_scope_from_payload_with( + &background_bash_terminal_json(event_name, "toolu_background"), + None, + &resolver, + &unreachable_seam, + ) + .expect("a terminal hook for untracked background Bash must be a safe no-op"); + + assert_eq!(output, ""); + assert_eq!( + state::read_state(&git_dir).expect("state should be readable"), + state_before, + "{event_name} for untracked background Bash must not transition another attempt" + ); + assert_eq!(adapter_state_file_bytes(&git_dir), state_bytes_before); + + remove_test_git_dir(&git_dir); + } } #[test] @@ -1017,6 +1130,252 @@ mod driver { output, pre_tool_use_deny_json(EXPLICIT_BACKGROUND_SHELL_DENY_REASON) ); + assert!( + !git_dir.exists(), + "the PowerShell denial must precede any adapter-state I/O" + ); + } + + const PROBE17_SELF_DETACHING_FOREGROUND_PRE_TOOL_USE: &str = + include_str!("fixtures/probe17-detached-child-after-post-tool-use.pre_tool_use.json"); + const PROBE17_SELF_DETACHING_FOREGROUND_POST_TOOL_USE: &str = + include_str!("fixtures/probe17-detached-child-after-post-tool-use.post_tool_use.json"); + + fn recorded_operations(payloads: &RefCell>) -> Vec { + payloads + .borrow() + .iter() + .map(|payload| { + let value: Value = serde_json::from_str(payload).expect("seam payload is JSON"); + value["operation"] + .as_str() + .expect("seam payload carries an operation") + .to_string() + }) + .collect() + } + + fn tool_payload( + event_name: &str, + tool_name: &str, + tool_use_id: &str, + tool_input: Option<&Value>, + ) -> String { + let mut overrides = vec![ + (HOOK_EVENT_NAME_FIELD, Value::String(event_name.to_string())), + (TOOL_NAME_FIELD, Value::String(tool_name.to_string())), + (TOOL_USE_ID_FIELD, Value::String(tool_use_id.to_string())), + ]; + if let Some(tool_input) = tool_input { + overrides.push((TOOL_INPUT_FIELD, tool_input.clone())); + } + pre_tool_use_json(&overrides) + } + + fn assert_tracked_foreground_lifecycle( + label: &str, + tool_name: &str, + tool_input: Option<&Value>, + terminal_event: &str, + ) { + let git_dir = unique_test_git_dir(label); + let resolver = fixed_resolver(git_dir.clone()); + let payloads: RefCell> = RefCell::new(Vec::new()); + let seam = |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { + payloads.borrow_mut().push(payload.to_string()); + Ok(String::new()) + }; + let case = format!("{tool_name} with tool_input {tool_input:?} then {terminal_event}"); + + let output = run_claude_mutation_scope_from_payload_with( + &tool_payload( + HOOK_EVENT_PRE_TOOL_USE, + tool_name, + "toolu_foreground", + tool_input, + ), + None, + &resolver, + &seam, + ) + .expect("foreground PreToolUse should succeed"); + + assert_eq!(output, "", "{case}"); + assert_eq!(recorded_operations(&payloads), vec!["start"], "{case}"); + let started = state::read_state(&git_dir).expect("state should be readable"); + assert_eq!(started.attempts.len(), 1, "{case}"); + assert_eq!(started.attempts[0].tool_use_id, "toolu_foreground"); + assert_eq!(started.attempts[0].tool_name, tool_name); + assert_eq!( + started.attempts[0].phase, + state::AttemptPhase::Active, + "{case}" + ); + assert!(!started.recovery_pending, "{case}"); + + let output = run_claude_mutation_scope_from_payload_with( + &tool_payload(terminal_event, tool_name, "toolu_foreground", tool_input), + None, + &resolver, + &seam, + ) + .expect("the foreground terminal hook should close the scope"); + + assert_eq!(output, "", "{case}"); + assert_eq!( + recorded_operations(&payloads), + vec!["start", "close"], + "{case}" + ); + let closed = state::read_state(&git_dir).expect("state should be readable"); + assert!(closed.attempts.is_empty(), "{case}"); + assert!(!closed.recovery_pending, "{case}"); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn foreground_bash_with_run_in_background_false_or_absent_keeps_the_tracked_lifecycle() { + let tool_inputs = [ + None, + Some(serde_json::json!({ "command": "true" })), + Some(serde_json::json!({ "command": "true", "run_in_background": false })), + Some(serde_json::json!({ "command": "true", "run_in_background": null })), + ]; + + for tool_input in &tool_inputs { + for terminal_event in [HOOK_EVENT_POST_TOOL_USE, HOOK_EVENT_POST_TOOL_USE_FAILURE] { + assert_tracked_foreground_lifecycle( + "foreground-bash-tracked", + "Bash", + tool_input.as_ref(), + terminal_event, + ); + } + } + } + + #[test] + fn foreground_powershell_keeps_the_tracked_lifecycle() { + let tool_input = serde_json::json!({ "run_in_background": false }); + + for terminal_event in [HOOK_EVENT_POST_TOOL_USE, HOOK_EVENT_POST_TOOL_USE_FAILURE] { + assert_tracked_foreground_lifecycle( + "foreground-powershell-tracked", + "PowerShell", + Some(&tool_input), + terminal_event, + ); + } + } + + #[test] + fn non_shell_mutation_capable_tools_stay_tracked_even_with_run_in_background_true() { + let tool_input = serde_json::json!({ "run_in_background": true }); + + for tool_name in [ + "Write", + "Edit", + "NotebookEdit", + "MultiEdit", + "mcp__claude-in-chrome__navigate", + "SomeBrandNewTool", + ] { + for terminal_event in [HOOK_EVENT_POST_TOOL_USE, HOOK_EVENT_POST_TOOL_USE_FAILURE] { + assert_tracked_foreground_lifecycle( + "non-shell-run-in-background-tracked", + tool_name, + Some(&tool_input), + terminal_event, + ); + } + } + } + + #[test] + fn read_only_and_delegation_tools_with_run_in_background_true_create_no_scope() { + let resolver = |_: &str| -> Result { + panic!("a read-only or delegation tool must never resolve a git dir") + }; + let model_state_resolver = |_: &Path, _: &str, _: &str| -> Result> { + panic!("a read-only or delegation tool must never resolve model state") + }; + let tool_input = serde_json::json!({ "run_in_background": true }); + + for tool_name in [ + "Read", + "Glob", + "Grep", + "WebFetch", + "WebSearch", + "AskUserQuestion", + "Agent", + ] { + let output = start_with_model_resolver( + &tool_payload( + HOOK_EVENT_PRE_TOOL_USE, + tool_name, + "toolu_untracked", + Some(&tool_input), + ), + None, + &resolver, + &model_state_resolver, + &unreachable_seam, + ) + .expect("read-only and delegation PreToolUse should succeed"); + + assert_eq!(output, "", "{tool_name}"); + } + } + + #[test] + fn self_detaching_foreground_bash_fixture_keeps_the_ordinary_tracked_path() { + let execution = parsed_pre_tool_use(PROBE17_SELF_DETACHING_FOREGROUND_PRE_TOOL_USE); + assert_eq!(execution.identity.tool_name, "Bash"); + assert!(!execution.run_in_background); + + let git_dir = unique_test_git_dir("probe17-self-detaching-foreground"); + let resolver = fixed_resolver(git_dir.clone()); + let payloads: RefCell> = RefCell::new(Vec::new()); + let seam = |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { + payloads.borrow_mut().push(payload.to_string()); + Ok(String::new()) + }; + + let output = run_claude_mutation_scope_from_payload_with( + PROBE17_SELF_DETACHING_FOREGROUND_PRE_TOOL_USE, + None, + &resolver, + &seam, + ) + .expect("the self-detaching foreground PreToolUse should succeed"); + + assert_eq!(output, ""); + assert_eq!(recorded_operations(&payloads), vec!["start"]); + let started = state::read_state(&git_dir).expect("state should be readable"); + assert_eq!(started.attempts.len(), 1); + assert_eq!( + started.attempts[0].tool_use_id, + execution.identity.tool_use_id + ); + assert_eq!(started.attempts[0].phase, state::AttemptPhase::Active); + + let output = run_claude_mutation_scope_from_payload_with( + PROBE17_SELF_DETACHING_FOREGROUND_POST_TOOL_USE, + None, + &resolver, + &seam, + ) + .expect("the self-detaching foreground PostToolUse should close the scope"); + + assert_eq!(output, ""); + assert_eq!(recorded_operations(&payloads), vec!["start", "close"]); + let closed = state::read_state(&git_dir).expect("state should be readable"); + assert!(closed.attempts.is_empty()); + assert!(!closed.recovery_pending); + + remove_test_git_dir(&git_dir); } #[test] @@ -3240,31 +3599,167 @@ mod production_regressions { } #[test] - fn test15_explicit_background_bash_is_denied_with_no_scope() { + fn test15_explicit_background_bash_is_allowed_untracked_with_no_scope_or_attempt() { let repo = ClaudeRepo::new("test15-explicit-background-bash"); let cwd = repo.cwd(); + let state_path = state::state_path(&repo.git_dir()); let output = repo .drive(&background_pre_tool_use_for( &cwd, "session-1", - "toolu_1", + "toolu_background", None, )) - .expect("an explicit background shell must still return Ok with a deny payload"); + .expect("background Bash should be allowed"); - assert_eq!( - output, - pre_tool_use_deny_json(EXPLICIT_BACKGROUND_SHELL_DENY_REASON) - ); + assert_eq!(output, ""); assert!( - repo.adapter_state().attempts.is_empty(), - "AC21: an explicit background shell must create no scope" + !state_path.exists(), + "untracked background Bash must create no adapter state" ); + for (terminal_event, payload) in [ + ( + HOOK_EVENT_POST_TOOL_USE, + post_tool_use_for(&cwd, "session-1", "Bash", "toolu_background", None), + ), + ( + HOOK_EVENT_POST_TOOL_USE_FAILURE, + post_tool_use_failure_for(&cwd, "session-1", "Bash", "toolu_background", None), + ), + ] { + assert_eq!( + repo.drive(&payload) + .expect("a terminal hook for untracked background Bash must be a no-op"), + "" + ); + assert!( + !state_path.exists(), + "{terminal_event} for untracked background Bash must create no adapter state" + ); + } + let db = repo.db(); assert_eq!(count(&db, "mutation_trace_scopes"), 0); assert_eq!(count(&db, "mutation_trace_events"), 0); + assert!(processed_events(&db).is_empty()); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test19_terminal_hooks_for_untracked_background_bash_do_not_transition_a_live_foreground_attempt( + ) { + let repo = ClaudeRepo::new("test19-background-terminal-unrelated"); + let cwd = repo.cwd(); + + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_foreground", + None, + )) + .expect("the foreground PreToolUse should succeed"); + let scope_id = repo.adapter_state().attempts[0].scope_id.clone(); + + assert_eq!( + repo.drive(&background_pre_tool_use_for( + &cwd, + "session-1", + "toolu_background", + None, + )) + .expect("background Bash should be allowed"), + "" + ); + + let state_before = repo.adapter_state(); + let scope_before = scope_status(&repo.db(), &scope_id); + let processed_before = processed_events(&repo.db()); + + for payload in [ + post_tool_use_for(&cwd, "session-1", "Bash", "toolu_background", None), + post_tool_use_failure_for(&cwd, "session-1", "Bash", "toolu_background", None), + ] { + assert_eq!( + repo.drive(&payload) + .expect("a terminal hook for untracked background Bash must be a no-op"), + "" + ); + assert_eq!(repo.adapter_state(), state_before); + } + + let db = repo.db(); + assert_eq!(count(&db, "mutation_trace_scopes"), 1); + assert_eq!(scope_status(&db, &scope_id), scope_before); + assert_eq!(processed_events(&db), processed_before); + assert_eq!(count(&db, "mutation_trace_events"), 0); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test20_untracked_background_bash_mutation_is_observed_at_foreground_close_with_no_attribution_guarantee( + ) { + let repo = ClaudeRepo::new("test20-background-a-foreground-b"); + let cwd = repo.cwd(); + + assert_eq!( + repo.drive(&background_pre_tool_use_for( + &cwd, + "session-1", + "toolu_background_a", + None, + )) + .expect("background A should be allowed"), + "" + ); + assert!(!state::state_path(&repo.git_dir()).exists()); + + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_foreground_b", + None, + )) + .expect("foreground B PreToolUse should succeed"); + let state_with_b = repo.adapter_state(); + assert_eq!(state_with_b.attempts.len(), 1); + assert_eq!(state_with_b.attempts[0].tool_use_id, "toolu_foreground_b"); + let scope_b = state_with_b.attempts[0].scope_id.clone(); + + fs::write(repo.root.join("background-a.txt"), "written by A\n") + .expect("untracked background A's write should write"); + + repo.drive(&post_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_foreground_b", + None, + )) + .expect("foreground B PostToolUse should succeed"); + + let db = repo.db(); + let worktree_id = repo.worktree_id(); + assert_eq!(count(&db, "mutation_trace_scopes"), 1); + assert_eq!( + mutation_events_for(&db, &worktree_id), + vec![( + "ai_exclusive".to_string(), + Some(scope_b), + "close".to_string(), + )], + "no attribution guarantee: untracked background A's mutation is observed at \ + tracked foreground B's close boundary and attributed to B" + ); + assert_eq!( + worktree_row(&db, &worktree_id).map(|(_, cursor_tree, _)| cursor_tree), + Some(repo.working_tree()) + ); assert_raw_agent_trace_tables_untouched(&db); } diff --git a/context/cli/claude-mutation-scope-background-execution.md b/context/cli/claude-mutation-scope-background-execution.md index c936a5991..691dd4323 100644 --- a/context/cli/claude-mutation-scope-background-execution.md +++ b/context/cli/claude-mutation-scope-background-execution.md @@ -4,20 +4,91 @@ Detail split out of [claude-mutation-scope-integration.md](claude-mutation-scope-integration.md) for the repository's per-file line budget. -## Background shell is unsupported +## Background shell execution -An explicit `Bash.run_in_background = true` / `PowerShell.run_in_background = -true` is denied in `PreToolUse` (fail-closed shape) with: +Native Claude background Bash is operationally supported but unsupported for +mutation attribution. The effective decision is +[Claude background Bash runs natively and untracked](../decisions/2026-10-02-claude-background-bash-untracked.md). + +### Background Bash: allowed natively, untracked + +`Bash` with `tool_input.run_in_background = true` returns the ordinary empty +`PreToolUse` allow output. `handle_pre_tool_use` takes that return before +repository resolution, the recovery barrier, model-state resolution, and +`establish_start`, so the call: + +- creates no SCE mutation scope and calls no generic mutation-scope `start`; +- creates no Claude adapter attempt and performs no adapter-state I/O; +- receives no lifecycle tracking; +- is admitted even while `recovery_pending` is armed, because it never reaches + the barrier; +- may mutate the repository in ways that contaminate the attribution of later + or concurrent tracked scopes. + +A later `PostToolUse` or `PostToolUseFailure` for that tool finds no matching +attempt and is a no-op through the existing missing-attempt branch of +`handle_close`. It does not close, abandon, or otherwise transition an +unrelated live attempt. + +Claude lifecycle completion remains unproven, so the adapter deliberately +avoids lifecycle tracking rather than attempting to recover it. SCE makes no +mutation-attribution guarantee for mutations produced by a native background +Bash process. This is a compatibility tradeoff, not an attribution-safety or +fail-closed feature. + +The accepted consequence, stated as a tradeoff outside SCE's guarantees rather +than a bug: + +```text +background A starts -> untracked +foreground B starts -> tracked +A mutates the repository while B is live +B closes +SCE observes A's mutation at B's boundary and attributes it to B +``` + +Under the existing protocol that sequence produces one `ai_exclusive` `close` +event attributed to B's scope, with the cursor advanced to the tree containing +A's write. No test or protocol machinery distinguishes A from B. + +### Background PowerShell: denied + +`PowerShell` with `run_in_background = true` is denied in `PreToolUse` +(fail-closed shape), before any repository or adapter-state access, with: ```text SCE mutation attribution does not yet support detached background shell execution. Run this command in the foreground. ``` -A detached shell can keep mutating the repository after `PostToolUse` returns and -can outlive a session; the generic contract has no process supervisor or stable -background-execution terminal signal. This is a deliberate correctness boundary, -not a Bash security policy. Background **subagents** are not excluded — their -internal mutation-capable tool calls still establish their own scopes. +Background **subagents** are not excluded — their internal foreground +mutation-capable tool calls still establish their own scopes. + +## T01 current-version probe result + +On 2026-10-01, the installed Claude Code version was `2.1.284`. A T01 probe +using an isolated temporary settings file and an additive capture hook reached +`SessionStart`, but the non-interactive session produced no model response or +tool/lifecycle event after more than 120 seconds. A no-hook control behaved the +same way. The result is recorded in +`cli/src/services/hooks/claude_mutation_scope/fixtures/probe18-t01-current-version.evidence.json` +and the detailed G1-G7 table is in `fixtures/NOTES.md`. + +The existing `probe14-*` and `probe17-*` evidence was captured on Claude Code +`2.1.258`, so it is retained as prior-version evidence and does not establish +the current-version terminal contract. G1-G7 therefore remain unknown for +`2.1.284`. At the time, the T01 decision gate stopped there and kept the +denial in force until a later probe could establish a reliable completion +signal and a proven no-more-mutation boundary, with SCE-owned process +supervision as the fallback direction rather than admission based on the +acknowledgement event alone. That constraint is recorded in the +[Claude background admission decision](../decisions/2026-10-01-claude-background-admission-requires-proven-lifecycle.md). + +The T01 technical conclusion stays true: G1-G7 are unproven, so +attribution-safe background tracking cannot be built from the observed Claude +lifecycle. The admission policy it carried is superseded by +[Claude background Bash runs natively and untracked](../decisions/2026-10-02-claude-background-bash-untracked.md), +which accepts untracked execution anyway and is what the adapter implements. +The 2026-10-01 record is retained unmodified as history. **Self-detaching descendants are a separate, explicit unsupported boundary (D20).** A `run_in_background = false` call can still leave a repository-mutating diff --git a/context/cli/claude-mutation-scope-integration.md b/context/cli/claude-mutation-scope-integration.md index 1c42f1258..cd37b8c3e 100644 --- a/context/cli/claude-mutation-scope-integration.md +++ b/context/cli/claude-mutation-scope-integration.md @@ -35,7 +35,8 @@ below. executions. Two parallel mutation-capable tools produce two simultaneously live scopes and may correctly yield `AiContended`; sequential tool calls are sequential scopes. `SessionStart`, `UserPromptSubmit`, and `SubagentStart` -establish no scope. +establish no scope, and neither does native background `Bash` (see +[Background shell execution](#background-shell-execution)). `classify_tool(tool_name)`: @@ -52,8 +53,9 @@ establish no scope. `is_explicit_background_shell(tool_name, run_in_background)` is a separate model-only predicate (`true` only for `Bash`/`PowerShell` with -`run_in_background == true`); its denial is in the driver — see -[Background shell is unsupported](#background-shell-is-unsupported). +`run_in_background == true`). The driver allows background `Bash` untracked +before consulting it, so its denial applies only to background `PowerShell` — +see [Background shell execution](#background-shell-execution). ## Identity and ScopeId / EventId derivation @@ -108,7 +110,7 @@ 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 -> +For a mutation-capable tool, `handle_pre_tool_use` runs: background-`Bash` untracked allow (empty success, nothing below runs) -> explicit-background-shell deny (`PowerShell`) -> 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 @@ -131,8 +133,8 @@ 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. 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. +returns `allow`, so SCE cannot bypass Claude's permission system. A read-only, +`Agent`, or background-`Bash` `PreToolUse` returns empty stdout, no scope. ## PostToolUse / PostToolUseFailure: close the scope @@ -210,9 +212,9 @@ checkout B's state and only that worktree's cursor. The adapter never accepts, derives, stores, or constructs a `WorktreeId`, and passes no `worktree_id` key to the seam. -## Background shell is unsupported +## Background shell execution -An explicit `Bash.run_in_background = true` / `PowerShell.run_in_background = true` is denied in `PreToolUse` (fail-closed shape); self-detaching descendants are a separate, explicit unsupported boundary (D20). See [Claude mutation-scope background and detached execution boundaries](claude-mutation-scope-background-execution.md) for the full denial text and both boundaries. +Native Claude background Bash is operationally supported but unsupported for mutation attribution: `Bash(run_in_background=true)` is allowed in `PreToolUse` with no mutation scope, no adapter attempt, no lifecycle tracking, and no attribution guarantee, per [Claude background Bash runs natively and untracked](../decisions/2026-10-02-claude-background-bash-untracked.md); its later `PostToolUse` / `PostToolUseFailure` finds no attempt and is a no-op. An explicit `PowerShell.run_in_background = true` is denied in `PreToolUse` (fail-closed shape); self-detaching descendants are a separate, explicit unsupported boundary (D20). See [Claude mutation-scope background and detached execution boundaries](claude-mutation-scope-background-execution.md) for the accepted A/B attribution consequence, the PowerShell denial text, and both boundaries. ## Generated settings diff --git a/context/context-map.md b/context/context-map.md index 4287d2f3f..a31f79fff 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -34,8 +34,8 @@ Feature/domain context: - `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 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) and an OpenCode adapter driver (full lifecycle + recovery, now generated as the final `sce-mutation-scope.ts` plugin and registered by `sce setup`, so reachable by a real session) — a Pi adapter driver now also exists (`cli/src/services/hooks/pi_mutation_scope/`, hidden `sce hooks pi-mutation-scope`) and is driven by the canonical generated Pi extension in ordinary Pi sessions; its human `user_bash` path uses the separate guarded external-mutation supervisor) - `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; an OpenCode adapter driver (`cli/src/services/hooks/opencode_mutation_scope/`, hidden `sce hooks opencode-mutation-scope`) also consumes it with a full `Start`/`Close`/`Abandon` lifecycle and checkout-local recovery state, and is now driven in production by the generated `sce-mutation-scope.ts` plugin installed last by `sce setup`; a Pi adapter driver (`cli/src/services/hooks/pi_mutation_scope/`, hidden `sce hooks pi-mutation-scope`) also consumes it now, with `(session-id, tool-call-id)`→`ScopeId` derivation and getppid()/`/proc`-start-time-based stale-process recovery; the canonical generated Pi extension drives it in ordinary sessions — see `context/cli/pi-mutation-scope-integration.md`) -- `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 — full detail split out to [`claude-mutation-scope-background-execution.md`](cli/claude-mutation-scope-background-execution.md) for the repository's per-file line budget); 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`; real Git/Agent Trace persistence coverage shared with the Codex path; and, added by the `doctor-mutation-scope-fix` plan's T04, a third `pending_abandon` attempt phase durably persisted before any abandon seam call — established evidence that abandonment has already been decided, distinct from "may still be running" — with every attempt a broad cleanup signal (`Stop`/`UserPromptSubmit`/`SubagentStop`/`SessionEnd`/`WorktreeRemove`) matches now marked `pending_abandon` in one write before any of their seam calls run, so one attempt's seam failure can never leave a batch sibling without its own retryable evidence; health classification detail split out to [`claude-mutation-scope-health.md`](cli/claude-mutation-scope-health.md)) -- `context/cli/claude-mutation-scope-background-execution.md` (the `run_in_background = true` `PreToolUse` denial and the separate self-detaching-descendant unsupported boundary D20 — Git-observable evidence that a foreground `setsid` command's detached descendant can still mutate the repository after `PostToolUse` returns — detail split out of `claude-mutation-scope-integration.md` for the repository's per-file line budget) +- `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`; `run_in_background = true` handling — native Claude background Bash is operationally supported but unsupported for mutation attribution (allowed in `PreToolUse` before repository resolution with no mutation scope, adapter attempt, or lifecycle tracking, per the effective decision `context/decisions/2026-10-02-claude-background-bash-untracked.md`), while background `PowerShell` stays denied — and the separate self-detaching-descendant unsupported boundary (D20, with T04's Git-observable evidence — full detail split out to [`claude-mutation-scope-background-execution.md`](cli/claude-mutation-scope-background-execution.md) for the repository's per-file line budget); 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`; real Git/Agent Trace persistence coverage shared with the Codex path; and, added by the `doctor-mutation-scope-fix` plan's T04, a third `pending_abandon` attempt phase durably persisted before any abandon seam call — established evidence that abandonment has already been decided, distinct from "may still be running" — with every attempt a broad cleanup signal (`Stop`/`UserPromptSubmit`/`SubagentStop`/`SessionEnd`/`WorktreeRemove`) matches now marked `pending_abandon` in one write before any of their seam calls run, so one attempt's seam failure can never leave a batch sibling without its own retryable evidence; health classification detail split out to [`claude-mutation-scope-health.md`](cli/claude-mutation-scope-health.md)) +- `context/cli/claude-mutation-scope-background-execution.md` (native Claude background Bash is operationally supported but unsupported for mutation attribution: `Bash(run_in_background=true)` is allowed in `PreToolUse` with no mutation scope, adapter attempt, lifecycle tracking, or attribution guarantee — effective record `context/decisions/2026-10-02-claude-background-bash-untracked.md` — its later terminal hooks are no-ops, and the accepted A/B contamination consequence is pinned by a regression; background `PowerShell` stays denied with the existing deny text; the T01 `2.1.284` probe result with lifecycle completion still unproven; and the separate self-detaching-descendant unsupported boundary D20 — Git-observable evidence that a foreground `setsid` command's detached descendant can still mutate the repository after `PostToolUse` returns — detail split out of `claude-mutation-scope-integration.md` for the repository's per-file line budget) - `context/cli/claude-mutation-scope-health.md` (the `doctor-mutation-scope-health` plan's Claude task, extended by the `doctor-mutation-scope-fix` plan's T04: `classify_health` in `cli/src/services/hooks/claude_mutation_scope/health.rs` maps `recovery_pending`/`attempts` onto `healthy | recovering | blocked | invalid` — `recovery_pending == false` is `healthy` regardless of live attempts; `recovery_pending == true` with `attempts` empty is `recovering` (the barrier's own next-`PreToolUse` flush clears it); `recovery_pending == true` with `attempts` non-empty is `blocked`, the exact incident shape this classifier exists to surface, since nothing in the ordinary hook lifecycle retries, removes, flushes, or clears from that shape; a read/parse failure is `invalid`. T04 adds a separate, `doctor`-invoked repair path (`assess_repairability`/`repair_blocked` in `health.rs`/`lifecycle.rs`, wired into `sce doctor --fix` by the same plan's T05) for the `blocked` shape: `AutoFixable` only when every currently persisted attempt is already `pending_abandon` (no established-abandon-intent `pending_start`/`active` attempt present); `repair_blocked` re-reads and re-proves that same condition inside one lock-protected read-only state transaction before releasing the lock and retrying each attempt's already-established seam `abandon` call independently, clearing the `recovery_pending` barrier only once no attempts remain — this classifier's own read-only behavior is unaffected by that repair path's existence) - `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) @@ -134,6 +134,7 @@ Supporting repo docs: Recent decision records: +- `context/decisions/2026-10-02-claude-background-bash-untracked.md` (effective Claude background-admission decision; supersedes `context/decisions/2026-10-01-claude-background-admission-requires-proven-lifecycle.md`, which is retained unmodified as history: permits Claude-managed `Bash(run_in_background=true)` natively with no mutation scope, adapter attempt, lifecycle tracking, or mutation-attribution guarantee, accepting that an untracked background process may contaminate a concurrent tracked scope's attribution; lifecycle completion remains unproven, background `PowerShell` stays denied, and the Claude adapter implements the decision) - `context/decisions/2026-09-07-split-auto-sync-defaults.md` (newly bootstrapped repo-local config explicitly opts into Agent Trace auto-sync while omitted runtime values remain disabled) - `context/decisions/2026-09-10-claude-bridge-seeding-on-diff-trace.md` (moves bridge-derived Claude model-state seeding from the model-less `SessionStart` path — structurally unable to read its own lazily created transcript — to the diff-trace state miss, amends attribution precedence to `direct > exact transcript > exact state > bridge-derived chain state > NULL`, accepts one exact-scope state write on that resolution path, and selects the newest chain observation by `observed_at_ms` rather than the chain origin or the newest sibling transcript; supersedes the trigger point and selection rule of the 2026-09-08 decision) - `context/decisions/2026-09-08-claude-bridge-session-model-inheritance.md` (accepts bounded, local-only inheritance of Claude model state across bridge-linked model-less `SessionStart` events, with fail-open behavior and no generic or exported session cache) diff --git a/context/decisions/2026-10-01-claude-background-admission-requires-proven-lifecycle.md b/context/decisions/2026-10-01-claude-background-admission-requires-proven-lifecycle.md new file mode 100644 index 000000000..bad4599a4 --- /dev/null +++ b/context/decisions/2026-10-01-claude-background-admission-requires-proven-lifecycle.md @@ -0,0 +1,73 @@ +# Decision: Keep Claude managed-background shell admission fail-closed until lifecycle completion is proven + +Date: 2026-10-01 +Status: Accepted +Plan: `context/plans/claude-background-shell-mutation-scope.md` +Task: `T01` + +## Context + +Claude Code `2.1.284` did not produce a model turn or any managed-background +tool lifecycle after the T01 capture session reached `SessionStart`; a no-hook +control behaved the same way. The current-version probe therefore could not +establish a terminal event, a stable completion correlation, or a boundary +proving that the process can no longer mutate. Earlier `2.1.258` evidence +showed only that `PostToolUse` acknowledges a background task quickly and +includes `backgroundTaskId`; it did not establish completion. Acknowledgement +alone is not a safe mutation boundary. + +## Decision + +Claude-managed background shell execution remains denied until SCE can prove +completion through a reliable lifecycle contract or an SCE-owned process +supervisor. + +## Rationale + +Keeping the existing denial preserves fail-closed mutation attribution when +SCE cannot prove that a detached process has stopped. Admission based only on +the acknowledgement event could leave a repository-mutating process outside a +live suppressor and create false-positive or falsely scoped mutation evidence. + +## Alternatives considered + +- **Admit on `PostToolUse` acknowledgement** — rejected because prior evidence + shows the acknowledgement can precede background completion. +- **Implement the hook-driven lifecycle immediately** — rejected because the + installed version did not establish the required G1-G5 contract. +- **Use the existing external-mutation guard unchanged** — rejected because + that supervisor is the human-mutation path and does not yet establish the + Claude-managed AI execution contract. + +## Compatibility and risks + +- Background Bash remains unavailable to Claude until a later plan revision or + probe establishes safe completion; foreground behavior is unchanged. +- The conservative boundary may produce a false negative for legitimate + background execution, which is preferred to unproven positive attribution. + +## Guardrails + +- No `run_in_background = true` admission change may land without evidence of + process termination ordering and a proven no-more-mutation boundary. +- Prior-version fixtures remain evidence only and must not be treated as the + current Claude lifecycle contract. + +## Consequences + +- The hook-driven background lifecycle plan stops before protocol admission. +- A future implementation must revise the plan toward SCE-owned supervision or + first capture the missing current-version lifecycle evidence. + +## Follow-up + +Revise the plan toward SCE-owned process supervision before admitting managed +background shells. + +## References + +- Plan: [`claude-background-shell-mutation-scope.md`](../plans/claude-background-shell-mutation-scope.md) +- Task: `T01` +- Current-state context: [`claude-mutation-scope-background-execution.md`](../cli/claude-mutation-scope-background-execution.md) +- Evidence: [`probe18-t01-current-version.evidence.json`](../../cli/src/services/hooks/claude_mutation_scope/fixtures/probe18-t01-current-version.evidence.json) +- Related decision: [`External-mutation guard as a kernel-enforced process supervisor`](2026-09-17-external-mutation-guard-process-supervisor.md) diff --git a/context/decisions/2026-10-02-claude-background-bash-untracked.md b/context/decisions/2026-10-02-claude-background-bash-untracked.md new file mode 100644 index 000000000..6d80d4ad7 --- /dev/null +++ b/context/decisions/2026-10-02-claude-background-bash-untracked.md @@ -0,0 +1,117 @@ +# Decision: Claude background Bash runs natively and untracked + +Date: 2026-10-02 +Status: Accepted +Plan: `context/plans/claude-background-shell-mutation-scope.md` +Task: `T02` +Supersedes: `context/decisions/2026-10-01-claude-background-admission-requires-proven-lifecycle.md` + +## Context + +The 2026-10-01 decision kept Claude-managed background shell execution denied +until SCE could prove completion through a reliable lifecycle contract or an +SCE-owned process supervisor. The T01 probe on Claude Code `2.1.284` could not +establish G1-G7: no terminal event, no stable completion correlation, and no +boundary proving that the background process can no longer mutate. That +technical conclusion still holds, so attribution-safe background tracking +cannot be built from the observed Claude lifecycle. + +Holding the denial until the lifecycle is proven leaves background Bash +unusable under Claude for an unbounded time. The product direction is to +restore that capability and accept that SCE does not attribute what it +produces. + +## Decision + +SCE permits Claude-managed `Bash(run_in_background=true)` natively, without +mutation-scope tracking, as an accepted unsupported mutation-attribution +boundary. Native background Bash creates no SCE mutation scope and no Claude +adapter attempt, calls no generic mutation-scope `start`, and receives no +lifecycle tracking. Claude lifecycle completion remains unproven, and SCE +deliberately makes no mutation-attribution guarantee for mutations produced by +that background process. + +## Rationale + +Lifecycle completion could not be proven, so the only ways to keep attribution +correct are to deny background Bash or to build supervision SCE does not have. +Permitting it untracked restores a native Claude capability at the cost of a +bounded, explicitly documented attribution gap. Naming the gap as outside +SCE's guarantees is more honest than a tracking design that would claim +correctness on an unproven terminal signal. + +## Alternatives considered + +- **Keep the fail-closed denial until lifecycle completion is proven** — the + superseded 2026-10-01 decision. Rejected because the lifecycle could not be + proven, so background Bash would stay unavailable under Claude indefinitely. +- **Hook-driven attribution-safe lifecycle tracking** (suppressor, safety + latch or taint, adapter state v2, background correlation) — rejected because + G1-G7 are unproven and the design cannot rest on the observed lifecycle. +- **SCE-owned process supervision** — rejected for this change as out of scope + and disproportionate to the capability being restored. + +## Compatibility and risks + +- This is a compatibility tradeoff, not an attribution-safe or fail-closed + feature. Mutations from a native background Bash process may overlap with + and contaminate the attribution of later or concurrent tracked scopes. +- Foreground mutation-capable Claude tools keep their existing lifecycle, + attribution, failure, read-only, and delegation behavior. +- Background `PowerShell` remains denied. +- Decision state and implementation state are distinct. This record + establishes the policy; the adapter continues to deny + `Bash(run_in_background=true)` until T03 of the plan implements it. + +## Guardrails + +- The change introduces no suppressor, safety latch, worktree taint, unknown + epoch, attribution filter, adapter-state version, background correlation, + new terminal hook, capability gate, process supervision, mutation-protocol + or Quint change, migration, doctor state, or recovery/reset command. +- The background Bash early return sits before repository resolution, the + recovery barrier, model-state resolution, and normal mutation-scope + establishment. +- No test or protocol machinery attempts to distinguish an untracked + background process from a tracked foreground scope. +- The self-detaching-descendant boundary (D20) and the `probe14-*` / + `probe17-*` evidence are unchanged. The T01 evidence fixture stays + unmodified as historical evidence. + +## Consequences + +- Background Bash becomes usable under Claude once T03 lands, and SCE records + nothing about it. +- The following sequence is an accepted consequence outside SCE's guarantees, + not a bug: + + ```text + background A starts -> untracked + foreground B starts -> tracked + A mutates the repository while B is live + B closes + SCE may observe A's mutation at B's boundary and may attribute it to B + ``` + +- Attribution evidence for a tracked scope that overlapped a native background + Bash process carries no guarantee that every observed mutation belongs to + that scope. +- The 2026-10-01 record stays unmodified as history; this record is the + effective background-admission decision. + +## Follow-up + +- T03 of the plan implements the admission and moves current-state context to + the final contract. + +## References + +- Plan: [`claude-background-shell-mutation-scope.md`](../plans/claude-background-shell-mutation-scope.md) +- Task: `T02` +- Current-state context: [`claude-mutation-scope-background-execution.md`](../cli/claude-mutation-scope-background-execution.md) +- Current-state context: [`claude-mutation-scope-integration.md`](../cli/claude-mutation-scope-integration.md) +- Evidence: [`probe18-t01-current-version.evidence.json`](../../cli/src/services/hooks/claude_mutation_scope/fixtures/probe18-t01-current-version.evidence.json) +- Evidence: [`NOTES.md`](../../cli/src/services/hooks/claude_mutation_scope/fixtures/NOTES.md) +- Related decision: [`Keep Claude managed-background shell admission fail-closed until lifecycle completion is proven`](2026-10-01-claude-background-admission-requires-proven-lifecycle.md) +- Related decision: [`Make synchronization decision-gate outcomes and ADR history explicit`](2026-08-12-decision-gate-semantics.md) +- Related decision: [`External-mutation guard as a kernel-enforced process supervisor`](2026-09-17-external-mutation-guard-process-supervisor.md) diff --git a/context/plans/claude-background-shell-mutation-scope.md b/context/plans/claude-background-shell-mutation-scope.md new file mode 100644 index 000000000..7a6993497 --- /dev/null +++ b/context/plans/claude-background-shell-mutation-scope.md @@ -0,0 +1,389 @@ +# Plan: claude-background-shell-mutation-scope + +## Change summary + +Replace the existing explicit-background denial for Claude `Bash` with a +deliberately minimal compatibility path. `Bash` with +`tool_input.run_in_background = true` is allowed immediately, creates no SCE +mutation scope, creates no Claude adapter attempt, calls no generic +mutation-scope `start`, and receives no lifecycle tracking. Ordinary foreground +Claude mutation-capable tools continue through the existing adapter and runtime +unchanged. `PowerShell` remains denied if its existing background predicate +remains unsupported. + +This is an explicit product tradeoff, not an attribution-safety feature. SCE +does not attribute or isolate mutations produced by native background Bash, and +those mutations may overlap with and contaminate attribution of later or +concurrent tracked scopes. T01's captured Claude Code `2.1.284` evidence stays +historical evidence: lifecycle completion was not proven, so this change +deliberately avoids lifecycle tracking rather than attempting to recover it. + +The product decision is recorded before the behavior changes. The accepted +2026-10-01 decision still requires background execution to stay denied until +lifecycle completion is proven, so the task stack first writes a new Accepted +ADR that `Supersedes:` it (T02) and only then enables native background Bash +(T03). The 2026-10-01 ADR is immutable history and is never edited, including +its `Status: Accepted` line, per +`context/decisions/2026-08-12-decision-gate-semantics.md`. + +Decision state and implementation state stay distinct in durable context, so +current-state documentation never gets ahead of the code: + +```text +after T02: + policy: background Bash permitted by current decision + implementation: adapter still denies until T03 + +after T03: + policy: permitted + implementation: permitted natively and untracked +``` + +Resulting architecture: + +```text +Claude background Bash + ↓ +allowed natively + ↓ +not represented in mutation-scope adapter + ↓ +no lifecycle tracking + ↓ +attribution correctness not guaranteed +``` + +## 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. Decision and documentation criteria (AC1-AC3) come +first because they must hold before the implementation criteria (AC4-AC9) may +be satisfied. + +- [x] AC1: A new Accepted ADR exists that explicitly `Supersedes:` + `context/decisions/2026-10-01-claude-background-admission-requires-proven-lifecycle.md` + and records native background Bash as an accepted unsupported-attribution + boundary. It states that SCE permits Claude-managed + `Bash(run_in_background=true)` natively without mutation-scope tracking, that + lifecycle completion remains unproven, and that SCE deliberately makes no + mutation-attribution guarantee for mutations produced by that background + process. It explicitly accepts attribution contamination (untracked + background A mutating while tracked foreground B is live may be observed at + B's boundary and attributed to B) as a compatibility tradeoff outside SCE's + guarantees, rather than claiming fail-closed or attribution-safe behavior. + - Validate: inspect the new `context/decisions/2026-10-02-*.md` record for + `Status: Accepted`, the `Supersedes:` line naming the 2026-10-01 ADR, the + decision statement, and the A/B consequence block. +- [x] AC2: The 2026-10-01 fail-closed ADR and the T01 evidence fixture are + byte-unchanged; the old ADR still reads `Status: Accepted` and carries no + backlink. + - Validate: for each of + `context/decisions/2026-10-01-claude-background-admission-requires-proven-lifecycle.md` + and + `cli/src/services/hooks/claude_mutation_scope/fixtures/probe18-t01-current-version.evidence.json`, + `git log --format=%H -- ` lists exactly one commit (the one that + added the file) and `git status --short -- ` is empty. +- [x] AC3: The behavior-enabling task did not land before a new Accepted ADR + existed that explicitly `Supersedes:` the 2026-10-01 fail-closed + background-admission ADR and records native background Bash as an accepted + unsupported-attribution boundary. Once the plan is complete, durable + current-state context states that native Claude background Bash is + operationally supported but unsupported for mutation attribution, points to + the new effective decision, carries no leftover pending-implementation + wording, and preserves the T01 evidence, the probe14/probe17 evidence, the + D20 self-detached descendant boundary, and the fact that Claude lifecycle + completion remains unproven. + - Validate: the plan shows T02 `(status:done)` with + `Context synchronization: synced` and a `Completed` date no later than + T03's; `git log --format=%H -- context/decisions/2026-10-02-*.md` resolves + to a commit that is an ancestor of the commit changing + `cli/src/services/hooks/claude_mutation_scope/lifecycle.rs`; inspect + `context/cli/claude-mutation-scope-background-execution.md`, + `context/cli/claude-mutation-scope-integration.md`, and the + `context/context-map.md` entries for the final contract sentence and for + the absence of any "still denies until T03" statement. +- [x] AC4: Claude `Bash` with `run_in_background=true` returns the existing + allow shape (`String::new()`), before repository resolution, the recovery + barrier, model-state resolution, and normal mutation-scope establishment; no + generic `start` is invoked and no adapter attempt is persisted. + - Validate: focused Claude adapter regressions assert empty `PreToolUse` + output, zero git-dir resolver calls, zero seam `start` calls, an unchanged + adapter-state file, and zero `mutation_trace_scopes` / + `mutation_trace_events` rows. +- [x] AC5: A later `PostToolUse` or `PostToolUseFailure` for that intentionally + untracked background Bash is harmless and does not transition another + attempt. + - Validate: focused terminal-hook regressions drive each event after an + untracked background admission, with and without an unrelated live + foreground attempt, and assert no close/abandon seam call or state change. +- [x] AC6: Background `PowerShell` remains denied. + - Validate: a focused `PowerShell` background `PreToolUse` regression asserts + the existing deny payload and no scope/attempt creation. +- [x] AC7: The accepted attribution limitation is executable: an untracked + background A can mutate while tracked foreground B is live, and SCE observes + that mutation at B's boundary under the existing protocol; no test or + protocol machinery attempts to distinguish A from B. + - Validate: a regression records the background-A/foreground-B sequence + against a real Git repository and Agent Trace DB, asserts the result the + existing protocol actually produces, and names in its test name or + assertion message that the result carries no attribution guarantee. +- [x] AC8: Foreground Claude `Bash` and other existing mutation-capable tools + retain their current lifecycle, attribution, failure, read-only, and + delegation behavior byte-for-byte or semantically unchanged; the probe17 + detached-descendant fixtures and D20 boundary are unchanged. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope` + passes with no changed expected output for foreground tests; + `git diff main -- cli/src/services/hooks/claude_mutation_scope/fixtures/` + shows no change to any `probe17-*` or `probe14-*` file. +- [x] AC9: The change introduces no background lifecycle or persistence design: + no suppressor, safety latch, worktree taint, unknown epoch, attribution + filter, adapter-state version, background correlation, new terminal hook, + capability gate, process supervision, mutation-protocol or Quint change, + migration, doctor state, or explicit recovery/reset command. + - Validate: the cumulative plan diff `git diff --name-only main` (a + plan-wide check, deliberately spanning T01-T04) shows only the Claude + adapter sources/tests under `cli/src/services/hooks/claude_mutation_scope/`, + the T01 fixture and notes, this plan, and the planned durable-context and + decision records; `spec/mutation_cursor.qnt`, `cli/src/services/mutation_trace/`, + `cli/migrations/`, doctor sources, and the adapter state format are + untouched. + +### Full validation + +Repository-wide checks `/validate` runs after the last task, regardless of +which criterion they map to. + +- `nix flake check` + +### Context sync + +- `context/cli/claude-mutation-scope-background-execution.md` +- `context/cli/claude-mutation-scope-integration.md` +- `context/context-map.md` +- A new dated decision record under `context/decisions/` carrying + `Supersedes: context/decisions/2026-10-01-claude-background-admission-requires-proven-lifecycle.md`. + The 2026-10-01 record itself is not modified. + +## 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:** the durable context and decision records listed under Context + sync; the Claude `PreToolUse` background Bash admission branch in + `cli/src/services/hooks/claude_mutation_scope/lifecycle.rs`; focused Claude + adapter and mutation-scope regressions in + `cli/src/services/hooks/claude_mutation_scope/tests.rs`. +- **Out of scope:** `ManagedBackgroundSuppressor`, + `background_safety_taint`, adapter state v2, background task correlation, + new terminal hooks, lifecycle capability detection, process supervision, + attribution filtering, worktree taint, suppressors, unknown epochs, + mutation-protocol changes, Quint/spec changes (`spec/mutation_cursor.qnt`), + Agent Trace DB migrations, doctor state, explicit resume/reset commands, and + background PowerShell admission. +- **Constraints:** T03 must not start until T02 is `(status:done)` and + `synced` with the superseding ADR written. No document may imply the adapter + admits background Bash before T03 lands: after T02, current-state context + records the decision and states that the adapter still denies + `Bash(run_in_background=true)` until T03; T03's context synchronization + replaces that with the final contract. Task-scope checks compare a task's + own start and end revisions (the `/next-task` Git baseline and its + baseline-relative `Files changed` record), never the cumulative branch + diff. No task edits + `context/decisions/2026-10-01-claude-background-admission-requires-proven-lifecycle.md` + (status, backlink, or body) or + `cli/src/services/hooks/claude_mutation_scope/fixtures/probe18-t01-current-version.evidence.json`. + Place the Bash background early return before repository resolution, + recovery-barrier handling, model-state resolution, and normal + mutation-scope establishment. Keep foreground behavior, read-only/delegation + behavior, and tracked-attempt cleanup unchanged. Use the existing missing + attempt behavior for terminal hooks unless a focused regression proves an + adapter-local no-op fix is necessary. +- **Non-goal:** making native background Bash attribution-safe or fail-closed. + Managed background Bash is an explicit unsupported attribution boundary. + The A/B contamination regression documents the accepted behavior; it does + not prevent it. + +## Assumptions + +- `Bash` is the only background tool enabled by this change; the existing + `is_explicit_background_shell` denial remains for `PowerShell`. +- The existing `handle_close` missing-attempt branch is the intended safe no-op + for terminal hooks whose `PreToolUse` was deliberately untracked. +- ADR supersession follows the repository convention in + `context/decisions/2026-08-12-decision-gate-semantics.md`: the new record + carries `Supersedes:`, the old record stays byte-unchanged with + `Status: Accepted`, and readers find the effective decision through + `context/context-map.md` and the current-state context. +- The superseding ADR is written by `sce-decision` through the decision gate + of T02's context synchronization, not hand-written during task execution; + its filename is the skill's dated slug (expected form + `2026-10-02-claude-background-bash-untracked.md`). +- `probe17` is fixture-and-documentation evidence only; no Rust test references + it today. "probe17 behavior unchanged" therefore means the fixtures and the + D20 documentation stay unchanged and a self-detaching `run_in_background = + false` command keeps the ordinary tracked foreground path. +- Repository-wide `nix flake check` and the no-prohibited-files inspection are + run by `/validate` from the acceptance criteria rather than by a task; T04 + runs only the focused Claude adapter selection. +- A task's start revision is the Git baseline `/next-task` captures before + editing (recorded `HEAD` plus pre-existing worktree state), and its end + revision is the worktree at task completion after context synchronization; + the baseline-relative `Files changed` record is the task-local change list. + No commit SHA for these revisions is written into the plan ahead of time. + +## Task stack + +- [x] T01: `Probe and record the Claude managed-background shell lifecycle` (status:done) + - Task ID: T01 + - Scope: In — on the installed Claude Code version, capture raw hook payloads + with wall-clock capture timestamps, and make the background command write + its own timestamps to a tracked, non-ignored path (including a write as its + last action and a repeating write for kill cases) so ordering is observed, + for: (1) successful background Bash; (2) non-zero exit; (3) + cancellation/kill; (4) another tool running while it remains active, + including a foreground `Edit`/`Bash` that opens and closes during it; (5) + `Stop`; (6) `StopFailure`; (7) `UserPromptSubmit`; (8) `SubagentStop`, + with a background Bash started by that subagent; (9) `SessionEnd`; (10) a + repository mutation after the initial `PostToolUse`; (11) process behavior + when Claude itself exits; (12) whether the terminal event arrives while the + session is otherwise idle; (13) a foreground command moved to the + background mid-flight, if producible; (14) whether hook registrations + edited mid-session take effect and whether a session's hooks come from one + settings snapshot. Register the capture hook for `PreToolUse`, + `PostToolUse`, `PostToolUseFailure`, `Notification`, `Stop`, `StopFailure`, + `SubagentStop`, `SessionEnd`, `UserPromptSubmit`, `PostToolBatch`, and every + other hook event the installed version exposes that could report + background-task completion. Preserve per event: timestamp, `session_id`, + `agent_id`, `tool_use_id`, `tool_name`, `tool_input`, `tool_response`, any + background/task/process identifier, and the raw payload. Store fixtures + under `fixtures/`, add a `NOTES.md` addendum answering G1–G7 with the + per-event table and the decision-gate outcome, and update + `context/cli/claude-mutation-scope-background-execution.md` with observed + behavior and the tested version. Out — any Rust change; any change to the + denial; committing the scratch hook or marker files. + - Dependencies: none + - Done when: every probe case is captured or recorded as not capturable with + its concrete reason; G4 is shown as + `last child write < process termination <= terminal event` from timestamps + the child wrote; each listed lifecycle event has a `yes`/`no`/`unknown` + answer for "process cannot still mutate after this event"; case 10 is shown + Git-observable the way `probe17` was; the decision-gate outcome (proceed, + or stop and revise toward supervision) is written in `NOTES.md`; + `.claude/settings.json` is back to its pre-task content. + - Verify: `git status --short` shows only new fixture files, `NOTES.md`, and + the context document; `git diff -- .claude/settings.json` is empty; + `claude --version` matches the version recorded in `NOTES.md`. + - Completed: 2026-10-01 + - Files changed: `cli/src/services/hooks/claude_mutation_scope/fixtures/probe18-t01-current-version.evidence.json`, `cli/src/services/hooks/claude_mutation_scope/fixtures/NOTES.md`, `context/cli/claude-mutation-scope-background-execution.md` + - Result: Captured the current Claude Code version and the isolated probe attempt. Claude 2.1.284 reached SessionStart but produced no model response or tool/lifecycle event within the probe timeout; all 14 cases were recorded as not capturable for this run, with prior 2.1.258 fixtures retained as prior-version evidence. G1-G7 remain unknown, so the decision gate is stop and revise toward SCE-owned process supervision; the existing background denial was unchanged. + - Verify: Passed before the lifecycle write: `git status --short --untracked-files=all` listed only the new fixture, `fixtures/NOTES.md`, and the context document; `git diff -- .claude/settings.json` was empty; `claude --version` reported `2.1.284`, matching `NOTES.md`. The evidence fixture also passed JSON parsing and `git diff --check` passed. + - Context impact: repository-wide behavior and Claude adapter lifecycle boundary; updated the background-execution context document and fixture notes with the current-version probe limitation and the stop decision so T02 cannot assume an unproven terminal contract. + - Context synchronization: synced + - Historical status (clarification added by the 2026-10-02 plan revision; the + record above is unchanged): The T01 lifecycle gate remains the historical + technical conclusion: SCE cannot implement attribution-safe background + tracking from the observed Claude lifecycle. A subsequent explicit product + decision supersedes the associated admission policy by permitting native + background Bash as an unsupported attribution boundary. The two do not + conflict: T01 failed to establish safe lifecycle tracking (G1-G7 not + proven, so the hook-driven attribution-safe lifecycle design cannot + proceed), and the later product decision accepts untracked execution + anyway. The `stop_and_revise_toward_sce_owned_process_supervision` outcome + in `probe18-t01-current-version.evidence.json` is historical evidence from + the plan state that existed when T01 ran and is not modified. The task + numbers referenced in the record above predate the 2026-10-02 renumbering. + +- [x] T02: `Record the untracked background Bash product decision and supersede the fail-closed ADR` (status:done) + - Task ID: T02 + - Scope: In — update `context/cli/claude-mutation-scope-background-execution.md` so it records the decision and the pending implementation as two distinct facts, in wording equivalent to: "A new product decision permits native Claude background Bash as an unsupported mutation-attribution boundary. The current adapter still denies `Bash(run_in_background=true)` until T03 of `claude-background-shell-mutation-scope` implements that decision." The document keeps the existing deny text as the adapter's present behavior and describes the accepted behavior the decision commits to: no mutation scope, no adapter attempt, no lifecycle tracking, background mutations may contaminate later or concurrent tracked attribution, lifecycle completion remains unproven. It includes the accepted A/B consequence (background A starts untracked; foreground B starts tracked; A mutates the repository while B is live; B closes; SCE may observe A's mutation at B's boundary and may attribute it to B; this is outside SCE's guarantees) stated as an accepted compatibility tradeoff rather than a bug. Apply the same decision-versus-implementation distinction to the background section of `context/cli/claude-mutation-scope-integration.md` and the two `context/context-map.md` annotations, which point to the new effective decision while stating the adapter still denies until T03. Have the T02 context-synchronization decision gate write, through `sce-decision`, one new Accepted ADR with `Supersedes: context/decisions/2026-10-01-claude-background-admission-requires-proven-lifecycle.md` and add its context-map entry. Preserve the T01 evidence section, the probe14/probe17 evidence, the D20 self-detached descendant boundary, and the statement that Claude lifecycle completion remains unproven. Out — any wording that says or implies the adapter already admits background Bash; the final "operationally supported" current-state contract (T03); any edit to the 2026-10-01 ADR (status, backlink, or body); any edit to `probe18-t01-current-version.evidence.json` or `fixtures/NOTES.md`; any Rust or test change; the code-order description of `handle_pre_tool_use` in the integration document (T03). + - Dependencies: T01 + - Done when: a new ADR exists and is `Accepted`; the new ADR has `Supersedes:` naming the 2026-10-01 ADR and records native background Bash as an accepted unsupported-attribution boundary with the A/B consequence; the 2026-10-01 ADR is byte-unchanged; the background-execution context, the integration context, and the context-map annotations each state the decision, state that the adapter still denies `Bash(run_in_background=true)` until T03, and name the new ADR rather than the 2026-10-01 ADR as the effective decision; no document implies the code already admits background Bash; PowerShell background is still documented as denied; T02 is `synced`. Only after that may T03 enable native background Bash. + - Verify: comparing T02's start revision (the `/next-task` Git baseline) with T02's end revision, the task-local change list — the baseline-relative `Files changed` record, equivalent to `git diff --name-only ..` — contains only `context/cli/claude-mutation-scope-background-execution.md`, `context/cli/claude-mutation-scope-integration.md`, `context/context-map.md`, the new `context/decisions/.md`, and `context/plans/claude-background-shell-mutation-scope.md`; the 2026-10-01 ADR and `probe18-t01-current-version.evidence.json` are absent from that list; inspect the new ADR for `Status: Accepted`, the `Supersedes:` line, the decision statement, and the A/B block; inspect the three context files for the decision statement, the "adapter still denies until T03" statement, and the retained T01, probe14/probe17, and D20 material. + - Completed: 2026-10-02 + - Files changed: `context/cli/claude-mutation-scope-background-execution.md`, `context/cli/claude-mutation-scope-integration.md`, `context/context-map.md`, `context/plans/claude-background-shell-mutation-scope.md` + - Result: The background-execution context now states the decision and the implementation as two distinct facts: a product decision permits native Claude background Bash as an unsupported mutation-attribution boundary, and the adapter still denies `Bash(run_in_background=true)` until T03. It keeps the deny text as present adapter behavior, describes the accepted behavior (no mutation scope, no adapter attempt, no lifecycle tracking, possible contamination of later or concurrent tracked attribution, lifecycle completion unproven), carries the A/B consequence as an accepted tradeoff, and keeps background `PowerShell` denied. The integration context's background paragraph and both context-map annotations carry the same distinction and name `context/decisions/2026-10-02-claude-background-bash-untracked.md` as the effective decision. The T01 probe section, the probe14/probe17 evidence, and the D20 boundary are retained. The superseding ADR and its context-map decision entry are written by the context-synchronization decision gate, not by task execution; until then the three context files link to that ADR path before the file exists. + - Verify: Passed at task execution: baseline was `HEAD` `b9eb2d5b` with a clean worktree, and `git status --short --untracked-files=all` then listed only the three context files (plus this plan after the lifecycle write); the 2026-10-01 ADR and `probe18-t01-current-version.evidence.json` were absent from that list and `git status --short` for both was empty; `git diff --check` passed; each of the three context files contains the decision statement, the "until T03" statement, and the new ADR path; the background-execution file retains the T01 section, the `probe14-*`/`probe17-*` evidence, the D20 paragraph, and the PowerShell denial; both edited `context/cli/` files are under 250 lines (109 and 247). Not yet satisfied at task execution, owned by context synchronization: the new ADR's existence and its `Status: Accepted`, `Supersedes:` line, decision statement, and A/B block. + - Context impact: domain — Claude mutation-scope adapter background-execution boundary. Establishes a changed admission policy (a compatibility contract and attribution-guarantee boundary) that supersedes an Accepted ADR, so it qualifies for the decision gate; requires one new superseding ADR plus its context-map entry. + - Context synchronization: synced + - Synchronization record: the decision gate wrote `context/decisions/2026-10-02-claude-background-bash-untracked.md` (`Status: Accepted`, `Supersedes:` the 2026-10-01 ADR, decision statement, A/B consequence block) and added its `context/context-map.md` decision entry, so T02's end-revision change list is the four `Files changed` paths above plus that ADR. The 2026-10-01 ADR and `probe18-t01-current-version.evidence.json` remained unchanged. + +- [x] T03: `Allow native background Bash without entering the mutation-scope adapter` (status:done) + - Task ID: T03 + - Scope: In — `cli/src/services/hooks/claude_mutation_scope/lifecycle.rs` and its focused adapter tests in `tests.rs`. In `handle_pre_tool_use`, `Bash` with `run_in_background=true` returns `String::new()` before repository resolution, the recovery barrier, model-state resolution, and `establish_start`, creating no adapter attempt and no mutation scope; `PowerShell` with `run_in_background=true` keeps the existing deny; foreground tools keep existing behavior. Replace the existing background-Bash denial tests with admission regressions; keep the PowerShell denial regression; add terminal-hook regressions showing a later `PostToolUse` and `PostToolUseFailure` for the untracked background tool are no-ops because `handle_close()` finds no matching `AttemptKey`, both with no live attempt and with an unrelated foreground attempt live whose adapter state must not be closed or mutated; add the A/B contamination regression (A = native background Bash, intentionally untracked; B = tracked foreground mutation scope; A mutates while B is live; B closes) asserting the actual existing protocol result and documenting that it carries no attribution guarantee. T03's context synchronization moves the three context files T02 touched to their final current-state contract — "Native Claude background Bash is operationally supported but unsupported for mutation attribution" — removing the "adapter still denies until T03" statement and the Bash deny text while keeping the PowerShell denial. Out — attribution filtering, worktree taint, suppressors, unknown epochs, process supervision, lifecycle correlation, reset commands, any state/protocol/schema change, generic ingress change, or background PowerShell support. + - Dependencies: T02 + - Done when: background Bash returns empty output without resolving the repo, invoking the generic seam, creating adapter state, or creating a mutation scope/attempt; background PowerShell still returns the existing deny payload; later `PostToolUse` and `PostToolUseFailure` with no matching attempt remain harmless no-ops, including when an unrelated foreground attempt is live; the A/B regression pins the existing protocol result for a mutation from untracked A observed at tracked B's boundary; after T03's context synchronization, `context/cli/claude-mutation-scope-background-execution.md`, `context/cli/claude-mutation-scope-integration.md`, and the `context/context-map.md` annotations state the final contract with no pending-implementation wording, and the integration context's `handle_pre_tool_use` order description matches the code. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope` passes, covering empty allow output, zero resolver and `start` calls, zero attempt/scope rows, unchanged state, the PowerShell deny, both terminal-hook no-op paths, and the A/B regression; the existing missing-attempt branch remains sufficient or receives only the smallest adapter-local correction; comparing T03's start revision (the `/next-task` Git baseline) with T03's end revision, the task-local change list contains only `cli/src/services/hooks/claude_mutation_scope/lifecycle.rs`, `cli/src/services/hooks/claude_mutation_scope/tests.rs`, the three context files named above, and this plan. + - Completed: 2026-10-02 + - Files changed: `cli/src/services/hooks/claude_mutation_scope/lifecycle.rs`, `cli/src/services/hooks/claude_mutation_scope/tests.rs`, `context/plans/claude-background-shell-mutation-scope.md` + - Result: `handle_pre_tool_use` now returns `String::new()` for `Bash` with `run_in_background=true` through a lifecycle-local `is_untracked_background_bash` check placed after the read-only/delegation return and before the existing `is_explicit_background_shell` deny, repository resolution, the recovery barrier, model-state resolution, and `establish_start`. `is_explicit_background_shell` in `events.rs` is unchanged, so the deny now fires only for background `PowerShell`. The two background-Bash denial tests were replaced with admission regressions (driver: panicking git-dir and model-state resolvers plus an unreachable seam; production `test15`: no adapter-state file, zero `mutation_trace_scopes`/`mutation_trace_events`/processed-event rows, including after `PostToolUse` and `PostToolUseFailure`). Added: admission while the recovery barrier is armed leaves the state file byte-unchanged; terminal-hook no-ops with no live attempt and with an unrelated live foreground attempt (driver and production `test19`); the A/B regression `test20_untracked_background_bash_mutation_is_observed_at_foreground_close_with_no_attribution_guarantee`. The existing `handle_close` missing-attempt branch was sufficient; no adapter-local correction was made. The PowerShell denial regression additionally asserts no adapter-state I/O. + - Verify: Passed: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope` — 139 passed, 0 failed, covering empty allow output, zero resolver and `start` calls, zero attempt/scope rows, unchanged state, the PowerShell deny, both terminal-hook no-op paths, and the A/B regression, which pins one `ai_exclusive` `close` event attributed to B's scope with the cursor at the tree containing A's write. Also passed: `cargo clippy --all-targets -- -D warnings` through the same wrapper, and `git diff --check`. Task-local change list at task execution, against baseline `HEAD` `f91a43af` with a clean worktree: `lifecycle.rs`, `tests.rs`, and this plan. Not yet satisfied at task execution, owned by context synchronization: the three context files' final contract and the integration context's `handle_pre_tool_use` order description. + - Context impact: domain — Claude mutation-scope adapter background-execution boundary. The adapter's observable admission behavior changed (background Bash allowed untracked; `handle_pre_tool_use` order gained a step), so `context/cli/claude-mutation-scope-background-execution.md`, `context/cli/claude-mutation-scope-integration.md`, and the two `context/context-map.md` annotations must move from "adapter still denies until T03" to the final contract. No new decision: the behavior implements the already Accepted `context/decisions/2026-10-02-claude-background-bash-untracked.md`. + - Context synchronization: synced + - Synchronization record: `context/cli/claude-mutation-scope-background-execution.md`, `context/cli/claude-mutation-scope-integration.md`, and the three background-related `context/context-map.md` annotations now state the final contract — native Claude background Bash is operationally supported but unsupported for mutation attribution — with the Bash deny text and every "adapter still denies until T03" statement removed, the PowerShell denial kept, and the integration context's `handle_pre_tool_use` order matching the code. T03's end-revision change list is the three `Files changed` paths above plus those three context files. The decision gate reused `context/decisions/2026-10-02-claude-background-bash-untracked.md`; no new ADR was written, and that immutable record keeps its as-written "until T03" wording. The two code files were committed during the task as `85750ccb`. + +- [x] T04: `Lock the unchanged foreground Claude contract` (status:done) + - Task ID: T04 + - Scope: In — preserve or add narrow foreground assertions in the Claude adapter tests: foreground `Bash` (`run_in_background` false or absent) still establishes, closes, and fails through the existing tracked path; other mutation-capable Claude tools (including unknown tool names) are unchanged, including when they carry `run_in_background=true`; read-only and delegation tools are unchanged; a self-detaching foreground command keeps the ordinary tracked path with the probe17 fixtures and D20 documentation untouched. Out — changing foreground lifecycle semantics, broad test rewrites, fixture edits, or a validation-only repository-wide task. + - Dependencies: T03 + - Done when: foreground behavior and stable output remain byte-for-byte or semantically unchanged, the focused foreground assertions pass alongside the existing Claude adapter tests, and the cumulative diff contains only the intentional Bash background admission plus focused coverage and the planned context/decision records. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope` passes; comparing T04's start revision (the `/next-task` Git baseline) with T04's end revision, the task-local change list contains only `cli/src/services/hooks/claude_mutation_scope/tests.rs`, this plan, and any context file its synchronization touches; as deliberately plan-wide checks, `git diff main -- cli/src/services/hooks/claude_mutation_scope/fixtures/` shows no `probe14-*` or `probe17-*` change and `git diff --name-only main` shows no change to `spec/mutation_cursor.qnt`, `cli/src/services/mutation_trace/`, `cli/migrations/`, doctor sources, or the adapter state format. + - Completed: 2026-10-02 + - Files changed: `cli/src/services/hooks/claude_mutation_scope/tests.rs`, `context/plans/claude-background-shell-mutation-scope.md` + - Result: Added five driver-level regressions to the Claude adapter tests and changed no production code. `foreground_bash_with_run_in_background_false_or_absent_keeps_the_tracked_lifecycle` drives `Bash` with no `tool_input`, with `tool_input` lacking the flag, with `run_in_background=false`, and with `run_in_background=null`, each through `PostToolUse` and `PostToolUseFailure`, asserting one seam `start`, an `Active` attempt, then one seam `close`, an empty attempt list, and no recovery barrier. `foreground_powershell_keeps_the_tracked_lifecycle` pins the same for foreground `PowerShell`. `non_shell_mutation_capable_tools_stay_tracked_even_with_run_in_background_true` pins the same lifecycle for `Write`, `Edit`, `NotebookEdit`, `MultiEdit`, an MCP tool, and an unknown tool name carrying `run_in_background=true`. `read_only_and_delegation_tools_with_run_in_background_true_create_no_scope` drives every known read-only tool and `Agent` with `run_in_background=true` against panicking git-dir and model-state resolvers and an unreachable seam. `self_detaching_foreground_bash_fixture_keeps_the_ordinary_tracked_path` replays the unmodified probe17 `PreToolUse` and `PostToolUse` fixtures through `include_str!` and asserts the tracked start and close. Existing production regressions `test1`, `test2`, `test17`, and `test18` were left as written and still pass with unchanged expected output. + - Verify: Passed: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope` — 144 passed, 0 failed (139 before this task plus the five new tests). Also passed: `cargo clippy --all-targets -- -D warnings` through the same wrapper, and `git diff --check`. Task-local change list at task execution, against baseline `HEAD` `c4e3ce22` with a clean worktree: `tests.rs` and this plan. Plan-wide checks passed: `git diff --name-only main -- cli/src/services/hooks/claude_mutation_scope/fixtures/` lists only `NOTES.md` and `probe18-t01-current-version.evidence.json`, so no `probe14-*` or `probe17-*` file changed; `git diff --name-only main` lists nothing under `spec/mutation_cursor.qnt`, `cli/src/services/mutation_trace/`, `cli/migrations/`, `cli/src/services/doctor`, or `cli/src/services/hooks/claude_mutation_scope/state.rs`. + - Context impact: none — test-only coverage of behavior that durable context already documents. The probe17 fixtures are now compile-time inputs of the adapter test module, which contradicts the plan assumption that no Rust test references them; `fixtures/NOTES.md` and the D20 documentation are unchanged. + - Context synchronization: synced + - Synchronization record: no context file changed. `context/cli/claude-mutation-scope-background-execution.md`, `context/cli/claude-mutation-scope-integration.md`, and the `context/context-map.md` annotations already state the foreground, read-only/delegation, background-`PowerShell`, and D20 behavior the new tests pin; `context/overview.md`, `context/architecture.md`, `context/glossary.md`, and `context/patterns.md` make no statement about it. No decision qualified. T04's end-revision change list is the two `Files changed` paths above. + +## Open questions + +None. The product direction, ADR supersession convention, task ordering, and +the decision-versus-implementation wording for the T02→T03 window are all +specified by the user. + +## Validation Report + +**Status:** validated +**Date:** 2026-10-02 + +### Commands run + +- `nix flake check` -> exit 0 (all checks passed: `cli-tests`, `cli-clippy`, `cli-fmt`, and the mutation-trace Quint connect test, on `x86_64-linux`) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope` -> exit 0 (144 passed, 0 failed, 0 ignored) +- `git log --format=%H -- ` and `git status --short -- ` for the 2026-10-01 ADR and `probe18-t01-current-version.evidence.json` -> exit 0 (each lists exactly one commit, `0badb22f`; status empty) +- `git log --format=%H -- context/decisions/2026-10-02-*.md` and `git log main..HEAD -- cli/src/services/hooks/claude_mutation_scope/lifecycle.rs` -> exit 0 (ADR commit `f91a43af` precedes lifecycle commit `c4e3ce22` on the linear branch history) +- `git diff --name-only main` -> exit 0 (ten paths, listed under AC9) +- `git diff --name-only main -- cli/src/services/hooks/claude_mutation_scope/fixtures/` -> exit 0 (only `NOTES.md` and `probe18-t01-current-version.evidence.json`) +- `git status --short --untracked-files=all` before and after the test runs -> exit 0 (empty both times) + +### Success-criteria verification + +- [x] AC1: A new Accepted ADR supersedes the 2026-10-01 ADR and records native background Bash as an accepted unsupported-attribution boundary -> inspected `context/decisions/2026-10-02-claude-background-bash-untracked.md`: `Status: Accepted`, `Supersedes:` naming the 2026-10-01 ADR, the decision statement (native, no mutation-scope tracking, lifecycle completion unproven, no attribution guarantee), and the A/B consequence block stated as an accepted tradeoff outside SCE's guarantees. +- [x] AC2: The 2026-10-01 ADR and the T01 evidence fixture are byte-unchanged -> `git log` lists exactly one commit (`0badb22f`, the adding commit) for each path and `git status --short` is empty for each; the old ADR was never edited after it was added. +- [x] AC3: The behavior-enabling task did not land before the superseding ADR, and current-state context states the final contract -> plan shows T02 `(status:done)`, `Context synchronization: synced`, `Completed: 2026-10-02`, no later than T03's `2026-10-02`; ADR commit `f91a43af` is an ancestor of lifecycle commit `c4e3ce22`; inspected `context/cli/claude-mutation-scope-background-execution.md`, `context/cli/claude-mutation-scope-integration.md`, and the `context/context-map.md` entries: each states "operationally supported but unsupported for mutation attribution", names the 2026-10-02 ADR, and carries no "still denies until T03" wording; the T01 section, `probe14-*`/`probe17-*` evidence, D20 boundary, and the unproven-lifecycle statement are retained. +- [x] AC4: Background `Bash` returns the allow shape before repository resolution, the recovery barrier, model-state resolution, and scope establishment -> `explicit_background_bash_is_allowed_untracked_before_any_repository_or_seam_access` (panicking git-dir and model-state resolvers, unreachable seam, empty output), `explicit_background_bash_is_allowed_while_the_recovery_barrier_is_armed` (state file byte-unchanged), and `test15_explicit_background_bash_is_allowed_untracked_with_no_scope_or_attempt` (no adapter-state file, zero `mutation_trace_scopes` and `mutation_trace_events` rows) pass. +- [x] AC5: Later `PostToolUse`/`PostToolUseFailure` for untracked background Bash is harmless -> `terminal_hooks_for_untracked_background_bash_are_no_ops_with_no_live_attempt`, `terminal_hooks_for_untracked_background_bash_leave_an_unrelated_live_attempt_untouched`, and `test19_terminal_hooks_for_untracked_background_bash_do_not_transition_a_live_foreground_attempt` pass, covering both events with and without an unrelated live foreground attempt, an unreachable seam, and unchanged state. +- [x] AC6: Background `PowerShell` remains denied -> `explicit_background_powershell_is_denied_ac21` passes, asserting the existing deny payload, an unreachable seam, and no adapter-state I/O. +- [x] AC7: The accepted attribution limitation is executable -> `test20_untracked_background_bash_mutation_is_observed_at_foreground_close_with_no_attribution_guarantee` passes against a real Git repository and Agent Trace DB, pinning one `ai_exclusive` `close` event attributed to B's scope with the cursor at the tree containing A's write; the test name and assertion message both state there is no attribution guarantee. +- [x] AC8: Foreground behavior is unchanged and the probe fixtures are untouched -> the focused selection passes 144/0; the only lines removed from `tests.rs` against `main` belong to the two replaced background-Bash denial tests, so no foreground expected output changed; the fixtures diff against `main` lists no `probe17-*` or `probe14-*` file. +- [x] AC9: No background lifecycle or persistence design was introduced -> `git diff --name-only main` lists only `lifecycle.rs` and `tests.rs` under `cli/src/services/hooks/claude_mutation_scope/`, the T01 fixture and `fixtures/NOTES.md`, this plan, the three context files, and the 2026-10-01 and 2026-10-02 decision records; nothing under `spec/mutation_cursor.qnt`, `cli/src/services/mutation_trace/`, `cli/migrations/`, doctor sources, or the adapter `state.rs`. + +### Failed checks and follow-ups + +- None. + +### Residual risks + +- The `sce` binary installed on this machine predates this branch: its `PreToolUse` hook still denied a `Bash(run_in_background=true)` call during this validation run. The new admission takes effect only after an `sce` build containing `c4e3ce22` is installed. +- `nix flake check` covered `x86_64-linux` only; `aarch64-linux`, `aarch64-darwin`, and `x86_64-darwin` were omitted as incompatible systems. +- Lifecycle completion for Claude background Bash remains unproven by design, so mutations from a native background Bash process can be attributed to an overlapping tracked scope.