# Proof audit pass 1: pending Claude question withdrawal

Scope: [#22803](https://git.cafe/pingdotgg/t3code/pulls/22803), `fix/issue-15517`, head [b0e0163e80](https://git.cafe/pingdotgg/t3code/commit/b0e0163e805da78f5156ca283cc35f3367a6df9f) against merge-base [e27a03e94d](https://git.cafe/pingdotgg/t3code/commit/e27a03e94d43b504cfc3933282af913763312afd). Source inspection only; no servers, browser, tests, or recordings were run. The matrix below is a capture plan, not completed evidence.

## What the diff actually changes

The only production change is the aborted `AskUserQuestion` branch in [ClaudeAdapterV2.ts](https://git.cafe/pingdotgg/t3code/blob/b0e0163e805da78f5156ca283cc35f3367a6df9f/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts#L8750). After the SDK callback signal aborts, it now emits cancellation for the runtime request, execution node, and turn item, with resolution/completion timestamps, before returning `deny`. Previously it removed the in-memory waiter and returned `deny` without those cancellation events. Normal answers retain the existing allow/result behavior.

The user-visible withdrawal paths that can reach this branch are:

- **Stop:** the pending-question composer exposes “Stop generation”; adapter interruption calls SDK interrupt and close. SDK cleanup aborts outstanding callback controllers.
- **New message / active steer:** another client can deliver a steering message while the question is pending. The adapter offers a priority `now` user message to the live SDK query. If the provider cancels the old question callback, its card must disappear while the live turn continues; a replacement question must remain answerable. Sending a message alone does not itself cancel the callback in T3: provider cancellation is required.
- **Provider transport/process failure:** SDK error cleanup aborts outstanding callback controllers; adapter query-exit handling marks the turn failed. This is supported as a process/transport failure, not as a claim that every provider error or error-result aborts a pending question.
- **Provider-side withdrawal/replacement without terminal turn:** an inbound SDK `control_cancel_request` targets the old question request. This is the precise nonterminal case fixed by the diff, folded into the two new-message rows rather than adding another viewport matrix.

The new [adapter regression test](https://git.cafe/pingdotgg/t3code/blob/b0e0163e805da78f5156ca283cc35f3367a6df9f/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts#L2320) uses `makeWakeHarnessWithOptions`, an injected query runner, `canUseTool`, and an `AbortController`; it asserts original request statuses `pending → cancelled` and successfully answers a replacement. It is not a running web-app fixture.

## Feasible app seed and capture method

Use the **real web app, server, Claude adapter, and installed Claude Agent SDK**, with one proposed external executable fixture named **QStub** at `/tmp/pr-22803-proof/claude-question-stub.cjs`. QStub does not exist yet and was not created in this read-only pass. No suitable ready-made question-withdrawal executable fixture was found in the inspected source. Implement this small NDJSON protocol stub before capture; do not substitute an ACP/Codex mock, a component render, DOM removal, or direct SQL insertion of cancelled records.

Configure an isolated Claude provider instance's **Binary path** to QStub and its home to an isolated fixture directory. [ClaudeSettings.binaryPath](https://git.cafe/pingdotgg/t3code/blob/b0e0163e805da78f5156ca283cc35f3367a6df9f/packages/contracts/src/settings.ts#L652) feeds [pathToClaudeCodeExecutable](https://git.cafe/pingdotgg/t3code/blob/b0e0163e805da78f5156ca283cc35f3367a6df9f/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts#L1350), and the [production query runner](https://git.cafe/pingdotgg/t3code/blob/b0e0163e805da78f5156ca283cc35f3367a6df9f/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts#L880) calls the real SDK. A `.cjs` entry is supported by its script-executable handling. QStub must implement version/probe and SDK initialization/control acknowledgements, including MCP registration if requested, as well as the scenario frames; it cannot be only a script that prints an AskUserQuestion JSON object.

For each row, create a fresh isolated project/thread through normal app/service commands, select that Claude instance, and dispatch the row's initial prompt. QStub emits valid SDK session initialization plus a `control_request` with `request.subtype: "can_use_tool"`, `tool_name: "AskUserQuestion"`, distinct `request_id` and `tool_use_id`, and one question: header “Color”, text “Red or Blue?”, Red/Blue options, `multiSelect: false`. Hold it unanswered. That drives the actual adapter to create the pending request/node/item and the normal client projection. Use fresh IDs per question and row.

The capture route for **every row** is the real thread route **`/<environmentId>/<threadId>`** (substitute the IDs returned by creation; do not invent `/threads/...`). It is defined by [the thread route](https://git.cafe/pingdotgg/t3code/blob/b0e0163e805da78f5156ca283cc35f3367a6df9f/apps/web/src/routes/_chat.$environmentId.$threadId.tsx). Each table entry names the concrete fixture mode and thread label. Use a separate state/home for each before/after checkout; never launch against live userdata or bake an origin into the web bundle.

QStub modes:

- **S (Stop):** first prompt creates Q1 and waits. On SDK interrupt, acknowledge the control request and cancel Q1 via `control_cancel_request`; close cleanly when asked. Do not cancel on a timer. A subsequent fresh prompt can create Q2 and accept Blue, to confirm recovery.
- **I (interrupt/replacement):** first prompt creates Q1. Send a second user message, “Replace the question”, through a second authenticated client/RPC harness: `orchestration.dispatchCommand`, command `message.dispatch`, `dispatchMode: {type: "steer_active", targetRunId: <activeRunId>}`, with the required command/message/thread IDs, text, and empty attachments. The [server transport](https://git.cafe/pingdotgg/t3code/blob/b0e0163e805da78f5156ca283cc35f3367a6df9f/apps/server/src/ws.ts#L1807) uses the real intake/orchestrator; [active steering](https://git.cafe/pingdotgg/t3code/blob/b0e0163e805da78f5156ca283cc35f3367a6df9f/apps/server/src/orchestration-v2/Orchestrator.ts#L4350) reaches [Claude steerTurn](https://git.cafe/pingdotgg/t3code/blob/b0e0163e805da78f5156ca283cc35f3367a6df9f/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts#L9773). When QStub receives that priority message, emit cancellation of Q1, keep the turn alive, and gate Q2 on an explicit harness release after Q1's cancellation has been observed. Then issue Q2 with new IDs and accept Blue through the visible app. This creates a visible empty-question interval without depending on a sleep, then proves replacement independence.
- **F (failure):** first prompt creates Q1. After the visible pending-state capture, release an external fixture gate that makes QStub write a distinctive fixture transport-error message and exit nonzero, without a successful result. The actual SDK cleanup must abort Q1 and the actual server must project failure. The external gate terminates only the fixture's own process, not a process found by pattern. Then send a fresh prompt to a fresh fixture process and answer Q2 with Blue to demonstrate recovery.
- **P (pending):** first prompt creates Q1 and waits indefinitely for an answer; do not release a gate, cancel, stop, or fail it during the still.

The installed SDK's `sdk.mjs` was inspected locally: `handleControlCancelRequest` aborts the matching callback controller, and `performCleanup` aborts all outstanding controllers. Thus the proposed executable fixture reaches the changed callback through the production SDK rather than manufacturing T3 cancellation events.

## Capture matrix — 9 rows, 8 recordings + 1 static still

Each recording row requires a matched **Before / After** pair with identical seed, dimensions, and theme. Width 390 means responsive web, not native React Native. Heights below are proposed consistent capture sizes. All state transitions, answer submission, and recovery remain in recordings, never represented only by stills.

| Named row | Viewport / theme | Evidence | Real-app seed and route | Trigger and required visible sequence |
|---|---|---|---|---|
| Stop desktop light | 1440×900 / light | **RECORDING** | QStub **S**, fresh thread `stop-1440-light`; `/<environmentId>/<threadId>` | Show Q1 and options → click **Stop generation** → Q1 ceases to be actionable and stopping settles → fresh prompt raises Q2; answer Blue successfully. |
| Stop desktop dark | 1440×900 / dark | **RECORDING** | QStub **S**, fresh thread `stop-1440-dark`; `/<environmentId>/<threadId>` | Show Q1 → click Stop → withdrawal and settled run → fresh Q2 accepts Blue. Include composer and adjacent timeline so a stale card is visible. |
| Stop narrow light | 390×844 / light | **RECORDING** | QStub **S**, fresh thread `stop-390-light`; `/<environmentId>/<threadId>` | Show Q1 and reachable Stop control at narrow width → Stop → no stale question controls → fresh Q2 accepts Blue; capture any panel/composer movement. |
| Stop narrow dark | 390×844 / dark | **RECORDING** | QStub **S**, fresh thread `stop-390-dark`; `/<environmentId>/<threadId>` | Same narrow Stop sequence, including question removal, settled run, and fresh Q2 answer. |
| New-message replacement desktop light | 1440×900 / light | **RECORDING** | QStub **I**, fresh thread `interrupt-1440-light`; `/<environmentId>/<threadId>`; second authenticated RPC client targets that same thread/run | Show Q1 → send “Replace the question” as an active steer → provider cancels Q1 while turn stays live → record Q1 gone before releasing Q2 → Q2 appears → visibly choose/submit Blue; only Q2 resolves. Keep the new user message visible when possible. |
| New-message replacement narrow dark | 390×844 / dark | **RECORDING** | QStub **I**, fresh thread `interrupt-390-dark`; `/<environmentId>/<threadId>`; same second-client steer harness | Same nonterminal Q1 cancellation → empty-question interval → new Q2 → successful Blue answer, at narrow width. A stale Q1 must not occupy the question queue ahead of Q2. |
| Provider failure desktop dark | 1440×900 / dark | **RECORDING** | QStub **F**, fresh thread `failure-1440-dark`; `/<environmentId>/<threadId>`; external failure gate for that fixture process | Show pending Q1 → release nonzero-exit gate → question loses actionable controls and failure is visible; no endless waiting → fresh prompt/Q2 accepts Blue. Retain the fixture error and server event trace beside the clip. |
| Provider failure narrow light | 390×844 / light | **RECORDING** | QStub **F**, fresh thread `failure-390-light`; `/<environmentId>/<threadId>`; same failure gate | Same pending → transport failure → withdrawn question/visible failure → recovery sequence at narrow width, with no stale composer question. |
| Pending question baseline | 1440×900 / light | **STATIC STILL** | QStub **P**, fresh thread `pending-baseline`; `/<environmentId>/<threadId>` | Untouched pending Q1: legible Color header, question, Red/Blue options, answer action, and Stop control. This is a layout/control baseline only; do not interact during this row. |

For each recording, preserve a correlated server/fixture trace with Q1/Q2 native IDs, runtime request IDs, callback deny/answer result, and request/node/item statuses. The after nonterminal sequence must show Q1 cancelled with timestamps, Q2 still pending until Blue, then Q2 resolved. A recording proves the visible transition; the trace verifies it was this callback path. Allow a cancelled historical timeline entry to remain; the requirement is that the old question is no longer pending/actionable, not that history is deleted.

## FRICTION

- **Harness prerequisite:** QStub and its control gates are specified above but not implemented or executed. The new test's `makeWakeHarnessWithOptions` is scoped inside a test file and has no app route or launch switch; it cannot simply be selected as an installed provider. An executable protocol fixture through Binary path is the feasible app method, with SDK handshake work still required. No row is claimed captured or passing.
- **New-message interaction constraint:** the local pending-question composer routes typing into a custom answer, and live SDK questions are not dismissible. See [composer input handling](https://git.cafe/pingdotgg/t3code/blob/b0e0163e805da78f5156ca283cc35f3367a6df9f/apps/web/src/components/chat/ChatComposer.tsx#L3030) and [request derivation](https://git.cafe/pingdotgg/t3code/blob/b0e0163e805da78f5156ca283cc35f3367a6df9f/packages/client-runtime/src/state/threadRequests.ts#L89). Do not label typing “Replace the question” into that field as a new-message interrupt. The two I rows explicitly use the supported second-client/RPC path and provider cancellation, not an unverified local button flow.
- **Terminal cleanup can mask the regression:** [ProviderEventIngestor](https://git.cafe/pingdotgg/t3code/blob/b0e0163e805da78f5156ca283cc35f3367a6df9f/apps/server/src/orchestration-v2/ProviderEventIngestor.ts#L584) already dismisses native questions on terminal provider updates and `turn.terminal`. Before Stop/failure clips may therefore look correct. Those six recordings verify recovery and theme/viewport behavior; they are not independently discriminating proof of this fix. Do not force a visual before/after difference that does not occur. The two nonterminal replacement rows and the added adapter test are the specific regression proof.
- **Failure scope:** this plan uses a real stub-process transport failure. A fabricated `is_error` result or an error notice without aborting Q1 does not establish the changed branch. Cancellation versus terminal-cleanup ordering must be reported from events rather than inferred from the final screenshot.
- **Surface scope:** this diff changes no client layout, contract, or provider other than Claude. The nine rows intentionally cover the requested responsive web matrix; desktop shares the web client, and native mobile uses the same server cancellation data but is not visually exercised by a 390px web viewport. No extra settings/palette/native/provider matrix is warranted for this two-file adapter diff.
- **Read-only compliance:** only source and existing local reference fixtures were read. No worktree files changed, no forge comments/reviews were posted, and no server or browser was launched. The only requested deliverable is this temporary matrix and its public ghdrop upload.
