From f2a52d3ef835de555144d835a94ec9c96022806b Mon Sep 17 00:00:00 2001 From: David Abram Date: Thu, 1 Oct 2026 16:30:21 +0200 Subject: [PATCH 1/6] context: Add Claude background shell mutation-scope plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Define the observed-lifecycle probe, implementation sequence, acceptance criteria, and context synchronization needed to safely attribute mutations from Claude-managed background Bash commands. The plan covers retaining scopes through terminal completion, recovery and health behavior, hook registration, and Git/trace attribution regressions across T01–T09. Plan: claude-background-shell-mutation-scope (T01–T09) Co-authored-by: SCE --- .../claude-background-shell-mutation-scope.md | 1239 +++++++++++++++++ 1 file changed, 1239 insertions(+) create mode 100644 context/plans/claude-background-shell-mutation-scope.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..81c837243 --- /dev/null +++ b/context/plans/claude-background-shell-mutation-scope.md @@ -0,0 +1,1239 @@ +# Plan: claude-background-shell-mutation-scope + +## Change summary + +Add first-class mutation-lifecycle safety support for Claude Code's managed +background shell execution (`Bash` with `tool_input.run_in_background = true`) +in the Claude adapter at `cli/src/services/hooks/claude_mutation_scope/`. Today +`handle_pre_tool_use` denies that call outright with +`EXPLICIT_BACKGROUND_SHELL_DENY_REASON`, so a session in which Claude chooses +background execution (the motivating case is +`nix flake check 2>&1 | tail -40; echo "EXIT=${PIPESTATUS[0]}"`) cannot run the +command at all. This plan replaces that denial with a lifecycle that keeps the +SCE mutation scope open from `PreToolUse` until a boundary proven to occur after +the background process can no longer mutate on the normal path, with a durable +worktree inhibitor as the fail-closed fallback, so the invariant holds: + +> Any repository mutation performed while a Claude-managed background command +> may still run is covered by its live SCE mutation suppressor or by the +> durable worktree safety inhibitor; neither state permits positive +> mutation-scope attribution from that background interval. + +Keeping a scope open is not the same as attributing to it. A background scope +can stay live for minutes or hours, so the first implementation treats it as a +per-scope `ManagedBackgroundSuppressor`: every interval on its worktree is +`IneligibleUnscoped` while it may still run, and its termination path is +non-attributing. It can never produce `AiExclusive(background_scope)` or a +`mutation_ai_patch`/AI-lineage record. Ordinary Claude foreground scopes keep +today's mutation-scope semantics. Independently established direct tool +evidence, such as a Claude `Edit`/`Write` `diff_trace`, remains authoritative +and is not revoked by the suppressor. The protocol also gets a durable +worktree-level +`background_safety_taint` latch for the impossible state in which a possibly +live background execution has lost its live suppressor. The latch is global to +the worktree and therefore protects every harness and every boundary. That +makes `cli/src/services/mutation_trace/`, `spec/mutation_cursor.qnt`, the +Quint Connect harness, the generic ingress `start` payload, persistence, and +doctor/health part of this change. + +The central safety invariant is: + +> While SCE believes a Claude-managed background process may still mutate the +> worktree, SCE has either a live required-confirmation suppressor for that +> execution or a durable `background_safety_taint` on the worktree. While +> either condition holds, mutation-scope/worktree-observation evidence cannot +> produce positive AI attribution from that background interval. +> Independently established direct tool evidence, such as a Claude +> `Edit`/`Write` `diff_trace`, remains authoritative and is not revoked by the +> background suppressor. + +The core safety property is: + +```text +No mutation may become AI-attributed merely because +a managed-background scope is live or terminates. +``` + +This does not mean that no mutation can be AI-attributed while the scope is +live. A foreground Claude `Edit` with its own direct `diff_trace` may remain +AI-attributed even though the same filesystem interval is +`IneligibleUnscoped` under the mutation protocol. + +Normal protocol transitions preserve the live suppressor. If a runtime +inconsistency nevertheless finds a possibly-live execution with a missing or +terminal suppressor, it must establish the durable safety latch before +processing another boundary. The latch is not ordinary `external_taint` or +`needs_rebaseline`: restart, ordinary recovery, sibling cleanup, and +`sce doctor --fix` cannot clear it. Only G5-proven process-termination +evidence followed by deterministic protocol cleanup can return the worktree to +normal attribution. + +The change stays Claude-only on the adapter side: no OpenCode, Codex, or Pi +adapter change and no harness-neutral background abstraction. Self-detaching +descendants of a foreground command (`command &`, `nohup`, `setsid`, +double-fork, `start_new_session=True`) remain the separate, explicitly +unsupported D20 boundary. The plan begins with an empirical probe of the real +Claude Code lifecycle. If that probe does not show a reliable terminal event, +a stable correlation identifier, and the required ordering, the denial stays and +the plan is revised toward SCE-owned process supervision. + +## Design + +### Current-state findings + +Verified against the code on `main` at `5dc8e2eb`. + +- **Denial.** `lifecycle.rs::handle_pre_tool_use` returns + `pre_tool_use_deny_json(EXPLICIT_BACKGROUND_SHELL_DENY_REASON)` when + `events.rs::is_explicit_background_shell(tool_name, run_in_background)` is + true (`Bash` or `PowerShell` with `run_in_background == true`). The check runs + before `git_dir` resolution, the recovery barrier, and `establish_start`. +- **Event parsing discards the evidence a background lifecycle needs.** + `parse_claude_hook_event` maps `PostToolUse` / `PostToolUseFailure` to a bare + `ClaudeToolIdentity`. `tool_input` and `tool_response` are dropped, so + `tool_response.backgroundTaskId` is unreachable. Any `hook_event_name` outside + the twelve known names is rejected. +- **Attempt state.** `state.rs` persists + `{version: 1, next_attempt_seq, recovery_pending, attempts[]}`; each + `AdapterAttempt` has a `phase` of `pending_start | active | pending_abandon`. + `parse_adapter_state` rejects any `version` other than + `ADAPTER_STATE_VERSION`, but deserialization does not use + `deny_unknown_fields`: a version-1 file carrying extra fields is accepted and + the extra fields are silently dropped. +- **`PostToolUse` always terminates the attempt** (`handle_close`), and the + `Stop`, `StopFailure`, `UserPromptSubmit`, `SubagentStop`, `SessionEnd`, and + `WorktreeRemove` sweeps abandon every matching attempt regardless of whether + its process is still running. +- **The recovery barrier and health classifier assume attempts are + short-lived.** `apply_recovery_barrier` denies while + `recovery_pending && !attempts.is_empty()`; `classify_health` reports that + shape as `blocked`; `assess_repairability` is `AutoFixable` only when every + attempt is `pending_abandon`. +- **Confirmation is keyed by harness, not by scope.** + `protocol::requires_boundary_confirmation(actor_kind)` is `true` for Codex, + OpenCode, and Pi and `false` for `ClaudeCode`; + `spec/mutation_cursor.qnt::requiresBoundaryConfirmation` mirrors it. + `ScopeState` holds only `status`, `actor_kind`, and `worktree_id`, and + `mutation_trace_scopes` has no per-scope mode column. Under these rules a sole + live Claude scope makes every interval `AiExclusive(scope)` at any boundary. +- **Taint recovery abandons live scopes.** `protocol::recover` marks every live + scope on a tainted or externally tainted worktree `Abandoned`. A live + background scope can therefore be retired at the protocol level, by a + recovery some other invocation triggered, while its process is still running. +- **There is no durable worktree-wide background safety latch.** A missing or + terminal background scope currently has no protocol representation that can + suppress mutation-scope attribution for other harnesses after the Claude + process has lost correlation. Ordinary `external_taint` is not suitable + because it is transient, and `needs_rebaseline` is deliberately clearable + by recovery. +- **Registered hooks.** `config/pkl/renderers/claude-content.pkl` registers the + adapter, unmatched, for ten events. `Notification` and any task-completion + event are not registered. +- **Existing evidence.** `fixtures/probe14-run-in-background-true.*` (Claude + Code `2.1.258`) shows `PostToolUse` after `duration_ms: 8` with + `tool_response.backgroundTaskId` and empty output. Nothing after that + acknowledgement was captured. The installed version is now `2.1.284`. +- **Context drift to repair.** `context/cli/claude-mutation-scope-integration.md` + says the adapter lives in `mod.rs` and `state.rs`; the code has split into + `events.rs`, `lifecycle.rs`, `payload.rs`, `health.rs`, and `state.rs`. + +### Observed Claude lifecycle contract and decision gate + +Unknown until T01 runs. T01 answers each determination from captured payloads +and externally observable timestamps only. + +| ID | Determination | +| --- | --- | +| G1 | `PostToolUse` for a background call fires before the process finishes and carries an identifier for the background task | +| G2 | Claude emits a later hook event when the background task ends, for success, non-zero exit, and cancellation/kill | +| G3 | A stable identifier joins the initial invocation to that terminal event | +| G4 | Ordering holds for the terminal event: `last possible child mutation < process termination <= terminal event delivery` | +| G5 | For each of `Stop`, `StopFailure`, `UserPromptSubmit`, `SubagentStop`, `SessionEnd`, `WorktreeRemove`, and Claude process exit: whether the event proves the process cannot still mutate after it (`yes` / `no` / `unknown`), with at least one boundary reachable in normal operation answering `yes` | +| G6 | The terminal event is delivered when the task ends even if the session is otherwise idle, not deferred to the next turn | +| G7 | Whether the terminal event needs a hook registration SCE does not install today, and whether a session's hook registrations come from one settings snapshot | + +`unknown` is treated as `no` everywhere. + +```text +T01 probe + | + +-- G1, G2, G3, G4 hold and G5 has at least one proven boundary + | -> implement the hook-driven managed background lifecycle (T02 onward) + | + +-- otherwise + -> STOP after T01 + -> leave the current denial in place + -> revise this plan through /change-to-plan toward SCE-owned + process supervision (see Fallback direction) +``` + +G6 does not stop the plan; it selects the terminal action (BG-D4). G7 selects +whether the capability gate needs a registration marker (BG-D5). + +### Two separate concerns + +**Adapter lifecycle** (`state.rs`, `lifecycle.rs`) tracks facts about one tool +execution: foreground, background awaiting its identifier, background +correlated and running, background unresolved, pending abandon, terminal. It +decides which seam operation to send and when. + +**Mutation protocol semantics** (`protocol.rs`, `spec/mutation_cursor.qnt`) +decide whether a worktree delta observed at a boundary is `AiExclusive`, +`AiContended`, or `IneligibleUnscoped`. + +A scope being open in the adapter never by itself entitles it to positive +attribution. The adapter keeps the scope open to satisfy the invariant; the +protocol decides what, if anything, is attributed. + +### Evidence channels + +SCE keeps direct tool evidence distinct from filesystem/mutation-scope +evidence. Direct coverage is resolved first, and mutation-history attribution +only processes lines not already covered by direct evidence. + +```text +Direct evidence + Claude Edit / Write / structured diff + ↓ + diff_trace + ↓ +independent positive evidence + ↓ +remains authoritative + + +Filesystem/mutation-scope evidence + observed Git tree delta + ↓ +managed background suppressor live + ↓ + IneligibleUnscoped + ↓ +no mutation_ai_patch / mutation-lineage AI +``` + +The managed-background suppressor affects mutation-scope attribution only. It +does not delete, downgrade, mask, or rewrite independently established direct +`diff_trace` evidence, and it does not change Claude `Edit`/`Write` collection +or direct-evidence precedence. + +### Design decisions + +**BG-D1 — Adapter state version 2.** Background-aware state is written as +`ADAPTER_STATE_VERSION = 2`. The new binary reads version 1 and version 2, +interprets every version-1 attempt as a foreground attempt, and always writes +version 2. No background attempt is ever written under version 1. An older +binary rejects a version-2 file through its existing version check. Version 2 +must represent background-awaiting-identifier, correlated-running, +`background_unresolved`, and `pending_terminal_recovery` distinctly; the +protocol's durable `background_safety_taint` remains authoritative when the +adapter and protocol disagree. + +**BG-D2 — Unresolved background has two explicitly different cases.** A +managed background execution that SCE can no longer correlate with a terminal +event is classified against the protocol suppressor before the adapter chooses +an outcome. + +**Case A: correlation lost, suppressor still live.** Enter persisted +`background_unresolved` (final name chosen in T05). The protocol scope remains +`Active` with `ManagedBackgroundSuppressor`; it is never closed or abandoned +without termination evidence. The attempt remains persisted, every new +mutation-capable Claude `PreToolUse` in that checkout is denied, the recovery +barrier cannot flush past it, and `sce doctor --fix` does not touch it. All +boundaries from every harness remain `IneligibleUnscoped` because the live +suppressor is worktree-wide. A G5-proven lifecycle boundary is the only way +to leave this case. + +**Case B: correlation lost or inconsistency found, suppressor not live.** If a +possibly-live managed background attempt is paired with a missing, `Closed`, or +`Abandoned` required scope, this is not ordinary `background_unresolved` and is +an invariant violation in the normal protocol path, not a valid lifecycle +transition. It is not repaired by the Claude adapter. Atomically establish the durable +worktree-level `background_safety_taint` with the attempt/scope identity and +reason, then force `IneligibleUnscoped` for every harness and every boundary. +Admission, mutation-scope attribution, ordinary `recover`, rebaseline, flush, +sibling abandonment, and `sce doctor --fix` remain blocked. The latch survives +process restart and can clear only after a G5-proven lifecycle boundary +demonstrates +that the process can no longer mutate, followed by deterministic protocol +cleanup. If establishing the latch fails, the operation fails closed and emits +no boundary that could produce positive mutation-scope attribution. + +Entry conditions include a background `PostToolUse` without a usable +identifier, conflicting identifiers, a `pending_start` whose idempotent +`start` cannot be durably replayed, any correlation loss, and the impossible +terminal-scope observation above. The implementation must not use the same +`background_unresolved` phase for Case B. + +**BG-D3 — Managed background is a non-attributing protocol mode.** The +protocol gains a durable per-scope mode fixed at registration and never +weakened: the existing harness default for ordinary scopes, or +`ManagedBackgroundSuppressor` for a Claude managed background. The latter +implies required confirmation and an execution lease that is potentially live. +The Quint model, Rust refinement, and MBT must prove: + +- for mutation-scope attribution, while the lease is potentially live, every + interval on that worktree is `IneligibleUnscoped`, including intervals + observed at `Start`, `Advance`, `Close`, `Flush`, recovery, and boundaries + from another harness; +- a managed-background scope cannot be closed or abandoned by a generic + boundary, `recover`, external-taint recovery, sibling cleanup, or replay; +- only a dedicated non-attributing termination transition carrying G4/G5 proof + can consume the lease and end the scope; +- `background_safety_taint` has the same attribution result even if the scope + is missing or terminal; +- no mutation event processed by the mutation protocol while either condition + holds can carry `AiExclusive`, `mutation_ai_patch`, or positive + mutation-lineage coverage; +- these protocol properties make no claim about independently established + direct evidence. A direct Claude `Edit`/`Write` `diff_trace` remains + authoritative and is not changed by this mode. + +The mode's guarantee is therefore: + +```text +For mutation-scope attribution: + every interval while A may run -> IneligibleUnscoped + +For direct attribution: + independently established direct evidence is unchanged +``` + +The existing Claude foreground default remains unchanged. Codex, OpenCode, and +Pi defaults remain unchanged. If the protocol cannot preserve the lease across +all recovery and replay paths, T02 stops and the plan is revised before any +background admission work. + +**BG-D4 — Terminal action follows proof and never attributes.** Success, +non-zero exit, and cancellation are execution outcomes, not attribution +failures. Once G4 or a G5 boundary proves the process cannot mutate, use the +dedicated non-attributing terminal operation (named `terminate_background` in +this plan; final API name chosen in T03). It may implement the state change as +`Abandon`/`consume` rather than `Close`, but it must atomically consume the +background lease, preserve `IneligibleUnscoped` for the final interval, and +never create `AiExclusive(background_scope)`. + +- Terminal event delivered promptly: `terminate_background` with its outcome. +- Terminal event delayed until later: keep the suppressor live and + non-attributing until the event arrives or a G5-proven boundary retires it; + a late duplicate is a no-op. +- G5-proven session/process boundary without a terminal event: + `terminate_background` with that proof; `Close` is not used. +- Terminal transition failure: retain the live suppressor or the durable safety + latch, persist `pending_terminal_recovery`, and retry only deterministic + cleanup. The failure is fail-closed and cannot re-enable mutation-scope + attribution. +- Correlation loss with a live suppressor follows BG-D2 Case A. Correlation + loss with a missing/terminal suppressor follows Case B. +- Never use `PostToolUse` acknowledgement, timeouts, TTLs, duration polling, or + unproven process-exit assumptions as termination evidence. + +**BG-D5 — Admission requires a verified completion capability.** If G7 shows +the terminal event needs a registration SCE does not install today, +`PreToolUse(Bash, run_in_background=true)` stays denied unless both hold: + +1. the `PreToolUse` invocation itself carries a registration-contract marker + that the generated settings emit only together with the terminal-event + registration, in a form older binaries ignore (an environment assignment in + the generated hook command, not a new CLI argument). Because a session's + hooks come from one settings snapshot (to be confirmed by G7), the marker on + the invocation proves the same snapshot holds the terminal registration; +2. the project `.claude/settings.json` contains an SCE-owned + `claude-mutation-scope` handler for the terminal event, by the same + ownership predicate setup and doctor already use. + +```text +background capability unavailable -> deny background Bash (stable reason naming `sce setup`) +background capability verified -> admit managed background Bash +``` + +An upgraded CLI with ungenerated settings therefore keeps denying. After +`sce setup` and a new Claude session, admission succeeds. If G7 shows no new +registration is needed, the capability is unconditionally available and no +marker is added. If G7 shows a session's hooks do not come from one snapshot, +the marker proof is unsound and T11 stops for a plan revision. + +**BG-D6 — Recovery barrier and safety-latch recovery.** Decided after BG-D3 +and dependent on its proof. + +| Persisted state at a mutation-capable `PreToolUse` | Outcome | +| --- | --- | +| `background_unresolved` with live suppressor | deny; no flush; nothing cleared | +| `background_safety_taint` | deny; no flush, rebaseline, or ordinary recovery | +| `recovery_pending` with any live managed background, including awaiting identifier | deny or perform only a guarded non-attributing rebaseline that preserves the live lease | +| `recovery_pending` after G5 proof with `pending_terminal_recovery` | retry deterministic non-attributing cleanup; never use `Close` | +| no background safety condition and no `recovery_pending` | proceed with existing semantics | + +Recovery may abandon ordinary sibling scopes only when it leaves every possibly +live managed background lease active. Implement this by changing `recover` or +adding a guarded `recover_managed_background` variant; do not retain the +current unconditional live-scope abandonment. If preserving that lease is +impossible, the whole recovery is a guarded no-op and the worktree remains tainted. A +`background_safety_taint` is never cleared by `recover`, external-taint +recovery, `Flush`, `repair_blocked`, or doctor. Only the G5 proof plus the +dedicated termination/cleanup transition may clear it. Process restart reads +the same persisted lease/latch state before any boundary is accepted. + +The named cases are therefore: + +- correlated A + foreground B: B's interval and every interval under A are + `IneligibleUnscoped`; B may retain today's lifecycle only if A's suppressor + survives it; +- unresolved A + foreground B: B cannot bypass the live suppressor and the + barrier remains blocked until A has proof; +- A's terminal transition fails after proof: A remains non-attributing and + pending cleanup; the latch/suppressor stays in force until retry succeeds; +- A is alive across separate CLI invocations: persisted state and protocol + lease survive restart, so mutation-scope attribution stays ineligible; +- missing/terminal suppressor while A may live: establish the durable latch, + and reject ordinary recovery until G5 evidence exists. + +**BG-D7 — Health and repairability.** Health keeps the shared +`healthy | recovering | blocked | invalid` statuses. Repairability gains a +third fact beside `AutoFixable` and `ManualOnly`: blocked awaiting lifecycle +evidence. + +| Condition | Status | Repairability | +| --- | --- | --- | +| correlated managed background suppressor live | `healthy` | none | +| background awaiting its identifier with live suppressor | `healthy` | none | +| unresolved background with live suppressor | `blocked` | blocked awaiting lifecycle evidence | +| background safety-tainted because its suppressor is missing/terminal | `blocked` | blocked awaiting lifecycle evidence; never auto-fixable | +| process termination proven, non-attributing terminal cleanup pending | `blocked` | `AutoFixable` only for deterministic retry, never to clear an unproven latch | +| unreadable, malformed, or unsupported-version state | `invalid` | `ManualOnly` | + +`sce doctor --fix` never closes, abandons, flushes past, rebaselines, or +clears a possibly-live background attempt or `background_safety_taint`. It may +retry deterministic cleanup only when G5 termination evidence is already +persisted and the safety latch can be cleared in the same guarded protocol +transition. + +**BG-D8 — Mid-flight backgrounding.** If T01 captures a foreground-started +command that Claude moves to the background (a `PostToolUse` carrying a +background identifier for an attempt whose `PreToolUse` did not set +`run_in_background`), the adapter first atomically strengthens that live scope +to `ManagedBackgroundSuppressor` before processing the acknowledgement. It +does not close the foreground scope or produce positive mutation-scope +attribution for the interval. The same attempt is then correlated with the +observed identifier. If the protocol +cannot strengthen it atomically, the adapter establishes +`background_safety_taint` and fails closed. If T01 cannot capture the path, it +is documented beside D20 as an unsupported boundary whose behavior this plan +does not change. + +### Required protocol invariants + +The Quint model and Rust refinement must state these as safety properties, not +as adapter assumptions: + +1. For every managed-background execution whose lifecycle is not G5-proven + terminated, either its protocol scope is `Active` with + `ManagedBackgroundSuppressor`, or its worktree is durably marked + `background_safety_taint`. +2. `recover`, external-taint recovery, adapter recovery, abandonment of + sibling scopes, duplicate/replayed boundaries, and ordinary flush/rebaseline + cannot terminate the last live suppressor or clear the safety latch while + that execution may still mutate. They either preserve the suppressor, leave + the worktree otherwise tainted, or establish the latch. +3. If either the live suppressor or the safety latch holds, every mutation + event processed by the mutation protocol for that worktree has + `IneligibleUnscoped` attribution. No `AiExclusive` result, + `mutation_ai_patch`, or mutation-lineage AI coverage is possible from the + managed-background interval. +4. Only a dedicated termination transition carrying G4/G5 evidence may + consume the lease. It must be non-attributing. Only that proven transition + may clear `background_safety_taint`, and only after deterministic cleanup + succeeds. + +These invariants cover only the mutation-scope/worktree-observation evidence +channel. They do not model `diff_traces` or revoke independent direct tool +evidence; direct coverage is resolved before mutation-history attribution. + +The refinement tests must exercise both the normal live-suppressor branch and +the missing/terminal-suppressor fallback branch, including restart between +every state-changing step. If any protocol action can violate one of these +properties, implementation stops and this plan is revised before Claude +background admission. + +### State transitions + +Foreground (unchanged): + +```text +PreToolUse -> pending_start -> seam start -> active +PostToolUse | PostToolUseFailure -> seam close -> attempt removed +``` + +Managed background: + +```text +PreToolUse(run_in_background=true), capability verified + -> pending_start -> seam start(ManagedBackgroundSuppressor) -> background awaiting identifier +PostToolUse with identifier + -> background running(identifier); no seam call +PostToolUse without usable identifier, or correlation otherwise lost + -> Case A background unresolved (live suppressor, admission blocked) + -> Case B background safety-tainted (missing/terminal suppressor, global inhibitor) +terminal event(identifier), G4+G6 + -> pending_terminal_recovery -> non-attributing terminate_background -> attempt removed +terminal event(identifier), G6 not holding; or G5-proven boundary + -> pending_terminal_recovery -> non-attributing terminate_background -> attempt removed +background unresolved + G5-proven boundary + -> pending_terminal_recovery -> non-attributing terminate_background -> attempt removed +background safety-tainted + G5-proven boundary + -> guarded cleanup clears inhibitor and consumes terminal protocol record -> attempt removed +``` + +`ScopeId` and `EventId` derivation are unchanged. + +### Overlapping scopes + +The protocol does not assume serialized scopes; mutation-scope attribution is +computed from the set of live scopes at each boundary. A managed-background +scope is a worktree suppressor, not a positive mutation-scope attribution +source. Use: + +```text +A = managed-background Bash +B = foreground Claude Edit with direct diff_trace +H = unrelated filesystem/human/process mutation +``` + +For these scopes and mutations: + +| Boundary | Live before | Interval attribution | Why | +| --- | --- | --- | --- | +| `Start(A)` | none | `IneligibleUnscoped` | no live scope | +| `Start(B)` | `{A}` | `IneligibleUnscoped` | A's live suppressor covers every boundary | +| `Close(B)` (B mutated) | `{A, B}` | `IneligibleUnscoped` | A suppresses the whole worktree | +| terminal proof for A | `{A}` until guarded transition | `IneligibleUnscoped` | termination is non-attributing | +| A removed after cleanup | none | normal future attribution | no A interval is attributed | + +- A's mutation follows the mutation protocol only: + + ```text + background Bash A writes file + ↓ + filesystem observation + ↓ + ManagedBackgroundSuppressor live + ↓ + IneligibleUnscoped + ↓ + no mutation_ai_patch + no positive mutation-lineage coverage from A + ``` + +- B's mutation follows both channels: + + ```text + Claude Edit B while A is running + ↓ + direct diff_trace for B + ↓ + B remains directly AI-attributed + + same filesystem interval observed by mutation protocol + ↓ + IneligibleUnscoped + ↓ + no mutation-scope AI attribution + ``` + + B's directly covered line remains AI-attributed with B's direct + session/model/tool provenance. The suppressor does not erase B's + `diff_trace`, and mutation history does not use A's observation to + supplement B's direct evidence. +- H follows the mutation protocol without direct evidence: + + ```text + human/editor/unrelated process writes file + ↓ + no direct evidence + ↓ + mutation scope sees IneligibleUnscoped + ↓ + not AI-attributed + ``` + +- A's mutations before, during, and after B are all `IneligibleUnscoped` in + the mutation protocol. A's terminal proof does not convert the final + interval into mutation-scope attribution: + + ```text + G4/G5 termination proof + ↓ + non-attributing terminate_background + ↓ + no AiExclusive(A) + no mutation_ai_patch from A + ``` + +- After A has been safely cleaned up, future ordinary foreground tools resume + normal direct and mutation-scope attribution semantics. The safety property + is that background execution cannot create false-positive AI attribution, + while stronger independent direct evidence remains usable. + +Mutation-scope coverage is deliberately given up in favor of avoiding false +positives; independently proven direct coverage is preserved. + +### Fallback direction if the lifecycle signal is insufficient + +Not implemented by this plan. If the decision gate stops the plan, the next +investigation is an SCE-supervised process lifetime: + +```text +Claude Bash(run_in_background=true) + -> PreToolUse + -> SCE rewrites the tool input so the command runs under an SCE-owned supervisor + -> supervisor launches the command and observes the real process exit + -> SCE closes the mutation scope at that exit +``` + +That investigation would need to establish whether the tested Claude Code +version honors modified tool input from a `PreToolUse` hook for `Bash`, how the +rewrite interacts with Claude's permission system and the SCE bash-policy hook, +and whether the existing `external-mutation-guard` supervisor precedent can be +reused. Timeouts, TTLs, duration polling, closing on `PostToolUse`, closing on +`Stop` without proof, and abandoning a possibly-running process are excluded +from the fallback as well. + +### Affected files and modules + +- `spec/mutation_cursor.qnt`, `cli/src/services/mutation_trace/{types,protocol,store}.rs`, + `cli/src/services/mutation_trace/mbt/`, a new + `cli/migrations/agent-trace-repository/006_*.sql` — per-scope mode, + non-attributing termination, durable background-safety latch, and migration. +- `cli/src/services/mutation_trace/runtime/coordinator.rs`, + `cli/src/services/hooks/mutation_scope.rs` — carry the mode on `Start`. +- `cli/src/services/hooks/claude_mutation_scope/{events,state,lifecycle,health,payload}.rs`, + `tests.rs`, `fixtures/`. +- `cli/src/services/hooks/mutation_scope_health.rs` and doctor rendering. +- `config/pkl/renderers/claude-content.pkl`, setup merge, doctor registration + checks — only if G7 requires a registration. +- `cli/src/services/hooks/mod.rs` (`mutation_provenance_e2e`). + +### Migration and compatibility + +- **Adapter state upgrade.** The new binary reads a version-1 file, treats + every attempt as foreground, and rewrites it as version 2 on its next write. + No background attempt can exist in a version-1 file, because background + admission is denied by every binary that writes version 1. +- **Adapter state downgrade.** An older binary rejects a version-2 file + ("unsupported version"), so every mutation-capable `PreToolUse` is denied and + health is `invalid` / `manual_only` until the newer binary is restored. It + never reads a background attempt as a foreground `active` attempt. This is + the reason additive fields under version 1 are not used: the older binary + would accept them, drop them, and apply foreground cleanup to a running + background process. +- **Agent Trace DB.** The per-scope mode and durable worktree safety latch are + additive migration state. Existing scope rows resolve to the harness + default, and existing worktrees have no latch. The migration must persist + the mode and a worktree-keyed `background_safety_taint` record (including + reason, scope/attempt identity, and lifecycle-proof status) in the same + transaction as protocol transitions. A binary older than the migration sees + an unexpected applied migration and must fail closed before loading or + accepting mutation-scope boundaries; it must never ignore the latch and + produce positive mutation-scope attribution. T03 records the observed + downgrade behavior. +- **Ingress.** The `start` payload gains one optional key. Payloads without it + behave exactly as today, so Codex, OpenCode, and Pi are unaffected. +- **Hook registration.** Covered by BG-D5: stale settings keep background Bash + denied rather than admitted without a terminal event. +- **PowerShell.** Not probeable in this Linux environment; stays denied. + +## 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. + +- [ ] AC1: Raw, unmodified hook payloads with capture timestamps and + externally observable write timestamps exist for every T01 probe case (or + the case is recorded as not capturable with its reason), and + `fixtures/NOTES.md` states the tested Claude Code version, answers G1–G7, + gives the per-event "process cannot still mutate after this event" table, + and records the decision-gate outcome. + - Validate: inspect `cli/src/services/hooks/claude_mutation_scope/fixtures/` + and the `NOTES.md` background-lifecycle addendum; every G-row cites its + fixtures and timestamps, and the G4 ordering is shown by timestamps written + by the child process itself. +- [ ] AC2: `context/cli/claude-mutation-scope-background-execution.md` + describes the managed-background lifecycle using only observed behavior, + names the tested version, and still documents self-detaching descendants as + unsupported. + - Validate: read the file; every lifecycle claim traces to a fixture named in + `NOTES.md`. +- [ ] AC3: A scope registered as `ManagedBackgroundSuppressor` suppresses + positive mutation-scope attribution on its worktree at every boundary, + including its terminal boundary; it cannot be closed or abandoned while + potentially live; its mode cannot be weakened; and no mutation-scope AI + attribution occurs while the execution is unterminated, including across + `recover`, external-taint recovery, sibling cleanup, duplicate boundaries, + and process restart. Independently established direct evidence remains + authoritative. A required-confirmation scope that is not managed background + retains its existing semantics, and Claude foreground behavior is + unchanged. + - Validate: `nix build .#checks.x86_64-linux.mutation-trace-quint-connect`; + `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml mutation_trace` +- [ ] AC4: A `start` ingress payload can request the managed-background + suppressor mode, a payload without the key behaves as today, the key is + rejected on every other operation, a weaker replay is rejected, and the mode + is durable in `mutation_trace_scopes`. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml mutation_scope` +- [ ] AC5: A version-1 adapter state file loads with every attempt treated as + foreground; every write by the new binary is version 2; a file with an + unsupported version is rejected and `PreToolUse` denies; the version-1 parser + rule (reject any other version) rejects a version-2 file. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope::state` +- [ ] AC6: `PreToolUse` for `Bash` with `run_in_background = true` is admitted + only when the completion capability is verified: with stale Claude settings + it is denied with the documented reason, and after setup makes the + registration current it returns empty stdout, commits a `start` requesting + `ManagedBackgroundSuppressor` before the command runs, and persists a + background attempt. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope`; + `nix run .#pkl-check-generated` +- [ ] AC7: The background `PostToolUse` persists the stable identifier, sends + no seam call, and leaves the attempt; a later separate invocation reading + only the state file still correlates the terminal event. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope` +- [ ] AC8: The terminal event proves lifecycle completion for success, + non-zero exit, and cancellation, then uses the BG-D4 non-attributing terminal + action; a duplicate terminal event is a no-op; an unknown identifier leaves + the state file byte-identical; and a failed terminal transition leaves + `pending_terminal_recovery` with the suppressor or safety latch still + active. No case uses `Close` to attribute the final interval. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope` +- [ ] AC9: A background `PostToolUse` with no usable identifier enters Case A + only when the required protocol suppressor is still live: it sends neither + `close` nor `abandon`, persists `background_unresolved`, denies later + mutation-capable admission, and leaves recovery/doctor unable to clear it. + If the scope is missing, `Closed`, or `Abandoned`, the same input enters Case + B instead and durably establishes `background_safety_taint`; it is not called + ordinary unresolved and cannot be cleared by recovery or doctor. Only a + G5-proven boundary can retire either case. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope` +- [ ] AC10: For each of `Stop`, `StopFailure`, `UserPromptSubmit`, + `SubagentStop`, `SessionEnd`, and `WorktreeRemove`, a test fixes whether the + event is terminal for a managed background attempt, matching the T01 table: + unproven events leave running and unresolved background attempts untouched, + proven events abandon them, and foreground attempts are swept exactly as + before. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope` +- [ ] AC11: Every row of the BG-D6 barrier table and each named recovery case + is pinned by a test. `recover`, external-taint recovery, sibling abandonment, + replay, flush, and rebaseline cannot remove the last live suppressor or clear + `background_safety_taint` while the process may still run. A failed terminal + transition remains fail-closed. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope` +- [ ] AC12: `sce doctor` distinguishes a healthy correlated background + suppressor, background awaiting its identifier, unresolved with a live + suppressor, safety-tainted because the suppressor is missing/terminal, + terminal awaiting protocol recovery, and invalid persisted state; reports + blocked safety-taint as not auto-fixable; names each background attempt's + scope, session, agent, `tool_use_id`, identifier, and latch reason; and + `sce doctor --fix` does not alter an unresolved/running attempt or clear the + safety latch. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope::health`; + `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml doctor` +- [ ] AC13: Structured log events exist for background started, identifier + bound, background completed, and background unresolved/recovery required, + carrying `session_id`, `agent_id`, `tool_use_id`, `scope_id`, the background + identifier, and the terminal outcome where each applies. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope` +- [ ] AC14: Against a real Git repository and Agent Trace DB, use + `A = managed-background Bash`, `B = foreground Claude Edit with direct + diff_trace`, and `H = unrelated filesystem/human/process mutation`. A's + mutation protocol attribution is `IneligibleUnscoped`, with no A lines in + `mutation_ai_patch` and no positive mutation-lineage provenance. B's + mutation protocol attribution for the same interval is also + `IneligibleUnscoped`, but B's direct `diff_trace` survives, its directly + covered line remains AI-attributed, and its direct session/model/tool + provenance remains authoritative. No mutation evidence from A supplements + B's direct evidence. H has no direct evidence and is not AI-attributed. + A's terminal proof uses the non-attributing terminal transition, with no + `AiExclusive(A)`, `mutation_ai_patch`, or mutation-lineage coverage from A; + the recorded order is `scope START` → `PostToolUse` → delayed mutation → + terminal proof → non-attributing cleanup. After A is safely removed, a new + ordinary foreground scope resumes the pre-existing direct and mutation-scope + attribution behavior. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml mutation_provenance_e2e` +- [ ] AC15: The foreground lifecycle is unchanged, `PowerShell` with + `run_in_background = true` is still denied, and a foreground command with a + self-detaching descendant still closes at `PostToolUse` with D20 documented + as unsupported. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope`; + pre-existing foreground and `probe17` assertions pass unmodified. +- [ ] AC16: In a real Claude Code session, a backgrounded + `nix flake check 2>&1 | tail -40; echo "EXIT=${PIPESTATUS[0]}"` is not + denied, its managed-background suppressor is live while it runs, the + terminal event ends it through the non-attributing path, and afterwards the + adapter state shows `"attempts": []` and `"recovery_pending": false`; no + `mutation_ai_patch` or AI-lineage row is emitted for that background scope. + - Validate: inspect the live-acceptance fixtures and `NOTES.md` section + recorded by T13; re-run with + `nix run .#turso -- --experimental-multiprocess-wal --readonly "" "SELECT actor_kind, status, COUNT(*) FROM mutation_trace_scopes GROUP BY actor_kind, status;"`. +- [ ] AC17: In a real Claude Code session, `sleep 3` followed by + `echo test >> ` run with `run_in_background = true` yields the + AC14 ordering in the persisted trace and logs, with the appended line + recorded as `IneligibleUnscoped` and no `mutation_ai_patch`/AI-lineage record. + - Validate: inspect the T13 fixtures; compare the adapter log timestamps, the + file's write timestamp, and the `mutation_trace_*` rows read through the + repository Turso CLI. +- [ ] AC18: Against a real Git repository and Agent Trace DB, seed a possibly + live background attempt whose required protocol scope is unexpectedly + `Closed` or `Abandoned`. The next boundary durably establishes + `background_safety_taint`; every subsequent boundary from Claude, Codex, + OpenCode, and Pi is `IneligibleUnscoped`; doctor reports + `blocked-awaiting-lifecycle-evidence`; and ordinary `recover`, rebaseline, + and `sce doctor --fix` cannot clear it. After a G5-proven termination + boundary, deterministic cleanup clears the latch and a new ordinary + foreground scope is eligible again. Repeat after SCE process restart. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml mutation_provenance_e2e`; inspect the DB with the repository Turso CLI. +- [ ] AC19: MBT/refinement covers live suppressor, recover, external-taint + recovery, sibling abandonment, duplicate/replayed boundaries, terminal + transition failure, safety-latch establishment, restart, and G5 cleanup; the + Quint invariant is stated precisely: if a managed background is not proven + terminated, either its live suppressor or the durable safety latch exists, + and while either exists mutation-scope evidence cannot produce + `AiExclusive`, `mutation_ai_patch`, or positive mutation-lineage coverage. + The protocol makes no claim about independent direct evidence. + - Validate: `nix build .#checks.x86_64-linux.mutation-trace-quint-connect`; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml mutation_trace` + +### Full validation + +Repository-wide checks `/validate` runs after the last task, regardless of +which criterion they map to. + +- `nix flake check` +- `nix run .#pkl-check-generated` + +### Context sync + +- `context/cli/claude-mutation-scope-background-execution.md` — observed + lifecycle, tested version, retained D20 boundary, mid-flight disposition. +- `context/cli/claude-mutation-scope-integration.md` — state version 2, + phases, `PostToolUse` behavior, cleanup table, barrier, capability gate, and + the module-layout drift noted above. +- `context/cli/claude-mutation-scope-health.md`, + `context/sce/mutation-scope-health-status.md`, + `context/sce/doctor-human-text-contract.md` — statuses and the third + repairability fact. +- `context/cli/mutation-trace-protocol.md`, + `context/cli/mutation-scope-runtime.md`, + `context/cli/mutation-trace-store.md`, + `context/cli/mutation-trace-quint-connect.md`, + `context/cli/mutation-scope-hook-ingress.md` — per-scope confirmation. +- `context/cli/doctor-recovery-safety-model.md` — mappings affected by the + barrier and repair changes. +- `context/sce/claude-raw-hook-capture.md`, + `context/sce/agent-trace-hooks-command-routing.md` — only if a registration + is added. +- `context/overview.md`, `context/glossary.md`, `context/context-map.md`, and + a decision record for conservative managed-background suppression and the + durable safety latch. + +## Task context synchronization lifecycle + +Persist this field in every plan; this is durable plan state, not chat state: + +- **Task context synchronization:** every task carries `pending | synced | blocked`. + A completed task must be `synced` before another task can start or the plan can + finish. +- For `blocked`, record **Blocker**, **Required action**, and **Retry condition** + beside the status. Never infer `synced` from conversation history; write every + lifecycle transition to the plan file. + +## Constraints and non-goals + +- **In scope:** `cli/src/services/hooks/claude_mutation_scope/**`; + `spec/mutation_cursor.qnt`; `cli/src/services/mutation_trace/` protocol, + types, store, MBT harness, and the coordinator's `Start` and guarded + non-attributing termination boundaries; one additive Agent Trace DB + migration for per-scope mode and the durable worktree safety latch; the + generic ingress `start` payload; the shared mutation-scope health types and + doctor rendering; + `config/pkl/renderers/claude-content.pkl` and its setup/doctor consumers when + a registration is required; the Claude cases in `cli/src/services/hooks/mod.rs`; + the context files listed under Context sync. +- **Out of scope:** OpenCode, Codex, and Pi adapters and their confirmation + behavior; changing the default confirmation rule for any harness; background + subagents (already supported through their own tool scopes); `PowerShell` + background execution; the supervised-process fallback. +- **Constraints:** every `PreToolUse` failure path stays fail-closed with + Claude's deny decision; the adapter reaches the runtime only through + `run_mutation_scope_from_payload`; the adapter-state lock is never held + across a seam call; success, non-zero exit, and cancellation are execution + outcomes, not attribution failures; a scope is closed or abandoned only at a + boundary proven to occur after its process can no longer mutate; a managed + background scope uses a non-attributing terminal path even after such proof; + the Bash + denial is removed only by T12, after protocol semantics, lifecycle, + unresolved blocking, recovery, health, and the capability gate exist; + attribution choices prefer a false negative over a false positive; context + documents record observed behavior only; all commands run through Nix per + `AGENTS.md`; context files respect the repository's per-file line budget. +- **Non-goal:** making all Claude scopes confirmation-required. +- **Non-goal:** detecting, supervising, or statically scanning for + self-detaching descendants (D20). +- **Non-goal:** a harness-neutral background execution model. +- **Non-goal:** timeouts, TTLs, duration polling, or PID tracking as a + substitute for an observed lifecycle boundary. +- **Non-goal:** automatically resolving a background attempt left behind by a + Claude process that died without emitting any proven boundary. It stays + persisted, keeps its worktree non-attributing (with the live suppressor or + safety latch), and is reported by doctor. + +## Assumptions + +- The probe follows the T01 methodology already recorded in + `fixtures/NOTES.md`: a scratch dump hook registered alongside the existing + SCE entries in `.claude/settings.json`, removed before the task finishes. +- New fixtures continue the existing `probeNN-..json` naming, + starting at `probe18`. +- `PowerShell` with `run_in_background = true` keeps its current denial and + reason text. +- Managed-background mutation-scope attribution is intentionally conservative + in v1: no interval, including the final interval at termination, may be + positively attributed to the background scope. Independently established + direct evidence is not affected; mutation-scope false negatives are + accepted. +- New log events follow the existing `sce.hooks.claude_mutation_scope.` + convention. +- Plan `Verify` commands use the repository Cargo wrapper form from + `AGENTS.md`; `nix flake check` remains the canonical verification. + +## Task stack + +- [ ] T01: `Probe and record the Claude managed-background shell lifecycle` (status:todo) + - 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`. + - Context synchronization: pending + +- [ ] T02: `Define and prove conservative managed-background protocol safety` (status:todo) + - Task ID: T02 + - Scope: In — specify the smallest protocol extension: a durable per-scope + `ManagedBackgroundSuppressor` mode/lease, a guarded non-attributing + `terminate_background` transition, and a durable worktree-level + `background_safety_taint` fallback. In `spec/mutation_cursor.qnt`, state + the invariant that an unproven background execution has either a live + suppressor or the latch, and that either condition makes all + mutation-scope attribution `IneligibleUnscoped`. Model `recover`, + external-taint recovery, sibling + abandonment, duplicate/replayed boundaries, missing/terminal suppressors, + terminal failure, and G5 cleanup. Define witness runs for A/B/H, a Claude + foreground scope unchanged, and all other harnesses unchanged. The Quint + model remains responsible only for the mutation-protocol channel; it does + not model `diff_traces` merely to express the direct-evidence distinction. + Out — DB migration, store persistence, coordinator/ingress, and Claude + adapter changes. + - Dependencies: T01, with its decision gate recorded as proceed + - Done when: the Quint safety invariants and witnesses pass; the model + explicitly forbids generic `recover`/abandon/close from terminating a + potentially-live managed background; the non-attributing terminal path is + the only lease-consuming path; the latch fallback forces global + `IneligibleUnscoped`; and the task stops for plan revision if any path can + produce positive mutation-scope attribution or remove both protections. + - Verify: `nix build .#checks.x86_64-linux.mutation-trace-quint-connect`; + `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml mutation_trace` + - Context synchronization: pending + +- [ ] T03: `Persist the protocol mode, lease, and safety latch` (status:todo) + - Task ID: T03 + - Scope: In — refine T02 in `types.rs`, `protocol.rs`, `store.rs`, the MBT + driver, and an additive `006` migration. Persist per-scope mode and + potentially-live lease state, the worktree-keyed `background_safety_taint` + reason/identity/proof state, guarded transitions, restart loading, and + migration compatibility. Record older-binary behavior and make any + schema-version mismatch fail closed before a boundary is accepted. Out — + runtime ingress and Claude adapter behavior. + - Dependencies: T02 + - Done when: all protocol fields round-trip through a real Agent Trace DB; + migration/restart tests preserve live suppressors and safety latches; + ordinary recover cannot clear the latch; only G5 proof plus deterministic + cleanup can clear it; real Git/DB tests cover a missing/terminal scope; + and the Rust refinement/MBT still matches the Quint model. + - Verify: `nix build .#checks.x86_64-linux.mutation-trace-quint-connect`; + `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml mutation_trace` + - Context synchronization: pending + +- [ ] T04: `Carry the background-safe mode through runtime ingress` (status:todo) + - Task ID: T04 + - Scope: In — `RuntimeBoundary::Start` and `coordinate()` register the + requested managed-background mode; `hooks/mutation_scope.rs` accepts one + optional `start` key, rejects it on every other operation, and forwards it + verbatim. Add the guarded non-attributing termination payload/boundary. + Out — any adapter sending the key. + - Dependencies: T03 + - Done when: a `start` payload requesting the suppressor persists the mode; + a payload without the key behaves exactly as today; a weaker replay is an + identity conflict; termination requires its dedicated operation/proof; + and exact per-operation key-set tests cover both additions. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml mutation_scope` + - Context synchronization: pending + +- [ ] T05: `Introduce version 2 Claude adapter state with version 1 migration` (status:todo) + - Task ID: T05 + - Scope: In — `state.rs`: `ADAPTER_STATE_VERSION = 2`; a reader that accepts + version 1 and version 2, maps every version-1 attempt to foreground, and + rejects any other version; writes always version 2; phases for background + awaiting identifier, background running with its identifier, background + unresolved, and `pending_terminal_recovery`; the transitions between them + and their invariants against ordinary abandonment; lookup by identifier. + Out — wiring into `lifecycle.rs`; + health. + - Dependencies: T04 + - Done when: a file written by the current binary loads as all-foreground and + is rewritten as version 2 on the next write; no code path can serialize a + background phase under version 1; a test holding the version-1 parser rule + shows a version-2 file is rejected; binding is idempotent for the same + identifier and a different identifier moves the attempt to unresolved; + every transition is one durable write under the adapter-state lock. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope::state` + - Context synchronization: pending + +- [ ] T06: `Parse Claude background lifecycle and completion evidence` (status:todo) + - Task ID: T06 + - Scope: In — `events.rs`: carry the background identifier and + `run_in_background` on the parsed `PostToolUse`/`PostToolUseFailure`; parse + the terminal event T01 identified into a typed variant with session, `cwd`, + optional `agent_id`, the joining identifier, and the outcome; unrelated + events of that hook name parse to a no-op. Fixture-driven tests. Out — + lifecycle behavior; the denial stays. + - Dependencies: T05 + - Done when: each T01 fixture parses to the expected typed event; a + `PostToolUse` without background evidence parses exactly as before; strict + rejection of malformed required fields is preserved; dispatch treats the + new variant as an empty-stdout no-op. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope` + - Context synchronization: pending + +- [ ] T07: `Implement the correlated managed-background lifecycle` (status:todo) + - Task ID: T07 + - Scope: In — `lifecycle.rs`: background `PostToolUse` binds the identifier + with no seam call; the terminal event looks the attempt up and applies the + BG-D4 non-attributing terminal action for success, non-zero exit, and + cancellation; a failed transition enters `pending_terminal_recovery` with + the suppressor/latch retained; log events for identifier bound and + background completed. Background attempts are created through the state API + in tests because the denial still stands. Out — unresolved handling, + cleanup sweeps, the barrier, removing the denial. + - Dependencies: T06 + - Done when: tests prove background `PostToolUse` does not terminate; each + outcome uses the non-attributing terminal transition and removes the + attempt only after protocol cleanup; a duplicate terminal event is a + no-op; an unknown identifier leaves the state file byte-identical; a + separate invocation reading only the file correlates the terminal event; + foreground tests pass unchanged. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope` + - Context synchronization: pending + +- [ ] T08: `Hold unresolved executions and establish the safety inhibitor` (status:todo) + - Task ID: T08 + - Scope: In — `lifecycle.rs`: every BG-D2 entry condition first checks the + protocol scope. A live suppressor produces Case A without `close` or + `abandon`; a missing/terminal suppressor atomically establishes Case B's + `background_safety_taint` before any other boundary; a `pending_start` + replays idempotent `start` only if it can make the suppressor live; + mutation-capable `PreToolUse` is denied in both cases; and logs identify + unresolved versus safety-tainted recovery. Out — proven-boundary cleanup + and recovery rules (T09); health (T10). + - Dependencies: T07 + - Done when: tests prove the two cases are distinct; no code path can send + `close`/`abandon` or flush past Case A; Case B is durable, global, + restart-safe, and cannot be cleared by `repair_blocked`, the barrier, or + ordinary recovery; and failure to establish the latch fails closed. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope` + - Context synchronization: pending + +- [ ] T09: `Apply proven-boundary cleanup and guarded recovery rules` (status:todo) + - Task ID: T09 + - Scope: In — `lifecycle.rs`: `Stop`, `StopFailure`, `UserPromptSubmit`, + `SubagentStop`, `SessionEnd`, and `WorktreeRemove` retire a running or + unresolved background attempt only where T01 answered `yes`, through the + non-attributing termination path; foreground sweeps stay unchanged. Apply + the BG-D6 barrier, preserve live suppressors during taint/external-taint + recovery, and make latch cleanup require persisted G5 evidence. Out — + health classification; removing the denial. + - Dependencies: T08 + - Done when: one test per event fixes its outcome for running and unresolved + background attempts and the unchanged outcome for a foreground attempt; + each BG-D6 row and recovery case has a test, including a real Git/DB test + that flush/recovery under a live correlated background scope emits + `IneligibleUnscoped`; missing-suppressor fallback remains latched; and + terminal-transition failure remains fail-closed. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope` + - Context synchronization: pending + +- [ ] T10: `Report background attempts and safety taint in health and doctor` (status:todo) + - Task ID: T10 + - Scope: In — `health.rs` `classify_health` and `assess_repairability`, + `repair_blocked`, the shared `mutation_scope_health.rs` repairability fact + for "blocked awaiting lifecycle evidence", and doctor text/JSON rendering + and fixability mapping per BG-D7, with per-attempt detail. Out — other + adapters' classifiers. + - Dependencies: T09 + - Done when: the classification table test covers every `recovery_pending` × + phase shape and the durable safety-latch state; all six BG-D7 conditions + render distinctly; safety-taint is explicitly not auto-fixable; + `sce doctor --fix` leaves unresolved/running attempts and the latch + untouched and re-proves its condition under the lock; and the Rust + regressions tied to `spec/doctor_recovery.qnt` invariants pass. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope::health`; + `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml doctor` + - Context synchronization: pending + +- [ ] T11: `Gate background admission on a verified terminal-hook capability` (status:todo) + - Task ID: T11 + - Scope: In — per BG-D5: the terminal-event registration and the + registration-contract marker in `config/pkl/renderers/claude-content.pkl`, + setup merge and doctor expectations for them, and an adapter function that + returns capability verified or unavailable from the invocation marker plus + the project settings check, with a stable deny reason. If G7 shows no new + registration is needed, complete with that evidence and a capability that + is always verified. If G7 shows hooks do not come from one snapshot, stop + for a plan revision. Out — calling the gate from `PreToolUse` (T12). + - Dependencies: T10 + - Done when: generated settings contain the registration and marker; setup's + merge stays idempotent and preserves user-owned hooks; the capability + function returns unavailable for settings generated before this change and + verified after `sce setup`; an older binary invoked with the marker present + behaves as before. + - Verify: `nix run .#pkl-check-generated`; + `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml setup`; + `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope` + - Context synchronization: pending + +- [ ] T12: `Admit capability-verified Claude background Bash instead of denying it` (status:todo) + - Task ID: T12 + - Scope: In — `lifecycle.rs` / `events.rs` / `payload.rs`: background Bash + `PreToolUse` checks the capability, then runs the barrier and a write-ahead + `start` requesting `ManagedBackgroundSuppressor`, and persists a background + attempt; the unconditional Bash denial is removed while `PowerShell` keeps + it; BG-D8 handling if T01 captured mid-flight backgrounding; log event for + background started; tests replaying T01 fixture sequences through the whole + adapter. Out — real Git/DB attribution regressions (T13). + - Dependencies: T09, T10, T11 + - Done when: with capability unavailable the call is denied with the + capability reason; with it verified the call returns empty stdout and never + `allow`; every failure in that path returns the fail-closed deny; the T01 + success, failure, and cancellation sequences each end with no attempt and + `recovery_pending == false`, as does the sequence for + `nix flake check 2>&1 | tail -40; echo "EXIT=${PIPESTATUS[0]}"`; foreground + tests and the `probe17` test pass unchanged. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope` + - Context synchronization: pending + +- [ ] T13: `Prove conservative background safety against real Git and the Agent Trace DB` (status:todo) + - Task ID: T13 + - Scope: In — regressions beside the existing Claude cases in + `services::hooks::tests::mutation_provenance_e2e`, driven through + `run_claude_mutation_scope_from_payload_at_state_root`: the A/B/H sequence + from Design, mutation-scope checks for every A interval, B's surviving + direct `diff_trace` and direct session/model/tool provenance, proof that A's + mutation evidence does not supplement B, H's lack of direct evidence, + terminal non-attributing cleanup, the missing/terminal-scope safety-latch + scenario, all-harness boundaries, process restart, and post-cleanup + ordinary attribution. Out — changes to protocol or lineage logic; a + failure here stops the task for a plan revision. + - Dependencies: T12 + - Done when: A, B, and H have `IneligibleUnscoped` mutation-protocol + attribution while A may run; no mutation-AI line or mutation-lineage AI + coverage is emitted for A; B's direct `diff_trace` remains AI-attributed + with its direct provenance and is not supplemented by A; H has no direct + evidence and is not AI-attributed; missing suppressor creates a durable + latch that recovery cannot clear; G5 cleanup clears it; restart preserves + it; and future ordinary foreground attribution is unchanged. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml mutation_provenance_e2e` + - Context synchronization: pending + +- [ ] T14: `Record live Claude acceptance evidence for background execution` (status:todo) + - Task ID: T14 + - Scope: In — in a real Claude Code session with the built CLI and current + generated settings, run the backgrounded + `nix flake check 2>&1 | tail -40; echo "EXIT=${PIPESTATUS[0]}"` scenario and + the `sleep 3` / `echo test >> ` scenario; store the captured + payloads, adapter-state snapshots during and after, log events, and + `mutation_trace_*` rows as fixtures with a `NOTES.md` section; update the + background-execution context document to describe the supported behavior. + Out — any source change; a failure is recorded and stops the task. + - Dependencies: T13 + - Done when: both scenarios show no `PreToolUse` deny, a live suppressor while + the command runs, the terminal event ending it through non-attributing + cleanup, `"attempts": []`, and `"recovery_pending": false`; neither + scenario emits `mutation_ai_patch`/AI lineage for the background scope; the + missing-suppressor restart scenario and doctor output are recorded; and the + test edit to the tracked file is reverted. + - Verify: `nix run .#sce -- doctor --format json` reports Claude + mutation-scope health `healthy`; `git status --short` shows only the new + fixtures, `NOTES.md`, and the context document. + - Context synchronization: pending + +## Final safety statement + +While SCE believes a Claude-managed background process may still mutate the +worktree, that execution cannot provide positive mutation-scope attribution. +A managed-background scope never produces `AiExclusive`, +`mutation_ai_patch`, or positive mutation-lineage coverage, including at +termination. Independently established direct tool evidence such as Claude +`Edit`/`Write` `diff_traces` remains authoritative and is unaffected. SCE +cannot return the mutation protocol to ordinary attribution until lifecycle +evidence proves the background process can no longer mutate and guarded +cleanup completes. The important property is: + +```text +background execution cannot create false-positive AI attribution +``` + +while stronger independent direct evidence remains usable. + +## Open questions + +None. The questions the earlier draft left open are now decisions or gates: +the first implementation never positively mutation-scope attributes a managed +background; +BG-D2 Case A is unresolved with a live suppressor, Case B is the durable +worktree safety latch, recovery is guarded by the protocol invariant, stale +hook registration is the BG-D5 admission gate, and mid-flight backgrounding is +BG-D8. There is no accepted residual false-positive mutation-scope attribution +case. If T02 +cannot prove the lease-or-latch invariant across all protocol transitions, the +plan stops before background admission and is revised. From 0badb22fb43912b735aac59ed829e90712a92c33 Mon Sep 17 00:00:00 2001 From: David Abram Date: Fri, 2 Oct 2026 00:29:20 +0200 Subject: [PATCH 2/6] claude: Record managed-background lifecycle probe result Record that Claude Code 2.1.284 produced no model turn or lifecycle completion evidence needed to prove managed-background safety. Preserve the fail-closed admission boundary and direct future work toward SCE-owned process supervision, with the evidence and plan state updated for T01. Plan: `context/plans/claude-background-shell-mutation-scope.md` Task: `T01` Co-authored-by: SCE --- .../claude_mutation_scope/fixtures/NOTES.md | 42 +++++++++++ .../probe18-t01-current-version.evidence.json | 41 +++++++++++ ...ude-mutation-scope-background-execution.md | 20 +++++ ...und-admission-requires-proven-lifecycle.md | 73 +++++++++++++++++++ .../claude-background-shell-mutation-scope.md | 9 ++- 5 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 cli/src/services/hooks/claude_mutation_scope/fixtures/probe18-t01-current-version.evidence.json create mode 100644 context/decisions/2026-10-01-claude-background-admission-requires-proven-lifecycle.md 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/context/cli/claude-mutation-scope-background-execution.md b/context/cli/claude-mutation-scope-background-execution.md index c936a5991..e5199488c 100644 --- a/context/cli/claude-mutation-scope-background-execution.md +++ b/context/cli/claude-mutation-scope-background-execution.md @@ -19,6 +19,26 @@ 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. +## 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`; per the plan, the decision gate stops here and the denial remains +in force until a later probe can establish a reliable completion signal and a +proven no-more-mutation boundary. The fallback direction is SCE-owned process +supervision, not admission based on the acknowledgement event alone. This +constraint is recorded in the +[Claude background admission decision](../decisions/2026-10-01-claude-background-admission-requires-proven-lifecycle.md). + **Self-detaching descendants are a separate, explicit unsupported boundary (D20).** A `run_in_background = false` call can still leave a repository-mutating descendant running after `PostToolUse` returns when the invoked command detaches 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/plans/claude-background-shell-mutation-scope.md b/context/plans/claude-background-shell-mutation-scope.md index 81c837243..a7a5ac34a 100644 --- a/context/plans/claude-background-shell-mutation-scope.md +++ b/context/plans/claude-background-shell-mutation-scope.md @@ -906,7 +906,7 @@ Persist this field in every plan; this is durable plan state, not chat state: ## Task stack -- [ ] T01: `Probe and record the Claude managed-background shell lifecycle` (status:todo) +- [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 @@ -946,7 +946,12 @@ Persist this field in every plan; this is durable plan state, not chat state: - 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`. - - Context synchronization: pending + - 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 - [ ] T02: `Define and prove conservative managed-background protocol safety` (status:todo) - Task ID: T02 From b9eb2d5ba77f7bc754c570d4cd3be158f459d478 Mon Sep 17 00:00:00 2001 From: David Abram Date: Fri, 2 Oct 2026 10:39:14 +0200 Subject: [PATCH 3/6] plan: Narrow Claude background shell scope to untracked Bash admission Replace the lifecycle-safety design with a minimal compatibility plan that admits native background Bash outside the mutation-scope adapter while retaining foreground behavior and PowerShell denial. Document the accepted attribution-contamination tradeoff and reduce the task stack to focused adapter regressions, context updates, and foreground contract checks. Plan: claude-background-shell-mutation-scope (T02-T04) Co-authored-by: SCE --- .../claude-background-shell-mutation-scope.md | 1364 +++-------------- 1 file changed, 227 insertions(+), 1137 deletions(-) diff --git a/context/plans/claude-background-shell-mutation-scope.md b/context/plans/claude-background-shell-mutation-scope.md index a7a5ac34a..7f489310f 100644 --- a/context/plans/claude-background-shell-mutation-scope.md +++ b/context/plans/claude-background-shell-mutation-scope.md @@ -2,804 +2,152 @@ ## Change summary -Add first-class mutation-lifecycle safety support for Claude Code's managed -background shell execution (`Bash` with `tool_input.run_in_background = true`) -in the Claude adapter at `cli/src/services/hooks/claude_mutation_scope/`. Today -`handle_pre_tool_use` denies that call outright with -`EXPLICIT_BACKGROUND_SHELL_DENY_REASON`, so a session in which Claude chooses -background execution (the motivating case is -`nix flake check 2>&1 | tail -40; echo "EXIT=${PIPESTATUS[0]}"`) cannot run the -command at all. This plan replaces that denial with a lifecycle that keeps the -SCE mutation scope open from `PreToolUse` until a boundary proven to occur after -the background process can no longer mutate on the normal path, with a durable -worktree inhibitor as the fail-closed fallback, so the invariant holds: - -> Any repository mutation performed while a Claude-managed background command -> may still run is covered by its live SCE mutation suppressor or by the -> durable worktree safety inhibitor; neither state permits positive -> mutation-scope attribution from that background interval. - -Keeping a scope open is not the same as attributing to it. A background scope -can stay live for minutes or hours, so the first implementation treats it as a -per-scope `ManagedBackgroundSuppressor`: every interval on its worktree is -`IneligibleUnscoped` while it may still run, and its termination path is -non-attributing. It can never produce `AiExclusive(background_scope)` or a -`mutation_ai_patch`/AI-lineage record. Ordinary Claude foreground scopes keep -today's mutation-scope semantics. Independently established direct tool -evidence, such as a Claude `Edit`/`Write` `diff_trace`, remains authoritative -and is not revoked by the suppressor. The protocol also gets a durable -worktree-level -`background_safety_taint` latch for the impossible state in which a possibly -live background execution has lost its live suppressor. The latch is global to -the worktree and therefore protects every harness and every boundary. That -makes `cli/src/services/mutation_trace/`, `spec/mutation_cursor.qnt`, the -Quint Connect harness, the generic ingress `start` payload, persistence, and -doctor/health part of this change. - -The central safety invariant is: - -> While SCE believes a Claude-managed background process may still mutate the -> worktree, SCE has either a live required-confirmation suppressor for that -> execution or a durable `background_safety_taint` on the worktree. While -> either condition holds, mutation-scope/worktree-observation evidence cannot -> produce positive AI attribution from that background interval. -> Independently established direct tool evidence, such as a Claude -> `Edit`/`Write` `diff_trace`, remains authoritative and is not revoked by the -> background suppressor. - -The core safety property is: - -```text -No mutation may become AI-attributed merely because -a managed-background scope is live or terminates. -``` - -This does not mean that no mutation can be AI-attributed while the scope is -live. A foreground Claude `Edit` with its own direct `diff_trace` may remain -AI-attributed even though the same filesystem interval is -`IneligibleUnscoped` under the mutation protocol. - -Normal protocol transitions preserve the live suppressor. If a runtime -inconsistency nevertheless finds a possibly-live execution with a missing or -terminal suppressor, it must establish the durable safety latch before -processing another boundary. The latch is not ordinary `external_taint` or -`needs_rebaseline`: restart, ordinary recovery, sibling cleanup, and -`sce doctor --fix` cannot clear it. Only G5-proven process-termination -evidence followed by deterministic protocol cleanup can return the worktree to -normal attribution. - -The change stays Claude-only on the adapter side: no OpenCode, Codex, or Pi -adapter change and no harness-neutral background abstraction. Self-detaching -descendants of a foreground command (`command &`, `nohup`, `setsid`, -double-fork, `start_new_session=True`) remain the separate, explicitly -unsupported D20 boundary. The plan begins with an empirical probe of the real -Claude Code lifecycle. If that probe does not show a reliable terminal event, -a stable correlation identifier, and the required ordering, the denial stays and -the plan is revised toward SCE-owned process supervision. - -## Design - -### Current-state findings - -Verified against the code on `main` at `5dc8e2eb`. - -- **Denial.** `lifecycle.rs::handle_pre_tool_use` returns - `pre_tool_use_deny_json(EXPLICIT_BACKGROUND_SHELL_DENY_REASON)` when - `events.rs::is_explicit_background_shell(tool_name, run_in_background)` is - true (`Bash` or `PowerShell` with `run_in_background == true`). The check runs - before `git_dir` resolution, the recovery barrier, and `establish_start`. -- **Event parsing discards the evidence a background lifecycle needs.** - `parse_claude_hook_event` maps `PostToolUse` / `PostToolUseFailure` to a bare - `ClaudeToolIdentity`. `tool_input` and `tool_response` are dropped, so - `tool_response.backgroundTaskId` is unreachable. Any `hook_event_name` outside - the twelve known names is rejected. -- **Attempt state.** `state.rs` persists - `{version: 1, next_attempt_seq, recovery_pending, attempts[]}`; each - `AdapterAttempt` has a `phase` of `pending_start | active | pending_abandon`. - `parse_adapter_state` rejects any `version` other than - `ADAPTER_STATE_VERSION`, but deserialization does not use - `deny_unknown_fields`: a version-1 file carrying extra fields is accepted and - the extra fields are silently dropped. -- **`PostToolUse` always terminates the attempt** (`handle_close`), and the - `Stop`, `StopFailure`, `UserPromptSubmit`, `SubagentStop`, `SessionEnd`, and - `WorktreeRemove` sweeps abandon every matching attempt regardless of whether - its process is still running. -- **The recovery barrier and health classifier assume attempts are - short-lived.** `apply_recovery_barrier` denies while - `recovery_pending && !attempts.is_empty()`; `classify_health` reports that - shape as `blocked`; `assess_repairability` is `AutoFixable` only when every - attempt is `pending_abandon`. -- **Confirmation is keyed by harness, not by scope.** - `protocol::requires_boundary_confirmation(actor_kind)` is `true` for Codex, - OpenCode, and Pi and `false` for `ClaudeCode`; - `spec/mutation_cursor.qnt::requiresBoundaryConfirmation` mirrors it. - `ScopeState` holds only `status`, `actor_kind`, and `worktree_id`, and - `mutation_trace_scopes` has no per-scope mode column. Under these rules a sole - live Claude scope makes every interval `AiExclusive(scope)` at any boundary. -- **Taint recovery abandons live scopes.** `protocol::recover` marks every live - scope on a tainted or externally tainted worktree `Abandoned`. A live - background scope can therefore be retired at the protocol level, by a - recovery some other invocation triggered, while its process is still running. -- **There is no durable worktree-wide background safety latch.** A missing or - terminal background scope currently has no protocol representation that can - suppress mutation-scope attribution for other harnesses after the Claude - process has lost correlation. Ordinary `external_taint` is not suitable - because it is transient, and `needs_rebaseline` is deliberately clearable - by recovery. -- **Registered hooks.** `config/pkl/renderers/claude-content.pkl` registers the - adapter, unmatched, for ten events. `Notification` and any task-completion - event are not registered. -- **Existing evidence.** `fixtures/probe14-run-in-background-true.*` (Claude - Code `2.1.258`) shows `PostToolUse` after `duration_ms: 8` with - `tool_response.backgroundTaskId` and empty output. Nothing after that - acknowledgement was captured. The installed version is now `2.1.284`. -- **Context drift to repair.** `context/cli/claude-mutation-scope-integration.md` - says the adapter lives in `mod.rs` and `state.rs`; the code has split into - `events.rs`, `lifecycle.rs`, `payload.rs`, `health.rs`, and `state.rs`. - -### Observed Claude lifecycle contract and decision gate - -Unknown until T01 runs. T01 answers each determination from captured payloads -and externally observable timestamps only. - -| ID | Determination | -| --- | --- | -| G1 | `PostToolUse` for a background call fires before the process finishes and carries an identifier for the background task | -| G2 | Claude emits a later hook event when the background task ends, for success, non-zero exit, and cancellation/kill | -| G3 | A stable identifier joins the initial invocation to that terminal event | -| G4 | Ordering holds for the terminal event: `last possible child mutation < process termination <= terminal event delivery` | -| G5 | For each of `Stop`, `StopFailure`, `UserPromptSubmit`, `SubagentStop`, `SessionEnd`, `WorktreeRemove`, and Claude process exit: whether the event proves the process cannot still mutate after it (`yes` / `no` / `unknown`), with at least one boundary reachable in normal operation answering `yes` | -| G6 | The terminal event is delivered when the task ends even if the session is otherwise idle, not deferred to the next turn | -| G7 | Whether the terminal event needs a hook registration SCE does not install today, and whether a session's hook registrations come from one settings snapshot | - -`unknown` is treated as `no` everywhere. - -```text -T01 probe - | - +-- G1, G2, G3, G4 hold and G5 has at least one proven boundary - | -> implement the hook-driven managed background lifecycle (T02 onward) - | - +-- otherwise - -> STOP after T01 - -> leave the current denial in place - -> revise this plan through /change-to-plan toward SCE-owned - process supervision (see Fallback direction) -``` - -G6 does not stop the plan; it selects the terminal action (BG-D4). G7 selects -whether the capability gate needs a registration marker (BG-D5). - -### Two separate concerns - -**Adapter lifecycle** (`state.rs`, `lifecycle.rs`) tracks facts about one tool -execution: foreground, background awaiting its identifier, background -correlated and running, background unresolved, pending abandon, terminal. It -decides which seam operation to send and when. - -**Mutation protocol semantics** (`protocol.rs`, `spec/mutation_cursor.qnt`) -decide whether a worktree delta observed at a boundary is `AiExclusive`, -`AiContended`, or `IneligibleUnscoped`. - -A scope being open in the adapter never by itself entitles it to positive -attribution. The adapter keeps the scope open to satisfy the invariant; the -protocol decides what, if anything, is attributed. - -### Evidence channels - -SCE keeps direct tool evidence distinct from filesystem/mutation-scope -evidence. Direct coverage is resolved first, and mutation-history attribution -only processes lines not already covered by direct evidence. - -```text -Direct evidence - Claude Edit / Write / structured diff - ↓ - diff_trace - ↓ -independent positive evidence - ↓ -remains authoritative - - -Filesystem/mutation-scope evidence - observed Git tree delta - ↓ -managed background suppressor live - ↓ - IneligibleUnscoped - ↓ -no mutation_ai_patch / mutation-lineage AI -``` - -The managed-background suppressor affects mutation-scope attribution only. It -does not delete, downgrade, mask, or rewrite independently established direct -`diff_trace` evidence, and it does not change Claude `Edit`/`Write` collection -or direct-evidence precedence. - -### Design decisions - -**BG-D1 — Adapter state version 2.** Background-aware state is written as -`ADAPTER_STATE_VERSION = 2`. The new binary reads version 1 and version 2, -interprets every version-1 attempt as a foreground attempt, and always writes -version 2. No background attempt is ever written under version 1. An older -binary rejects a version-2 file through its existing version check. Version 2 -must represent background-awaiting-identifier, correlated-running, -`background_unresolved`, and `pending_terminal_recovery` distinctly; the -protocol's durable `background_safety_taint` remains authoritative when the -adapter and protocol disagree. - -**BG-D2 — Unresolved background has two explicitly different cases.** A -managed background execution that SCE can no longer correlate with a terminal -event is classified against the protocol suppressor before the adapter chooses -an outcome. - -**Case A: correlation lost, suppressor still live.** Enter persisted -`background_unresolved` (final name chosen in T05). The protocol scope remains -`Active` with `ManagedBackgroundSuppressor`; it is never closed or abandoned -without termination evidence. The attempt remains persisted, every new -mutation-capable Claude `PreToolUse` in that checkout is denied, the recovery -barrier cannot flush past it, and `sce doctor --fix` does not touch it. All -boundaries from every harness remain `IneligibleUnscoped` because the live -suppressor is worktree-wide. A G5-proven lifecycle boundary is the only way -to leave this case. - -**Case B: correlation lost or inconsistency found, suppressor not live.** If a -possibly-live managed background attempt is paired with a missing, `Closed`, or -`Abandoned` required scope, this is not ordinary `background_unresolved` and is -an invariant violation in the normal protocol path, not a valid lifecycle -transition. It is not repaired by the Claude adapter. Atomically establish the durable -worktree-level `background_safety_taint` with the attempt/scope identity and -reason, then force `IneligibleUnscoped` for every harness and every boundary. -Admission, mutation-scope attribution, ordinary `recover`, rebaseline, flush, -sibling abandonment, and `sce doctor --fix` remain blocked. The latch survives -process restart and can clear only after a G5-proven lifecycle boundary -demonstrates -that the process can no longer mutate, followed by deterministic protocol -cleanup. If establishing the latch fails, the operation fails closed and emits -no boundary that could produce positive mutation-scope attribution. - -Entry conditions include a background `PostToolUse` without a usable -identifier, conflicting identifiers, a `pending_start` whose idempotent -`start` cannot be durably replayed, any correlation loss, and the impossible -terminal-scope observation above. The implementation must not use the same -`background_unresolved` phase for Case B. - -**BG-D3 — Managed background is a non-attributing protocol mode.** The -protocol gains a durable per-scope mode fixed at registration and never -weakened: the existing harness default for ordinary scopes, or -`ManagedBackgroundSuppressor` for a Claude managed background. The latter -implies required confirmation and an execution lease that is potentially live. -The Quint model, Rust refinement, and MBT must prove: - -- for mutation-scope attribution, while the lease is potentially live, every - interval on that worktree is `IneligibleUnscoped`, including intervals - observed at `Start`, `Advance`, `Close`, `Flush`, recovery, and boundaries - from another harness; -- a managed-background scope cannot be closed or abandoned by a generic - boundary, `recover`, external-taint recovery, sibling cleanup, or replay; -- only a dedicated non-attributing termination transition carrying G4/G5 proof - can consume the lease and end the scope; -- `background_safety_taint` has the same attribution result even if the scope - is missing or terminal; -- no mutation event processed by the mutation protocol while either condition - holds can carry `AiExclusive`, `mutation_ai_patch`, or positive - mutation-lineage coverage; -- these protocol properties make no claim about independently established - direct evidence. A direct Claude `Edit`/`Write` `diff_trace` remains - authoritative and is not changed by this mode. - -The mode's guarantee is therefore: - -```text -For mutation-scope attribution: - every interval while A may run -> IneligibleUnscoped - -For direct attribution: - independently established direct evidence is unchanged -``` - -The existing Claude foreground default remains unchanged. Codex, OpenCode, and -Pi defaults remain unchanged. If the protocol cannot preserve the lease across -all recovery and replay paths, T02 stops and the plan is revised before any -background admission work. - -**BG-D4 — Terminal action follows proof and never attributes.** Success, -non-zero exit, and cancellation are execution outcomes, not attribution -failures. Once G4 or a G5 boundary proves the process cannot mutate, use the -dedicated non-attributing terminal operation (named `terminate_background` in -this plan; final API name chosen in T03). It may implement the state change as -`Abandon`/`consume` rather than `Close`, but it must atomically consume the -background lease, preserve `IneligibleUnscoped` for the final interval, and -never create `AiExclusive(background_scope)`. - -- Terminal event delivered promptly: `terminate_background` with its outcome. -- Terminal event delayed until later: keep the suppressor live and - non-attributing until the event arrives or a G5-proven boundary retires it; - a late duplicate is a no-op. -- G5-proven session/process boundary without a terminal event: - `terminate_background` with that proof; `Close` is not used. -- Terminal transition failure: retain the live suppressor or the durable safety - latch, persist `pending_terminal_recovery`, and retry only deterministic - cleanup. The failure is fail-closed and cannot re-enable mutation-scope - attribution. -- Correlation loss with a live suppressor follows BG-D2 Case A. Correlation - loss with a missing/terminal suppressor follows Case B. -- Never use `PostToolUse` acknowledgement, timeouts, TTLs, duration polling, or - unproven process-exit assumptions as termination evidence. - -**BG-D5 — Admission requires a verified completion capability.** If G7 shows -the terminal event needs a registration SCE does not install today, -`PreToolUse(Bash, run_in_background=true)` stays denied unless both hold: - -1. the `PreToolUse` invocation itself carries a registration-contract marker - that the generated settings emit only together with the terminal-event - registration, in a form older binaries ignore (an environment assignment in - the generated hook command, not a new CLI argument). Because a session's - hooks come from one settings snapshot (to be confirmed by G7), the marker on - the invocation proves the same snapshot holds the terminal registration; -2. the project `.claude/settings.json` contains an SCE-owned - `claude-mutation-scope` handler for the terminal event, by the same - ownership predicate setup and doctor already use. - -```text -background capability unavailable -> deny background Bash (stable reason naming `sce setup`) -background capability verified -> admit managed background Bash -``` - -An upgraded CLI with ungenerated settings therefore keeps denying. After -`sce setup` and a new Claude session, admission succeeds. If G7 shows no new -registration is needed, the capability is unconditionally available and no -marker is added. If G7 shows a session's hooks do not come from one snapshot, -the marker proof is unsound and T11 stops for a plan revision. - -**BG-D6 — Recovery barrier and safety-latch recovery.** Decided after BG-D3 -and dependent on its proof. - -| Persisted state at a mutation-capable `PreToolUse` | Outcome | -| --- | --- | -| `background_unresolved` with live suppressor | deny; no flush; nothing cleared | -| `background_safety_taint` | deny; no flush, rebaseline, or ordinary recovery | -| `recovery_pending` with any live managed background, including awaiting identifier | deny or perform only a guarded non-attributing rebaseline that preserves the live lease | -| `recovery_pending` after G5 proof with `pending_terminal_recovery` | retry deterministic non-attributing cleanup; never use `Close` | -| no background safety condition and no `recovery_pending` | proceed with existing semantics | - -Recovery may abandon ordinary sibling scopes only when it leaves every possibly -live managed background lease active. Implement this by changing `recover` or -adding a guarded `recover_managed_background` variant; do not retain the -current unconditional live-scope abandonment. If preserving that lease is -impossible, the whole recovery is a guarded no-op and the worktree remains tainted. A -`background_safety_taint` is never cleared by `recover`, external-taint -recovery, `Flush`, `repair_blocked`, or doctor. Only the G5 proof plus the -dedicated termination/cleanup transition may clear it. Process restart reads -the same persisted lease/latch state before any boundary is accepted. - -The named cases are therefore: - -- correlated A + foreground B: B's interval and every interval under A are - `IneligibleUnscoped`; B may retain today's lifecycle only if A's suppressor - survives it; -- unresolved A + foreground B: B cannot bypass the live suppressor and the - barrier remains blocked until A has proof; -- A's terminal transition fails after proof: A remains non-attributing and - pending cleanup; the latch/suppressor stays in force until retry succeeds; -- A is alive across separate CLI invocations: persisted state and protocol - lease survive restart, so mutation-scope attribution stays ineligible; -- missing/terminal suppressor while A may live: establish the durable latch, - and reject ordinary recovery until G5 evidence exists. - -**BG-D7 — Health and repairability.** Health keeps the shared -`healthy | recovering | blocked | invalid` statuses. Repairability gains a -third fact beside `AutoFixable` and `ManualOnly`: blocked awaiting lifecycle -evidence. - -| Condition | Status | Repairability | -| --- | --- | --- | -| correlated managed background suppressor live | `healthy` | none | -| background awaiting its identifier with live suppressor | `healthy` | none | -| unresolved background with live suppressor | `blocked` | blocked awaiting lifecycle evidence | -| background safety-tainted because its suppressor is missing/terminal | `blocked` | blocked awaiting lifecycle evidence; never auto-fixable | -| process termination proven, non-attributing terminal cleanup pending | `blocked` | `AutoFixable` only for deterministic retry, never to clear an unproven latch | -| unreadable, malformed, or unsupported-version state | `invalid` | `ManualOnly` | - -`sce doctor --fix` never closes, abandons, flushes past, rebaselines, or -clears a possibly-live background attempt or `background_safety_taint`. It may -retry deterministic cleanup only when G5 termination evidence is already -persisted and the safety latch can be cleared in the same guarded protocol -transition. - -**BG-D8 — Mid-flight backgrounding.** If T01 captures a foreground-started -command that Claude moves to the background (a `PostToolUse` carrying a -background identifier for an attempt whose `PreToolUse` did not set -`run_in_background`), the adapter first atomically strengthens that live scope -to `ManagedBackgroundSuppressor` before processing the acknowledgement. It -does not close the foreground scope or produce positive mutation-scope -attribution for the interval. The same attempt is then correlated with the -observed identifier. If the protocol -cannot strengthen it atomically, the adapter establishes -`background_safety_taint` and fails closed. If T01 cannot capture the path, it -is documented beside D20 as an unsupported boundary whose behavior this plan -does not change. - -### Required protocol invariants - -The Quint model and Rust refinement must state these as safety properties, not -as adapter assumptions: - -1. For every managed-background execution whose lifecycle is not G5-proven - terminated, either its protocol scope is `Active` with - `ManagedBackgroundSuppressor`, or its worktree is durably marked - `background_safety_taint`. -2. `recover`, external-taint recovery, adapter recovery, abandonment of - sibling scopes, duplicate/replayed boundaries, and ordinary flush/rebaseline - cannot terminate the last live suppressor or clear the safety latch while - that execution may still mutate. They either preserve the suppressor, leave - the worktree otherwise tainted, or establish the latch. -3. If either the live suppressor or the safety latch holds, every mutation - event processed by the mutation protocol for that worktree has - `IneligibleUnscoped` attribution. No `AiExclusive` result, - `mutation_ai_patch`, or mutation-lineage AI coverage is possible from the - managed-background interval. -4. Only a dedicated termination transition carrying G4/G5 evidence may - consume the lease. It must be non-attributing. Only that proven transition - may clear `background_safety_taint`, and only after deterministic cleanup - succeeds. - -These invariants cover only the mutation-scope/worktree-observation evidence -channel. They do not model `diff_traces` or revoke independent direct tool -evidence; direct coverage is resolved before mutation-history attribution. - -The refinement tests must exercise both the normal live-suppressor branch and -the missing/terminal-suppressor fallback branch, including restart between -every state-changing step. If any protocol action can violate one of these -properties, implementation stops and this plan is revised before Claude -background admission. - -### State transitions - -Foreground (unchanged): +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 -PreToolUse -> pending_start -> seam start -> active -PostToolUse | PostToolUseFailure -> seam close -> attempt removed -``` +after T02: + policy: background Bash permitted by current decision + implementation: adapter still denies until T03 -Managed background: - -```text -PreToolUse(run_in_background=true), capability verified - -> pending_start -> seam start(ManagedBackgroundSuppressor) -> background awaiting identifier -PostToolUse with identifier - -> background running(identifier); no seam call -PostToolUse without usable identifier, or correlation otherwise lost - -> Case A background unresolved (live suppressor, admission blocked) - -> Case B background safety-tainted (missing/terminal suppressor, global inhibitor) -terminal event(identifier), G4+G6 - -> pending_terminal_recovery -> non-attributing terminate_background -> attempt removed -terminal event(identifier), G6 not holding; or G5-proven boundary - -> pending_terminal_recovery -> non-attributing terminate_background -> attempt removed -background unresolved + G5-proven boundary - -> pending_terminal_recovery -> non-attributing terminate_background -> attempt removed -background safety-tainted + G5-proven boundary - -> guarded cleanup clears inhibitor and consumes terminal protocol record -> attempt removed +after T03: + policy: permitted + implementation: permitted natively and untracked ``` -`ScopeId` and `EventId` derivation are unchanged. - -### Overlapping scopes - -The protocol does not assume serialized scopes; mutation-scope attribution is -computed from the set of live scopes at each boundary. A managed-background -scope is a worktree suppressor, not a positive mutation-scope attribution -source. Use: - -```text -A = managed-background Bash -B = foreground Claude Edit with direct diff_trace -H = unrelated filesystem/human/process mutation -``` - -For these scopes and mutations: - -| Boundary | Live before | Interval attribution | Why | -| --- | --- | --- | --- | -| `Start(A)` | none | `IneligibleUnscoped` | no live scope | -| `Start(B)` | `{A}` | `IneligibleUnscoped` | A's live suppressor covers every boundary | -| `Close(B)` (B mutated) | `{A, B}` | `IneligibleUnscoped` | A suppresses the whole worktree | -| terminal proof for A | `{A}` until guarded transition | `IneligibleUnscoped` | termination is non-attributing | -| A removed after cleanup | none | normal future attribution | no A interval is attributed | - -- A's mutation follows the mutation protocol only: - - ```text - background Bash A writes file - ↓ - filesystem observation - ↓ - ManagedBackgroundSuppressor live - ↓ - IneligibleUnscoped - ↓ - no mutation_ai_patch - no positive mutation-lineage coverage from A - ``` - -- B's mutation follows both channels: - - ```text - Claude Edit B while A is running - ↓ - direct diff_trace for B - ↓ - B remains directly AI-attributed - - same filesystem interval observed by mutation protocol - ↓ - IneligibleUnscoped - ↓ - no mutation-scope AI attribution - ``` - - B's directly covered line remains AI-attributed with B's direct - session/model/tool provenance. The suppressor does not erase B's - `diff_trace`, and mutation history does not use A's observation to - supplement B's direct evidence. -- H follows the mutation protocol without direct evidence: - - ```text - human/editor/unrelated process writes file - ↓ - no direct evidence - ↓ - mutation scope sees IneligibleUnscoped - ↓ - not AI-attributed - ``` - -- A's mutations before, during, and after B are all `IneligibleUnscoped` in - the mutation protocol. A's terminal proof does not convert the final - interval into mutation-scope attribution: - - ```text - G4/G5 termination proof - ↓ - non-attributing terminate_background - ↓ - no AiExclusive(A) - no mutation_ai_patch from A - ``` - -- After A has been safely cleaned up, future ordinary foreground tools resume - normal direct and mutation-scope attribution semantics. The safety property - is that background execution cannot create false-positive AI attribution, - while stronger independent direct evidence remains usable. - -Mutation-scope coverage is deliberately given up in favor of avoiding false -positives; independently proven direct coverage is preserved. - -### Fallback direction if the lifecycle signal is insufficient - -Not implemented by this plan. If the decision gate stops the plan, the next -investigation is an SCE-supervised process lifetime: +Resulting architecture: ```text -Claude Bash(run_in_background=true) - -> PreToolUse - -> SCE rewrites the tool input so the command runs under an SCE-owned supervisor - -> supervisor launches the command and observes the real process exit - -> SCE closes the mutation scope at that exit +Claude background Bash + ↓ +allowed natively + ↓ +not represented in mutation-scope adapter + ↓ +no lifecycle tracking + ↓ +attribution correctness not guaranteed ``` -That investigation would need to establish whether the tested Claude Code -version honors modified tool input from a `PreToolUse` hook for `Bash`, how the -rewrite interacts with Claude's permission system and the SCE bash-policy hook, -and whether the existing `external-mutation-guard` supervisor precedent can be -reused. Timeouts, TTLs, duration polling, closing on `PostToolUse`, closing on -`Stop` without proof, and abandoning a possibly-running process are excluded -from the fallback as well. - -### Affected files and modules - -- `spec/mutation_cursor.qnt`, `cli/src/services/mutation_trace/{types,protocol,store}.rs`, - `cli/src/services/mutation_trace/mbt/`, a new - `cli/migrations/agent-trace-repository/006_*.sql` — per-scope mode, - non-attributing termination, durable background-safety latch, and migration. -- `cli/src/services/mutation_trace/runtime/coordinator.rs`, - `cli/src/services/hooks/mutation_scope.rs` — carry the mode on `Start`. -- `cli/src/services/hooks/claude_mutation_scope/{events,state,lifecycle,health,payload}.rs`, - `tests.rs`, `fixtures/`. -- `cli/src/services/hooks/mutation_scope_health.rs` and doctor rendering. -- `config/pkl/renderers/claude-content.pkl`, setup merge, doctor registration - checks — only if G7 requires a registration. -- `cli/src/services/hooks/mod.rs` (`mutation_provenance_e2e`). - -### Migration and compatibility - -- **Adapter state upgrade.** The new binary reads a version-1 file, treats - every attempt as foreground, and rewrites it as version 2 on its next write. - No background attempt can exist in a version-1 file, because background - admission is denied by every binary that writes version 1. -- **Adapter state downgrade.** An older binary rejects a version-2 file - ("unsupported version"), so every mutation-capable `PreToolUse` is denied and - health is `invalid` / `manual_only` until the newer binary is restored. It - never reads a background attempt as a foreground `active` attempt. This is - the reason additive fields under version 1 are not used: the older binary - would accept them, drop them, and apply foreground cleanup to a running - background process. -- **Agent Trace DB.** The per-scope mode and durable worktree safety latch are - additive migration state. Existing scope rows resolve to the harness - default, and existing worktrees have no latch. The migration must persist - the mode and a worktree-keyed `background_safety_taint` record (including - reason, scope/attempt identity, and lifecycle-proof status) in the same - transaction as protocol transitions. A binary older than the migration sees - an unexpected applied migration and must fail closed before loading or - accepting mutation-scope boundaries; it must never ignore the latch and - produce positive mutation-scope attribution. T03 records the observed - downgrade behavior. -- **Ingress.** The `start` payload gains one optional key. Payloads without it - behave exactly as today, so Codex, OpenCode, and Pi are unaffected. -- **Hook registration.** Covered by BG-D5: stale settings keep background Bash - denied rather than admitted without a terminal event. -- **PowerShell.** Not probeable in this Linux environment; stays denied. - ## 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. - -- [ ] AC1: Raw, unmodified hook payloads with capture timestamps and - externally observable write timestamps exist for every T01 probe case (or - the case is recorded as not capturable with its reason), and - `fixtures/NOTES.md` states the tested Claude Code version, answers G1–G7, - gives the per-event "process cannot still mutate after this event" table, - and records the decision-gate outcome. - - Validate: inspect `cli/src/services/hooks/claude_mutation_scope/fixtures/` - and the `NOTES.md` background-lifecycle addendum; every G-row cites its - fixtures and timestamps, and the G4 ordering is shown by timestamps written - by the child process itself. -- [ ] AC2: `context/cli/claude-mutation-scope-background-execution.md` - describes the managed-background lifecycle using only observed behavior, - names the tested version, and still documents self-detaching descendants as - unsupported. - - Validate: read the file; every lifecycle claim traces to a fixture named in - `NOTES.md`. -- [ ] AC3: A scope registered as `ManagedBackgroundSuppressor` suppresses - positive mutation-scope attribution on its worktree at every boundary, - including its terminal boundary; it cannot be closed or abandoned while - potentially live; its mode cannot be weakened; and no mutation-scope AI - attribution occurs while the execution is unterminated, including across - `recover`, external-taint recovery, sibling cleanup, duplicate boundaries, - and process restart. Independently established direct evidence remains - authoritative. A required-confirmation scope that is not managed background - retains its existing semantics, and Claude foreground behavior is - unchanged. - - Validate: `nix build .#checks.x86_64-linux.mutation-trace-quint-connect`; - `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml mutation_trace` -- [ ] AC4: A `start` ingress payload can request the managed-background - suppressor mode, a payload without the key behaves as today, the key is - rejected on every other operation, a weaker replay is rejected, and the mode - is durable in `mutation_trace_scopes`. - - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml mutation_scope` -- [ ] AC5: A version-1 adapter state file loads with every attempt treated as - foreground; every write by the new binary is version 2; a file with an - unsupported version is rejected and `PreToolUse` denies; the version-1 parser - rule (reject any other version) rejects a version-2 file. - - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope::state` -- [ ] AC6: `PreToolUse` for `Bash` with `run_in_background = true` is admitted - only when the completion capability is verified: with stale Claude settings - it is denied with the documented reason, and after setup makes the - registration current it returns empty stdout, commits a `start` requesting - `ManagedBackgroundSuppressor` before the command runs, and persists a - background attempt. - - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope`; - `nix run .#pkl-check-generated` -- [ ] AC7: The background `PostToolUse` persists the stable identifier, sends - no seam call, and leaves the attempt; a later separate invocation reading - only the state file still correlates the terminal event. - - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope` -- [ ] AC8: The terminal event proves lifecycle completion for success, - non-zero exit, and cancellation, then uses the BG-D4 non-attributing terminal - action; a duplicate terminal event is a no-op; an unknown identifier leaves - the state file byte-identical; and a failed terminal transition leaves - `pending_terminal_recovery` with the suppressor or safety latch still - active. No case uses `Close` to attribute the final interval. - - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope` -- [ ] AC9: A background `PostToolUse` with no usable identifier enters Case A - only when the required protocol suppressor is still live: it sends neither - `close` nor `abandon`, persists `background_unresolved`, denies later - mutation-capable admission, and leaves recovery/doctor unable to clear it. - If the scope is missing, `Closed`, or `Abandoned`, the same input enters Case - B instead and durably establishes `background_safety_taint`; it is not called - ordinary unresolved and cannot be cleared by recovery or doctor. Only a - G5-proven boundary can retire either case. - - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope` -- [ ] AC10: For each of `Stop`, `StopFailure`, `UserPromptSubmit`, - `SubagentStop`, `SessionEnd`, and `WorktreeRemove`, a test fixes whether the - event is terminal for a managed background attempt, matching the T01 table: - unproven events leave running and unresolved background attempts untouched, - proven events abandon them, and foreground attempts are swept exactly as - before. - - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope` -- [ ] AC11: Every row of the BG-D6 barrier table and each named recovery case - is pinned by a test. `recover`, external-taint recovery, sibling abandonment, - replay, flush, and rebaseline cannot remove the last live suppressor or clear - `background_safety_taint` while the process may still run. A failed terminal - transition remains fail-closed. +performs final validation. Decision and documentation criteria (AC1-AC3) come +first because they must hold before the implementation criteria (AC4-AC9) may +be satisfied. + +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] AC6: Background `PowerShell` remains denied. + - Validate: a focused `PowerShell` background `PreToolUse` regression asserts + the existing deny payload and no scope/attempt creation. +- [ ] 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. +- [ ] 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` -- [ ] AC12: `sce doctor` distinguishes a healthy correlated background - suppressor, background awaiting its identifier, unresolved with a live - suppressor, safety-tainted because the suppressor is missing/terminal, - terminal awaiting protocol recovery, and invalid persisted state; reports - blocked safety-taint as not auto-fixable; names each background attempt's - scope, session, agent, `tool_use_id`, identifier, and latch reason; and - `sce doctor --fix` does not alter an unresolved/running attempt or clear the - safety latch. - - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope::health`; - `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml doctor` -- [ ] AC13: Structured log events exist for background started, identifier - bound, background completed, and background unresolved/recovery required, - carrying `session_id`, `agent_id`, `tool_use_id`, `scope_id`, the background - identifier, and the terminal outcome where each applies. - - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope` -- [ ] AC14: Against a real Git repository and Agent Trace DB, use - `A = managed-background Bash`, `B = foreground Claude Edit with direct - diff_trace`, and `H = unrelated filesystem/human/process mutation`. A's - mutation protocol attribution is `IneligibleUnscoped`, with no A lines in - `mutation_ai_patch` and no positive mutation-lineage provenance. B's - mutation protocol attribution for the same interval is also - `IneligibleUnscoped`, but B's direct `diff_trace` survives, its directly - covered line remains AI-attributed, and its direct session/model/tool - provenance remains authoritative. No mutation evidence from A supplements - B's direct evidence. H has no direct evidence and is not AI-attributed. - A's terminal proof uses the non-attributing terminal transition, with no - `AiExclusive(A)`, `mutation_ai_patch`, or mutation-lineage coverage from A; - the recorded order is `scope START` → `PostToolUse` → delayed mutation → - terminal proof → non-attributing cleanup. After A is safely removed, a new - ordinary foreground scope resumes the pre-existing direct and mutation-scope - attribution behavior. - - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml mutation_provenance_e2e` -- [ ] AC15: The foreground lifecycle is unchanged, `PowerShell` with - `run_in_background = true` is still denied, and a foreground command with a - self-detaching descendant still closes at `PostToolUse` with D20 documented - as unsupported. - - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope`; - pre-existing foreground and `probe17` assertions pass unmodified. -- [ ] AC16: In a real Claude Code session, a backgrounded - `nix flake check 2>&1 | tail -40; echo "EXIT=${PIPESTATUS[0]}"` is not - denied, its managed-background suppressor is live while it runs, the - terminal event ends it through the non-attributing path, and afterwards the - adapter state shows `"attempts": []` and `"recovery_pending": false`; no - `mutation_ai_patch` or AI-lineage row is emitted for that background scope. - - Validate: inspect the live-acceptance fixtures and `NOTES.md` section - recorded by T13; re-run with - `nix run .#turso -- --experimental-multiprocess-wal --readonly "" "SELECT actor_kind, status, COUNT(*) FROM mutation_trace_scopes GROUP BY actor_kind, status;"`. -- [ ] AC17: In a real Claude Code session, `sleep 3` followed by - `echo test >> ` run with `run_in_background = true` yields the - AC14 ordering in the persisted trace and logs, with the appended line - recorded as `IneligibleUnscoped` and no `mutation_ai_patch`/AI-lineage record. - - Validate: inspect the T13 fixtures; compare the adapter log timestamps, the - file's write timestamp, and the `mutation_trace_*` rows read through the - repository Turso CLI. -- [ ] AC18: Against a real Git repository and Agent Trace DB, seed a possibly - live background attempt whose required protocol scope is unexpectedly - `Closed` or `Abandoned`. The next boundary durably establishes - `background_safety_taint`; every subsequent boundary from Claude, Codex, - OpenCode, and Pi is `IneligibleUnscoped`; doctor reports - `blocked-awaiting-lifecycle-evidence`; and ordinary `recover`, rebaseline, - and `sce doctor --fix` cannot clear it. After a G5-proven termination - boundary, deterministic cleanup clears the latch and a new ordinary - foreground scope is eligible again. Repeat after SCE process restart. - - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml mutation_provenance_e2e`; inspect the DB with the repository Turso CLI. -- [ ] AC19: MBT/refinement covers live suppressor, recover, external-taint - recovery, sibling abandonment, duplicate/replayed boundaries, terminal - transition failure, safety-latch establishment, restart, and G5 cleanup; the - Quint invariant is stated precisely: if a managed background is not proven - terminated, either its live suppressor or the durable safety latch exists, - and while either exists mutation-scope evidence cannot produce - `AiExclusive`, `mutation_ai_patch`, or positive mutation-lineage coverage. - The protocol makes no claim about independent direct evidence. - - Validate: `nix build .#checks.x86_64-linux.mutation-trace-quint-connect`; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml mutation_trace` + 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. +- [ ] 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 @@ -807,32 +155,15 @@ Repository-wide checks `/validate` runs after the last task, regardless of which criterion they map to. - `nix flake check` -- `nix run .#pkl-check-generated` ### Context sync -- `context/cli/claude-mutation-scope-background-execution.md` — observed - lifecycle, tested version, retained D20 boundary, mid-flight disposition. -- `context/cli/claude-mutation-scope-integration.md` — state version 2, - phases, `PostToolUse` behavior, cleanup table, barrier, capability gate, and - the module-layout drift noted above. -- `context/cli/claude-mutation-scope-health.md`, - `context/sce/mutation-scope-health-status.md`, - `context/sce/doctor-human-text-contract.md` — statuses and the third - repairability fact. -- `context/cli/mutation-trace-protocol.md`, - `context/cli/mutation-scope-runtime.md`, - `context/cli/mutation-trace-store.md`, - `context/cli/mutation-trace-quint-connect.md`, - `context/cli/mutation-scope-hook-ingress.md` — per-scope confirmation. -- `context/cli/doctor-recovery-safety-model.md` — mappings affected by the - barrier and repair changes. -- `context/sce/claude-raw-hook-capture.md`, - `context/sce/agent-trace-hooks-command-routing.md` — only if a registration - is added. -- `context/overview.md`, `context/glossary.md`, `context/context-map.md`, and - a decision record for conservative managed-background suppression and the - durable safety latch. +- `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 @@ -847,62 +178,68 @@ Persist this field in every plan; this is durable plan state, not chat state: ## Constraints and non-goals -- **In scope:** `cli/src/services/hooks/claude_mutation_scope/**`; - `spec/mutation_cursor.qnt`; `cli/src/services/mutation_trace/` protocol, - types, store, MBT harness, and the coordinator's `Start` and guarded - non-attributing termination boundaries; one additive Agent Trace DB - migration for per-scope mode and the durable worktree safety latch; the - generic ingress `start` payload; the shared mutation-scope health types and - doctor rendering; - `config/pkl/renderers/claude-content.pkl` and its setup/doctor consumers when - a registration is required; the Claude cases in `cli/src/services/hooks/mod.rs`; - the context files listed under Context sync. -- **Out of scope:** OpenCode, Codex, and Pi adapters and their confirmation - behavior; changing the default confirmation rule for any harness; background - subagents (already supported through their own tool scopes); `PowerShell` - background execution; the supervised-process fallback. -- **Constraints:** every `PreToolUse` failure path stays fail-closed with - Claude's deny decision; the adapter reaches the runtime only through - `run_mutation_scope_from_payload`; the adapter-state lock is never held - across a seam call; success, non-zero exit, and cancellation are execution - outcomes, not attribution failures; a scope is closed or abandoned only at a - boundary proven to occur after its process can no longer mutate; a managed - background scope uses a non-attributing terminal path even after such proof; - the Bash - denial is removed only by T12, after protocol semantics, lifecycle, - unresolved blocking, recovery, health, and the capability gate exist; - attribution choices prefer a false negative over a false positive; context - documents record observed behavior only; all commands run through Nix per - `AGENTS.md`; context files respect the repository's per-file line budget. -- **Non-goal:** making all Claude scopes confirmation-required. -- **Non-goal:** detecting, supervising, or statically scanning for - self-detaching descendants (D20). -- **Non-goal:** a harness-neutral background execution model. -- **Non-goal:** timeouts, TTLs, duration polling, or PID tracking as a - substitute for an observed lifecycle boundary. -- **Non-goal:** automatically resolving a background attempt left behind by a - Claude process that died without emitting any proven boundary. It stays - persisted, keeps its worktree non-attributing (with the live suppressor or - safety latch), and is reported by doctor. +- **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 -- The probe follows the T01 methodology already recorded in - `fixtures/NOTES.md`: a scratch dump hook registered alongside the existing - SCE entries in `.claude/settings.json`, removed before the task finishes. -- New fixtures continue the existing `probeNN-..json` naming, - starting at `probe18`. -- `PowerShell` with `run_in_background = true` keeps its current denial and - reason text. -- Managed-background mutation-scope attribution is intentionally conservative - in v1: no interval, including the final interval at termination, may be - positively attributed to the background scope. Independently established - direct evidence is not affected; mutation-scope false negatives are - accepted. -- New log events follow the existing `sce.hooks.claude_mutation_scope.` - convention. -- Plan `Verify` commands use the repository Cargo wrapper form from - `AGENTS.md`; `nix flake check` remains the canonical verification. +- `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 @@ -952,293 +289,46 @@ Persist this field in every plan; this is durable plan state, not chat state: - 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 - -- [ ] T02: `Define and prove conservative managed-background protocol safety` (status:todo) + - 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. + +- [ ] T02: `Record the untracked background Bash product decision and supersede the fail-closed ADR` (status:todo) - Task ID: T02 - - Scope: In — specify the smallest protocol extension: a durable per-scope - `ManagedBackgroundSuppressor` mode/lease, a guarded non-attributing - `terminate_background` transition, and a durable worktree-level - `background_safety_taint` fallback. In `spec/mutation_cursor.qnt`, state - the invariant that an unproven background execution has either a live - suppressor or the latch, and that either condition makes all - mutation-scope attribution `IneligibleUnscoped`. Model `recover`, - external-taint recovery, sibling - abandonment, duplicate/replayed boundaries, missing/terminal suppressors, - terminal failure, and G5 cleanup. Define witness runs for A/B/H, a Claude - foreground scope unchanged, and all other harnesses unchanged. The Quint - model remains responsible only for the mutation-protocol channel; it does - not model `diff_traces` merely to express the direct-evidence distinction. - Out — DB migration, store persistence, coordinator/ingress, and Claude - adapter changes. - - Dependencies: T01, with its decision gate recorded as proceed - - Done when: the Quint safety invariants and witnesses pass; the model - explicitly forbids generic `recover`/abandon/close from terminating a - potentially-live managed background; the non-attributing terminal path is - the only lease-consuming path; the latch fallback forces global - `IneligibleUnscoped`; and the task stops for plan revision if any path can - produce positive mutation-scope attribution or remove both protections. - - Verify: `nix build .#checks.x86_64-linux.mutation-trace-quint-connect`; - `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml mutation_trace` + - 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. - Context synchronization: pending -- [ ] T03: `Persist the protocol mode, lease, and safety latch` (status:todo) +- [ ] T03: `Allow native background Bash without entering the mutation-scope adapter` (status:todo) - Task ID: T03 - - Scope: In — refine T02 in `types.rs`, `protocol.rs`, `store.rs`, the MBT - driver, and an additive `006` migration. Persist per-scope mode and - potentially-live lease state, the worktree-keyed `background_safety_taint` - reason/identity/proof state, guarded transitions, restart loading, and - migration compatibility. Record older-binary behavior and make any - schema-version mismatch fail closed before a boundary is accepted. Out — - runtime ingress and Claude adapter behavior. + - 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: all protocol fields round-trip through a real Agent Trace DB; - migration/restart tests preserve live suppressors and safety latches; - ordinary recover cannot clear the latch; only G5 proof plus deterministic - cleanup can clear it; real Git/DB tests cover a missing/terminal scope; - and the Rust refinement/MBT still matches the Quint model. - - Verify: `nix build .#checks.x86_64-linux.mutation-trace-quint-connect`; - `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml mutation_trace` + - 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. - Context synchronization: pending -- [ ] T04: `Carry the background-safe mode through runtime ingress` (status:todo) +- [ ] T04: `Lock the unchanged foreground Claude contract` (status:todo) - Task ID: T04 - - Scope: In — `RuntimeBoundary::Start` and `coordinate()` register the - requested managed-background mode; `hooks/mutation_scope.rs` accepts one - optional `start` key, rejects it on every other operation, and forwards it - verbatim. Add the guarded non-attributing termination payload/boundary. - Out — any adapter sending the key. + - 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: a `start` payload requesting the suppressor persists the mode; - a payload without the key behaves exactly as today; a weaker replay is an - identity conflict; termination requires its dedicated operation/proof; - and exact per-operation key-set tests cover both additions. - - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml mutation_scope` - - Context synchronization: pending - -- [ ] T05: `Introduce version 2 Claude adapter state with version 1 migration` (status:todo) - - Task ID: T05 - - Scope: In — `state.rs`: `ADAPTER_STATE_VERSION = 2`; a reader that accepts - version 1 and version 2, maps every version-1 attempt to foreground, and - rejects any other version; writes always version 2; phases for background - awaiting identifier, background running with its identifier, background - unresolved, and `pending_terminal_recovery`; the transitions between them - and their invariants against ordinary abandonment; lookup by identifier. - Out — wiring into `lifecycle.rs`; - health. - - Dependencies: T04 - - Done when: a file written by the current binary loads as all-foreground and - is rewritten as version 2 on the next write; no code path can serialize a - background phase under version 1; a test holding the version-1 parser rule - shows a version-2 file is rejected; binding is idempotent for the same - identifier and a different identifier moves the attempt to unresolved; - every transition is one durable write under the adapter-state lock. - - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope::state` - - Context synchronization: pending - -- [ ] T06: `Parse Claude background lifecycle and completion evidence` (status:todo) - - Task ID: T06 - - Scope: In — `events.rs`: carry the background identifier and - `run_in_background` on the parsed `PostToolUse`/`PostToolUseFailure`; parse - the terminal event T01 identified into a typed variant with session, `cwd`, - optional `agent_id`, the joining identifier, and the outcome; unrelated - events of that hook name parse to a no-op. Fixture-driven tests. Out — - lifecycle behavior; the denial stays. - - Dependencies: T05 - - Done when: each T01 fixture parses to the expected typed event; a - `PostToolUse` without background evidence parses exactly as before; strict - rejection of malformed required fields is preserved; dispatch treats the - new variant as an empty-stdout no-op. - - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope` - - Context synchronization: pending - -- [ ] T07: `Implement the correlated managed-background lifecycle` (status:todo) - - Task ID: T07 - - Scope: In — `lifecycle.rs`: background `PostToolUse` binds the identifier - with no seam call; the terminal event looks the attempt up and applies the - BG-D4 non-attributing terminal action for success, non-zero exit, and - cancellation; a failed transition enters `pending_terminal_recovery` with - the suppressor/latch retained; log events for identifier bound and - background completed. Background attempts are created through the state API - in tests because the denial still stands. Out — unresolved handling, - cleanup sweeps, the barrier, removing the denial. - - Dependencies: T06 - - Done when: tests prove background `PostToolUse` does not terminate; each - outcome uses the non-attributing terminal transition and removes the - attempt only after protocol cleanup; a duplicate terminal event is a - no-op; an unknown identifier leaves the state file byte-identical; a - separate invocation reading only the file correlates the terminal event; - foreground tests pass unchanged. - - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope` - - Context synchronization: pending - -- [ ] T08: `Hold unresolved executions and establish the safety inhibitor` (status:todo) - - Task ID: T08 - - Scope: In — `lifecycle.rs`: every BG-D2 entry condition first checks the - protocol scope. A live suppressor produces Case A without `close` or - `abandon`; a missing/terminal suppressor atomically establishes Case B's - `background_safety_taint` before any other boundary; a `pending_start` - replays idempotent `start` only if it can make the suppressor live; - mutation-capable `PreToolUse` is denied in both cases; and logs identify - unresolved versus safety-tainted recovery. Out — proven-boundary cleanup - and recovery rules (T09); health (T10). - - Dependencies: T07 - - Done when: tests prove the two cases are distinct; no code path can send - `close`/`abandon` or flush past Case A; Case B is durable, global, - restart-safe, and cannot be cleared by `repair_blocked`, the barrier, or - ordinary recovery; and failure to establish the latch fails closed. - - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope` - - Context synchronization: pending - -- [ ] T09: `Apply proven-boundary cleanup and guarded recovery rules` (status:todo) - - Task ID: T09 - - Scope: In — `lifecycle.rs`: `Stop`, `StopFailure`, `UserPromptSubmit`, - `SubagentStop`, `SessionEnd`, and `WorktreeRemove` retire a running or - unresolved background attempt only where T01 answered `yes`, through the - non-attributing termination path; foreground sweeps stay unchanged. Apply - the BG-D6 barrier, preserve live suppressors during taint/external-taint - recovery, and make latch cleanup require persisted G5 evidence. Out — - health classification; removing the denial. - - Dependencies: T08 - - Done when: one test per event fixes its outcome for running and unresolved - background attempts and the unchanged outcome for a foreground attempt; - each BG-D6 row and recovery case has a test, including a real Git/DB test - that flush/recovery under a live correlated background scope emits - `IneligibleUnscoped`; missing-suppressor fallback remains latched; and - terminal-transition failure remains fail-closed. - - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope` + - 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. - Context synchronization: pending -- [ ] T10: `Report background attempts and safety taint in health and doctor` (status:todo) - - Task ID: T10 - - Scope: In — `health.rs` `classify_health` and `assess_repairability`, - `repair_blocked`, the shared `mutation_scope_health.rs` repairability fact - for "blocked awaiting lifecycle evidence", and doctor text/JSON rendering - and fixability mapping per BG-D7, with per-attempt detail. Out — other - adapters' classifiers. - - Dependencies: T09 - - Done when: the classification table test covers every `recovery_pending` × - phase shape and the durable safety-latch state; all six BG-D7 conditions - render distinctly; safety-taint is explicitly not auto-fixable; - `sce doctor --fix` leaves unresolved/running attempts and the latch - untouched and re-proves its condition under the lock; and the Rust - regressions tied to `spec/doctor_recovery.qnt` invariants pass. - - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope::health`; - `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml doctor` - - Context synchronization: pending - -- [ ] T11: `Gate background admission on a verified terminal-hook capability` (status:todo) - - Task ID: T11 - - Scope: In — per BG-D5: the terminal-event registration and the - registration-contract marker in `config/pkl/renderers/claude-content.pkl`, - setup merge and doctor expectations for them, and an adapter function that - returns capability verified or unavailable from the invocation marker plus - the project settings check, with a stable deny reason. If G7 shows no new - registration is needed, complete with that evidence and a capability that - is always verified. If G7 shows hooks do not come from one snapshot, stop - for a plan revision. Out — calling the gate from `PreToolUse` (T12). - - Dependencies: T10 - - Done when: generated settings contain the registration and marker; setup's - merge stays idempotent and preserves user-owned hooks; the capability - function returns unavailable for settings generated before this change and - verified after `sce setup`; an older binary invoked with the marker present - behaves as before. - - Verify: `nix run .#pkl-check-generated`; - `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml setup`; - `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope` - - Context synchronization: pending - -- [ ] T12: `Admit capability-verified Claude background Bash instead of denying it` (status:todo) - - Task ID: T12 - - Scope: In — `lifecycle.rs` / `events.rs` / `payload.rs`: background Bash - `PreToolUse` checks the capability, then runs the barrier and a write-ahead - `start` requesting `ManagedBackgroundSuppressor`, and persists a background - attempt; the unconditional Bash denial is removed while `PowerShell` keeps - it; BG-D8 handling if T01 captured mid-flight backgrounding; log event for - background started; tests replaying T01 fixture sequences through the whole - adapter. Out — real Git/DB attribution regressions (T13). - - Dependencies: T09, T10, T11 - - Done when: with capability unavailable the call is denied with the - capability reason; with it verified the call returns empty stdout and never - `allow`; every failure in that path returns the fail-closed deny; the T01 - success, failure, and cancellation sequences each end with no attempt and - `recovery_pending == false`, as does the sequence for - `nix flake check 2>&1 | tail -40; echo "EXIT=${PIPESTATUS[0]}"`; foreground - tests and the `probe17` test pass unchanged. - - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope` - - Context synchronization: pending - -- [ ] T13: `Prove conservative background safety against real Git and the Agent Trace DB` (status:todo) - - Task ID: T13 - - Scope: In — regressions beside the existing Claude cases in - `services::hooks::tests::mutation_provenance_e2e`, driven through - `run_claude_mutation_scope_from_payload_at_state_root`: the A/B/H sequence - from Design, mutation-scope checks for every A interval, B's surviving - direct `diff_trace` and direct session/model/tool provenance, proof that A's - mutation evidence does not supplement B, H's lack of direct evidence, - terminal non-attributing cleanup, the missing/terminal-scope safety-latch - scenario, all-harness boundaries, process restart, and post-cleanup - ordinary attribution. Out — changes to protocol or lineage logic; a - failure here stops the task for a plan revision. - - Dependencies: T12 - - Done when: A, B, and H have `IneligibleUnscoped` mutation-protocol - attribution while A may run; no mutation-AI line or mutation-lineage AI - coverage is emitted for A; B's direct `diff_trace` remains AI-attributed - with its direct provenance and is not supplemented by A; H has no direct - evidence and is not AI-attributed; missing suppressor creates a durable - latch that recovery cannot clear; G5 cleanup clears it; restart preserves - it; and future ordinary foreground attribution is unchanged. - - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml mutation_provenance_e2e` - - Context synchronization: pending - -- [ ] T14: `Record live Claude acceptance evidence for background execution` (status:todo) - - Task ID: T14 - - Scope: In — in a real Claude Code session with the built CLI and current - generated settings, run the backgrounded - `nix flake check 2>&1 | tail -40; echo "EXIT=${PIPESTATUS[0]}"` scenario and - the `sleep 3` / `echo test >> ` scenario; store the captured - payloads, adapter-state snapshots during and after, log events, and - `mutation_trace_*` rows as fixtures with a `NOTES.md` section; update the - background-execution context document to describe the supported behavior. - Out — any source change; a failure is recorded and stops the task. - - Dependencies: T13 - - Done when: both scenarios show no `PreToolUse` deny, a live suppressor while - the command runs, the terminal event ending it through non-attributing - cleanup, `"attempts": []`, and `"recovery_pending": false`; neither - scenario emits `mutation_ai_patch`/AI lineage for the background scope; the - missing-suppressor restart scenario and doctor output are recorded; and the - test edit to the tracked file is reverted. - - Verify: `nix run .#sce -- doctor --format json` reports Claude - mutation-scope health `healthy`; `git status --short` shows only the new - fixtures, `NOTES.md`, and the context document. - - Context synchronization: pending - -## Final safety statement - -While SCE believes a Claude-managed background process may still mutate the -worktree, that execution cannot provide positive mutation-scope attribution. -A managed-background scope never produces `AiExclusive`, -`mutation_ai_patch`, or positive mutation-lineage coverage, including at -termination. Independently established direct tool evidence such as Claude -`Edit`/`Write` `diff_traces` remains authoritative and is unaffected. SCE -cannot return the mutation protocol to ordinary attribution until lifecycle -evidence proves the background process can no longer mutate and guarded -cleanup completes. The important property is: - -```text -background execution cannot create false-positive AI attribution -``` - -while stronger independent direct evidence remains usable. - ## Open questions -None. The questions the earlier draft left open are now decisions or gates: -the first implementation never positively mutation-scope attributes a managed -background; -BG-D2 Case A is unresolved with a live suppressor, Case B is the durable -worktree safety latch, recovery is guarded by the protocol invariant, stale -hook registration is the BG-D5 admission gate, and mid-flight backgrounding is -BG-D8. There is no accepted residual false-positive mutation-scope attribution -case. If T02 -cannot prove the lease-or-latch invariant across all protocol transitions, the -plan stops before background admission and is revised. +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. From f91a43af2502c19c060d92efaf8914b51f5fa210 Mon Sep 17 00:00:00 2001 From: David Abram Date: Fri, 2 Oct 2026 11:13:34 +0200 Subject: [PATCH 4/6] context: Record accepted untracked Claude background Bash decision Restore the documented Claude capability while making the attribution boundary explicit: native background Bash is permitted without SCE tracking, and lifecycle completion remains unproven. Add the superseding accepted ADR and update the Claude mutation-scope context and map to distinguish the decision from the adapter's current denial until T03, while keeping background PowerShell denied. Complete T02 with the accepted A/B contamination tradeoff documented outside SCE's attribution guarantees. Co-authored-by: SCE --- ...ude-mutation-scope-background-execution.md | 52 ++++++++ .../cli/claude-mutation-scope-integration.md | 2 +- context/context-map.md | 5 +- ...-10-02-claude-background-bash-untracked.md | 117 ++++++++++++++++++ .../claude-background-shell-mutation-scope.md | 10 +- 5 files changed, 181 insertions(+), 5 deletions(-) create mode 100644 context/decisions/2026-10-02-claude-background-bash-untracked.md diff --git a/context/cli/claude-mutation-scope-background-execution.md b/context/cli/claude-mutation-scope-background-execution.md index e5199488c..f08f6a26c 100644 --- a/context/cli/claude-mutation-scope-background-execution.md +++ b/context/cli/claude-mutation-scope-background-execution.md @@ -6,6 +6,18 @@ for the repository's per-file line budget. ## Background shell is unsupported +Decision state and implementation state are two distinct facts here: + +- **Decision.** A new product decision permits native Claude background Bash as + an unsupported mutation-attribution boundary. The effective decision is + [Claude background Bash runs natively and untracked](../decisions/2026-10-02-claude-background-bash-untracked.md). +- **Implementation.** The current adapter still denies + `Bash(run_in_background=true)` until T03 of + [`claude-background-shell-mutation-scope`](../plans/claude-background-shell-mutation-scope.md) + implements that decision. + +### Present adapter behavior + An explicit `Bash.run_in_background = true` / `PowerShell.run_in_background = true` is denied in `PreToolUse` (fail-closed shape) with: @@ -19,6 +31,37 @@ 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. +### Accepted behavior the decision commits to + +Not yet implemented. Once T03 lands, Claude-managed +`Bash(run_in_background=true)` is allowed natively and: + +- creates no SCE mutation scope; +- creates no Claude adapter attempt; +- receives no lifecycle tracking; +- may mutate the repository in ways that contaminate the attribution of later + or concurrent tracked scopes. + +Claude lifecycle completion remains unproven, so the decision 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 may observe A's mutation at B's boundary and may attribute it to B +``` + +The decision covers `Bash` only. Background `PowerShell` stays denied, before +and after T03. + ## T01 current-version probe result On 2026-10-01, the installed Claude Code version was `2.1.284`. A T01 probe @@ -39,6 +82,15 @@ supervision, not admission based on the acknowledgement event alone. This constraint is recorded in the [Claude background admission decision](../decisions/2026-10-01-claude-background-admission-requires-proven-lifecycle.md). +That paragraph is the historical T01 technical conclusion and 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. The 2026-10-01 record is retained +unmodified as history; the denial itself remains the adapter's behavior until +T03. + **Self-detaching descendants are a separate, explicit unsupported boundary (D20).** A `run_in_background = false` call can still leave a repository-mutating descendant running after `PostToolUse` returns when the invoked command detaches diff --git a/context/cli/claude-mutation-scope-integration.md b/context/cli/claude-mutation-scope-integration.md index 1c42f1258..07660ad4c 100644 --- a/context/cli/claude-mutation-scope-integration.md +++ b/context/cli/claude-mutation-scope-integration.md @@ -212,7 +212,7 @@ to the seam. ## Background shell is unsupported -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. +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). A new product decision, [Claude background Bash runs natively and untracked](../decisions/2026-10-02-claude-background-bash-untracked.md), permits native Claude background Bash as an unsupported mutation-attribution boundary (no mutation scope, no adapter attempt, no lifecycle tracking, no attribution guarantee); the current adapter still denies `Bash(run_in_background=true)` until T03 of `claude-background-shell-mutation-scope` implements that decision, and background `PowerShell` stays denied. See [Claude mutation-scope background and detached execution boundaries](claude-mutation-scope-background-execution.md) for the full denial text, the accepted A/B attribution consequence, and both boundaries. ## Generated settings diff --git a/context/context-map.md b/context/context-map.md index 4287d2f3f..8200e1998 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`; the `run_in_background = true` denial — the effective decision `context/decisions/2026-10-02-claude-background-bash-untracked.md` permits native Claude background Bash as an unsupported mutation-attribution boundary, but the adapter still denies `Bash(run_in_background=true)` until T03 of the `claude-background-shell-mutation-scope` plan implements it, and 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` (the `run_in_background = true` `PreToolUse` denial as the adapter's present behavior; the product decision — effective record `context/decisions/2026-10-02-claude-background-bash-untracked.md` — that permits native Claude background Bash as an unsupported mutation-attribution boundary with no mutation scope, adapter attempt, lifecycle tracking, or attribution guarantee, including the accepted A/B contamination consequence, while the adapter still denies `Bash(run_in_background=true)` until T03 of the `claude-background-shell-mutation-scope` plan and background `PowerShell` stays denied; 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 adapter still denies background Bash until T03 of the `claude-background-shell-mutation-scope` plan implements it) - `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-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 index 7f489310f..51e143be0 100644 --- a/context/plans/claude-background-shell-mutation-scope.md +++ b/context/plans/claude-background-shell-mutation-scope.md @@ -303,13 +303,19 @@ Persist this field in every plan; this is durable plan state, not chat state: 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. -- [ ] T02: `Record the untracked background Bash product decision and supersede the fail-closed ADR` (status:todo) +- [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. - - Context synchronization: pending + - 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. - [ ] T03: `Allow native background Bash without entering the mutation-scope adapter` (status:todo) - Task ID: T03 From c4e3ce220c588a5670e4325ececcf69b6876301d Mon Sep 17 00:00:00 2001 From: David Abram Date: Fri, 2 Oct 2026 11:57:45 +0200 Subject: [PATCH 5/6] hooks: Allow untracked background Bash executions Ignore background Bash lifecycle hooks before repository or adapter-state access because they have no tracked attempt to attribute. Add regression coverage for recovery barriers, unrelated attempts, and foreground close behavior. Co-authored-by: SCE --- .../hooks/claude_mutation_scope/lifecycle.rs | 9 + .../hooks/claude_mutation_scope/tests.rs | 299 ++++++++++++++++-- ...ude-mutation-scope-background-execution.md | 91 +++--- .../cli/claude-mutation-scope-integration.md | 18 +- context/context-map.md | 6 +- .../claude-background-shell-mutation-scope.md | 10 +- 6 files changed, 351 insertions(+), 82 deletions(-) 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..70fa30ac9 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,10 @@ 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" + ); } #[test] @@ -3240,31 +3357,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 f08f6a26c..691dd4323 100644 --- a/context/cli/claude-mutation-scope-background-execution.md +++ b/context/cli/claude-mutation-scope-background-execution.md @@ -4,45 +4,33 @@ 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 -Decision state and implementation state are two distinct facts here: +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). -- **Decision.** A new product decision permits native Claude background Bash as - an unsupported mutation-attribution boundary. The effective decision is - [Claude background Bash runs natively and untracked](../decisions/2026-10-02-claude-background-bash-untracked.md). -- **Implementation.** The current adapter still denies - `Bash(run_in_background=true)` until T03 of - [`claude-background-shell-mutation-scope`](../plans/claude-background-shell-mutation-scope.md) - implements that decision. +### Background Bash: allowed natively, untracked -### Present adapter behavior +`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: -An explicit `Bash.run_in_background = true` / `PowerShell.run_in_background = -true` is denied in `PreToolUse` (fail-closed shape) 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. - -### Accepted behavior the decision commits to - -Not yet implemented. Once T03 lands, Claude-managed -`Bash(run_in_background=true)` is allowed natively and: - -- creates no SCE mutation scope; -- creates no Claude adapter attempt; +- 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. -Claude lifecycle completion remains unproven, so the decision deliberately +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 @@ -56,11 +44,24 @@ 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 +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. ``` -The decision covers `Bash` only. Background `PowerShell` stays denied, before -and after T03. +Background **subagents** are not excluded — their internal foreground +mutation-capable tool calls still establish their own scopes. ## T01 current-version probe result @@ -75,21 +76,19 @@ 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`; per the plan, the decision gate stops here and the denial remains -in force until a later probe can establish a reliable completion signal and a -proven no-more-mutation boundary. The fallback direction is SCE-owned process -supervision, not admission based on the acknowledgement event alone. This -constraint is recorded in the +`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). -That paragraph is the historical T01 technical conclusion and 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 +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. The 2026-10-01 record is retained -unmodified as history; the denial itself remains the adapter's behavior until -T03. +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 07660ad4c..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). A new product decision, [Claude background Bash runs natively and untracked](../decisions/2026-10-02-claude-background-bash-untracked.md), permits native Claude background Bash as an unsupported mutation-attribution boundary (no mutation scope, no adapter attempt, no lifecycle tracking, no attribution guarantee); the current adapter still denies `Bash(run_in_background=true)` until T03 of `claude-background-shell-mutation-scope` implements that decision, and background `PowerShell` stays denied. See [Claude mutation-scope background and detached execution boundaries](claude-mutation-scope-background-execution.md) for the full denial text, the accepted A/B attribution consequence, 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 8200e1998..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 — the effective decision `context/decisions/2026-10-02-claude-background-bash-untracked.md` permits native Claude background Bash as an unsupported mutation-attribution boundary, but the adapter still denies `Bash(run_in_background=true)` until T03 of the `claude-background-shell-mutation-scope` plan implements it, and 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` (the `run_in_background = true` `PreToolUse` denial as the adapter's present behavior; the product decision — effective record `context/decisions/2026-10-02-claude-background-bash-untracked.md` — that permits native Claude background Bash as an unsupported mutation-attribution boundary with no mutation scope, adapter attempt, lifecycle tracking, or attribution guarantee, including the accepted A/B contamination consequence, while the adapter still denies `Bash(run_in_background=true)` until T03 of the `claude-background-shell-mutation-scope` plan and background `PowerShell` stays denied; 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-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,7 +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 adapter still denies background Bash until T03 of the `claude-background-shell-mutation-scope` plan implements it) +- `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/plans/claude-background-shell-mutation-scope.md b/context/plans/claude-background-shell-mutation-scope.md index 51e143be0..cef6df728 100644 --- a/context/plans/claude-background-shell-mutation-scope.md +++ b/context/plans/claude-background-shell-mutation-scope.md @@ -317,13 +317,19 @@ Persist this field in every plan; this is durable plan state, not chat state: - 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. -- [ ] T03: `Allow native background Bash without entering the mutation-scope adapter` (status:todo) +- [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. - - Context synchronization: pending + - 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`. - [ ] T04: `Lock the unchanged foreground Claude contract` (status:todo) - Task ID: T04 From 256a3f0185e0f854e1623f4d4b4637a0b759ddab Mon Sep 17 00:00:00 2001 From: David Abram Date: Fri, 2 Oct 2026 12:12:20 +0200 Subject: [PATCH 6/6] tests: Lock unchanged foreground Claude mutation lifecycle Pin foreground Bash and PowerShell tracking, mutation-capable tool handling, read-only and delegation exclusions, and self-detaching foreground behavior with focused adapter regressions. This preserves the existing lifecycle and output while covering background-flag edge cases without changing production code. Plan: `claude-background-shell-mutation-scope` (T04) Co-authored-by: SCE --- .../hooks/claude_mutation_scope/tests.rs | 242 ++++++++++++++++++ .../claude-background-shell-mutation-scope.md | 65 ++++- 2 files changed, 296 insertions(+), 11 deletions(-) diff --git a/cli/src/services/hooks/claude_mutation_scope/tests.rs b/cli/src/services/hooks/claude_mutation_scope/tests.rs index 70fa30ac9..4c244d28a 100644 --- a/cli/src/services/hooks/claude_mutation_scope/tests.rs +++ b/cli/src/services/hooks/claude_mutation_scope/tests.rs @@ -1136,6 +1136,248 @@ mod driver { ); } + 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] fn write_ahead_pending_start_persists_before_the_seam_start_call_ac7() { let git_dir = unique_test_git_dir("write-ahead"); diff --git a/context/plans/claude-background-shell-mutation-scope.md b/context/plans/claude-background-shell-mutation-scope.md index cef6df728..7a6993497 100644 --- a/context/plans/claude-background-shell-mutation-scope.md +++ b/context/plans/claude-background-shell-mutation-scope.md @@ -61,7 +61,7 @@ performs final validation. Decision and documentation criteria (AC1-AC3) come first because they must hold before the implementation criteria (AC4-AC9) may be satisfied. -- [ ] AC1: A new Accepted ADR exists that explicitly `Supersedes:` +- [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 @@ -75,7 +75,7 @@ be satisfied. - 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. -- [ ] AC2: The 2026-10-01 fail-closed ADR and the T01 evidence fixture are +- [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 @@ -84,7 +84,7 @@ be satisfied. `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. -- [ ] AC3: The behavior-enabling task did not land before a new Accepted ADR +- [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 @@ -103,7 +103,7 @@ be satisfied. `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. -- [ ] AC4: Claude `Bash` with `run_in_background=true` returns the existing +- [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. @@ -111,16 +111,16 @@ be satisfied. output, zero git-dir resolver calls, zero seam `start` calls, an unchanged adapter-state file, and zero `mutation_trace_scopes` / `mutation_trace_events` rows. -- [ ] AC5: A later `PostToolUse` or `PostToolUseFailure` for that intentionally +- [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. -- [ ] AC6: Background `PowerShell` remains denied. +- [x] AC6: Background `PowerShell` remains denied. - Validate: a focused `PowerShell` background `PreToolUse` regression asserts the existing deny payload and no scope/attempt creation. -- [ ] AC7: The accepted attribution limitation is executable: an untracked +- [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. @@ -128,7 +128,7 @@ be satisfied. 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. -- [ ] AC8: Foreground Claude `Bash` and other existing mutation-capable tools +- [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. @@ -136,7 +136,7 @@ be satisfied. 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. -- [ ] AC9: The change introduces no background lifecycle or persistence design: +- [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, @@ -331,16 +331,59 @@ Persist this field in every plan; this is durable plan state, not chat state: - 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`. -- [ ] T04: `Lock the unchanged foreground Claude contract` (status:todo) +- [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. - - Context synchronization: pending + - 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.