# Proof matrix, pass 1 (server-only): [#22803](https://git.cafe/pingdotgg/t3code/pulls/22803)

Read-only source audit. No servers, tests, or forge writes were run. This is a capture plan, not evidence.

## Revisions

| Role | SHA | Note |
|---|---|---|
| BEFORE | [8d3b061121](https://git.cafe/pingdotgg/t3code/commit/8d3b061121fe65669d5d43d011b48cd3027a4108) | `git merge-base e7f57a52be origin/main` = current `origin/main` tip. **Not** 2532e64099: the brief's guess is out of date. |
| AFTER | [e7f57a52be](https://git.cafe/pingdotgg/t3code/commit/e7f57a52be493928cfd0b79983a772b7c53011ef) | merge of origin/main into [b0e0163e80](https://git.cafe/pingdotgg/t3code/commit/b0e0163e805da78f5156ca283cc35f3367a6df9f) |

`git diff 8d3b061121 e7f57a52be --stat` shows only `ClaudeAdapterV2.ts` (+35/−13) and `ClaudeAdapterV2.test.ts` (+89). That is identical to the PR's own diff `e27a03e94d..b0e0163e80`. The two revisions differ by the PR diff alone.

## What changes and what doesn't (sets the before/after expectations)

- **PR change** (`ClaudeAdapterV2.ts`, AskUserQuestion branch of `canUseTool`, `if (callbackOptions.signal.aborted)` at ~L8916 in AFTER): when the SDK aborts the callback's signal, the adapter now emits `runtime_request.updated` / `node.updated` / `turn_item.updated` with `status: "cancelled"` before returning `deny`. BEFORE, it only removed the in-memory waiter (`pendingRuntimeRequests`) and returned `deny`. The projection request stayed `pending`.
- **Existing terminal cleanup that hides the difference:** `ProviderEventIngestor.dismissNativeUserInputs` (`apps/server/src/orchestration-v2/ProviderEventIngestor.ts:348-391`) cancels every `kind='user_input' AND status='pending'` request, node, and turn item of a provider turn. It runs on `turn.terminal` (L685) and on `provider_turn.updated` with status completed/interrupted/failed/cancelled (L583-592). It is covered by `ProviderEventIngestor.test.ts:966` "dismisses only native questions when a provider turn ends with %s". **So any row where the turn ends (A1, A2, A3) ends with the same projection BEFORE and AFTER.** Only event attribution and ordering can differ, and that ordering is racy (see A1). These rows show no regression; they are not proof of the fix.
- **When the SDK aborts the callback signal** (SDK 0.3.276, `apps/server/node_modules/@anthropic-ai/claude-agent-sdk/sdk.mjs`):
  1. inbound `{"type":"control_cancel_request","request_id":X}` → `handleControlCancelRequest` aborts that one controller. The query stays alive and the SDK still writes the callback's `deny` back as a `control_response`.
  2. `performCleanup` (on `query.close()` or a transport/read error) aborts **all** `cancelControllers`. `cleanupPerformed` then suppresses the response write.
  - `interrupt()` alone does **not** abort. In `ClaudeAdapterV2.interruptTurn` (~L9758) the adapter calls `query.interrupt` (an SDK `control_request{subtype:"interrupt"}` that **must** be answered: the SDK sets no deadline) and then `query.close`. So A1/A2 reach the PR branch through path 2.
- **Discriminating case = A4:** a provider `control_cancel_request` while the turn stays alive. BEFORE, the withdrawn question stays `pending` in projection and on the client stream until the turn ends. AFTER, it is `cancelled` immediately. The difference must be sampled **at a checkpoint before the turn ends**, because turn completion triggers `dismissNativeUserInputs` and cancels it in BEFORE too.

## Persistence and stream names (found in source)

- DB: `<T3_HOME>/userdata/statev2.sqlite` (`apps/server/src/config.ts:120-124`). Settings: `<T3_HOME>/userdata/settings.json`.
- Event log table: `orchestration_v2_events(sequence, event_id, command_id, thread_id, run_id, node_id, provider, raw_event_id, event_type, occurred_at, payload_json)` (`persistence/Migrations/055_OrchestrationV2.ts:20`).
- Projections: `orchestration_v2_projection_runtime_requests` (cols include `thread_id, provider_turn_id, kind, status`), `orchestration_v2_projection_nodes`, `orchestration_v2_projection_turn_items` (`type='user_input_request'`), `orchestration_v2_projection_runs`. Receipts: `orchestration_v2_command_receipts`.
- Domain event types (persisted `event_type`, kebab-case): `runtime-request.updated`, `node.updated`, `turn-item.updated`, `provider-turn.updated`, `run.updated`. Adapter-side provider event types are snake_case (`runtime_request.updated`, `turn_item.updated`); the ingestor maps them.
- RuntimeRequest status literals: `pending | resolved | expired | cancelled` (`packages/contracts/src/orchestrationV2.ts:1021`).
- Client stream: WS RPC `orchestration.subscribeThread({threadId})` → `OrchestrationV2ThreadStreamItem` = `{kind:"snapshot", snapshotSequence, projection}` | `{kind:"event", sequence, event}` | `{kind:"synchronized"}` (`orchestrationV2.ts:3319`). Point reads: `orchestration.getThreadProjection({threadId})`.
- Commands go over WS RPC `orchestration.dispatchCommand` (union `OrchestrationV2Command`, `orchestrationV2.ts:2571+`):
  - interrupt: `{type:"run.interrupt", commandId, threadId, runId, reason?, holdQueue?}`
  - stop: `{type:"thread.stop", commandId, threadId, reason?}`
  - answer: `{type:"runtime-request.respond", commandId, threadId, requestId, answers}`
  - message/steer: `{type:"message.dispatch", …, dispatchMode:{type:"steer_active", targetRunId}}` (also `restart_active`, `queue_after_active`, `start_immediately`)
  - dismiss UI-side: `{type:"thread.user-input.dismiss", …}` (not used)
- Launch: WS RPC `orchestration.launchThread({commandId, threadId, projectId, title, modelSelection:{instanceId:"claudeAgent", model}, runtimeMode, interactionMode, workspaceStrategy:{type:"root"}, initialMessage:{text, attachments:[]}})`. Project: HTTP `POST /api/projects/mutate {type:"project.create", commandId, projectId, title, workspaceRoot}`.

## Server and auth (copy the existing live harness)

[`apps/server/scripts/verify-background-live.ts`](https://git.cafe/pingdotgg/t3code/blob/8d3b061121fe65669d5d43d011b48cd3027a4108/apps/server/scripts/verify-background-live.ts) already does everything needed (L200-300):

1. `node apps/server/src/bin.ts start --bootstrap-fd 3 --log-level debug`, cwd = the revision's worktree, with JSON on fd 3: `{mode:"desktop", noBrowser:true, port, t3Home, host:"127.0.0.1", desktopBootstrapToken, tailscaleServeEnabled:false, tailscaleServePort:443}`. Ready when the log shows `startup phase: complete`. Capture the PID at spawn and kill only that PID.
2. `POST /oauth/token` token-exchange (`subject_token_type=urn:t3:params:oauth:token-type:environment-bootstrap`) → `access_token`.
3. `POST /api/auth/websocket-ticket` → `ws://…/ws?orchestrationProtocol=<ORCHESTRATION_PROTOCOL_VERSION>&wsTicket=…`, then `RpcClient.make(WsRpcGroup)`.

No `pair` command and no ~/.t3 involvement. Homes: `/tmp/ev-22803/{before,after}/home`, each seeded fresh before every row (or once per revision, with one thread per row). Do not use `migrate-dev-db`: the state here is synthetic and identical.

Seed `<home>/userdata/settings.json` the way the removed `usage-limit-evidence.ts` ([9fd419a6e9](https://git.cafe/pingdotgg/t3code/commit/9fd419a6e9)) did:
```json
{"providerInstances":{"claudeAgent":{"driver":"claudeAgent","enabled":true,
  "config":{"binaryPath":"<REPO>/apps/server/src/provider/testFixtures/claudeQuestionMockCli.mjs",
            "homePath":"<home>/claude-home"}}}}
```
`binaryPath` → `pathToClaudeCodeExecutable` (`ClaudeAdapterV2.ts:1388`). A `.mjs` path is in `CLAUDE_SCRIPT_EXTENSIONS` (L302), so the SDK runs it via node and the agent-scope wrapper is skipped. The provider health probe (`provider/ClaudeProvider.ts:518`) execs the path directly with `--version`. The file therefore needs a `#!/usr/bin/env node` shebang and git mode 100755.

## Fixture decision

Real `claude` exists (`/usr/local/nvm/versions/node/v24.21.0/bin/claude`) and the hub would serve it. It is **not viable as the proof fixture**. A4 needs a CLI-originated `control_cancel_request` mid-turn, which a real model cannot be made to produce. C is timing-sensitive. Model compliance with "call AskUserQuestion only" is nondeterministic. At most it can be an optional corroboration run for B and A1, labelled as such. It does not replace any row.

There is no existing CLI-wire fake for Claude. The `testkit/fixtures/*/claude_transcript.ndjson` replays are SDK-level (`protocol:"claude-agent-sdk.query"`, fed through a fake query object), none of them contains a Claude AskUserQuestion, and they cannot drive a real server process. The repo convention for mock provider binaries is `apps/server/src/provider/testFixtures/` (`codexCollabMockPeer.mjs`, `grok-text-mock-agent.mjs`).

**Fixture: `claudeQuestionMockCli.mjs`** (proposed path `apps/server/src/provider/testFixtures/claudeQuestionMockCli.mjs`, 100755). It is an NDJSON stub speaking SDK stream-json on stdin/stdout:

- `--version` → print `2.1.0 (Claude Code)`, exit 0.
- Any SDK→CLI `{"type":"control_request","request_id":R,"request":{subtype,…}}` → reply `{"type":"control_response","response":{"subtype":"success","request_id":R,"response":P}}`. P depends on the subtype:
  - `initialize` → `{commands:[],agents:[],models:[],output_style:"default",available_output_styles:["default"],account:{email:"fixture@example.invalid",subscriptionType:"max",tokenSource:"fixture"}}`
  - `mcp_set_servers` → `{added:[],removed:[],errors:{}}`
  - `interrupt` → `{}`, recorded. In A1/A2 do **not** emit a result: the adapter closes the query right after, and the abort must come from the SDK cleanup.
  - everything else (`set_model`, `apply_flag_settings`, `set_permission_mode`, …) → `{}`.
  The stub must answer every request, because the SDK never times them out.
- On the first `{"type":"user",…}` line: parse `ROW:<id>` and `TRACE:<dir>` from the text content. This keeps one script and one binary for both revisions and does not depend on env plumbing. Then emit:
  1. `{"type":"system","subtype":"init","session_id":S,"cwd":…,"tools":["AskUserQuestion"],"model":"claude-sonnet-4-6","permissionMode":…,"mcp_servers":[],"slash_commands":[],"uuid":…}`
  2. optional `{"type":"command_lifecycle","command_uuid":<user msg uuid>,"state":"started"}` (sets `promptEchoMode="acknowledged"`; without it the adapter still routes frames because the mode is not `early`)
  3. `{"type":"assistant","parent_tool_use_id":null,"session_id":S,"uuid":…,"message":{"id":"msg_q1","role":"assistant","model":"claude-sonnet-4-6","content":[{"type":"tool_use","id":"toolu_q1","name":"AskUserQuestion","input":{"questions":Q}}],"stop_reason":"tool_use","usage":{…}}}`
  4. `{"type":"control_request","request_id":"cu_q1","request":{"subtype":"can_use_tool","tool_name":"AskUserQuestion","input":{"questions":Q},"tool_use_id":"toolu_q1","permission_suggestions":[]}}`
  `Q = [{"header":"Color","question":"Red or Blue?","options":[{"label":"Red","description":"Choose red."},{"label":"Blue","description":"Choose blue."}],"multiSelect":false}]`. The adapter's `nativeRequestId` = `tool_use_id` (`ClaudeAdapterV2.ts:8821`). Each question needs its own `tool_use_id` and its own `request_id`, because the SDK skips a duplicate in-flight `request_id`.
- Row-specific continuation (below). Turn end = `{"type":"user","parent_tool_use_id":null,"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":…,"content":…}]}}` + assistant text + `{"type":"result","subtype":"success","is_error":false,"num_turns":1,"result":"…","session_id":S,"stop_reason":"end_turn","usage":{…},"total_cost_usd":0,"duration_ms":1,"duration_api_ms":1}`.
- **Trace:** `<TRACE>/<ROW>-<pid>.ndjson` gets every stdin line in and stdout line out as `{t:hrtime_ms, dir:"in"|"out", frame}`, plus `{event:"exit", code}`. The driver joins trace `tool_use_id`/`request_id` ↔ projection `runtimeRequest.nativeRequestRef.nativeId` (= `toolu_*`) ↔ `orchestration_v2_events.payload_json`. This closes the earlier audit's "correlate fixture traces per row" gap.

## Driver

Proposed path: **`apps/server/scripts/claude-question-withdrawal-evidence.ts`**. It follows `verify-background-live.ts` (live verifier) and the removed `usage-limit-evidence.ts` convention. Invocation: `node apps/server/scripts/claude-question-withdrawal-evidence.ts --worktree <before|after tree> --home /tmp/ev-22803/<rev>/home --out /tmp/ev-22803/<rev> --row A1|A2|A3|A4|B|C|all`. The same script file runs against both trees. Run it from the AFTER tree's copy, pointing `--worktree` at each tree so only the server code differs. The fixture path also comes from the AFTER tree.

Per row: create project (workspaceRoot = a fresh temp git dir) → `subscribeThread` (record every item to `<out>/<row>.stream.ndjson`) → `launchThread` with `initialMessage.text = "ROW:<id> TRACE:<out>/traces …"`, `runtimeMode:"full-access"`, model `claude-sonnet-4-6` → wait for the stream to show `runtime-request.updated` with `kind:"user_input", status:"pending"` → row action → wait for the row's terminal checkpoint → dump `getThreadProjection` and run the SQL below into `<out>/<row>.db.json` → stop the server by its PID.

SQL per row:
```sql
SELECT sequence, event_type, provider, raw_event_id, json_extract(payload_json,'$.id') id,
       json_extract(payload_json,'$.status') status, json_extract(payload_json,'$.kind') kind,
       json_extract(payload_json,'$.type') item_type, occurred_at
FROM orchestration_v2_events
WHERE thread_id=? AND event_type IN ('runtime-request.updated','node.updated','turn-item.updated','provider-turn.updated','run.updated')
ORDER BY sequence;
SELECT id, kind, status, resolved_at FROM orchestration_v2_projection_runtime_requests WHERE thread_id=?;
```

## Rows

"Stream" = `orchestration.subscribeThread` items. "Q1" = request whose `nativeRequestRef` = `toolu_q1`.

| id | trigger | fixture mode (stub behaviour after can_use_tool `cu_q1`) | RPC path | persisted events: BEFORE | persisted events: AFTER | client stream: BEFORE | client stream: AFTER | discriminating |
|---|---|---|---|---|---|---|---|---|
| **A1** | question pending → turn interrupt | `interrupt`: reply `{}`, emit nothing; stay alive until stdin EOF/SIGTERM, then exit 0 | `dispatchCommand{run.interrupt, runId=<active run from projection>}` | Q1 `runtime-request.updated` pending → then, in the same ingest batch as the terminal (`provider-turn.updated` interrupted / `turn.terminal`), Q1 `runtime-request.updated` cancelled + `node.updated` cancelled + `turn-item.updated` cancelled (from `dismissNativeUserInputs`); run `interrupted`. Final Q1 = cancelled. | Same final state. Additionally the adapter's own cancelled trio (provider-attributed raw event) may land **before** the terminal (then the ingestor finds nothing pending, so no second trio), or **after** it (then a duplicate cancelled `runtime-request.updated` with a later `resolvedAt`). Ordering is a race between `performCleanup` abort and `finalizeActiveTurnAfterQueryExit`. | `event` items mirror DB; final snapshot has Q1 cancelled, run interrupted | same final; may contain one extra Q1 cancelled event | **no**: terminal cleanup makes BEFORE look identical. Report as no-regression. Assert: exactly one Q1 lifecycle ending in cancelled, no `resolved`, run interrupted, trace shows `interrupt` request + response and no `control_response` for `cu_q1` (cleanup suppresses it). |
| **A2** | question pending → stop | same as A1 | `dispatchCommand{thread.stop}` | as A1; run `interrupted` (confirm in capture), queue held | as A1 | as A1 | as A1 | **no** (same reason) |
| **A3** | question pending → provider process failure | on a second trigger (the driver calls `GET`/touches `<TRACE>/<ROW>.die`; stub polls it, or simply a 1.5 s delay after `cu_q1`): write `fatal: fixture transport failure` to stderr, `process.exit(1)` | none (provider-originated) | SDK read error → `performCleanup(err)` → abort. Adapter query exit → turn `failed` → `turn.terminal` failed → `dismissNativeUserInputs`: Q1 cancelled trio; run `failed` with one expected error item. | same final; adapter trio may precede or duplicate, as A1 | final snapshot Q1 cancelled, run failed | same | **no**. Prefer the file-trigger over a delay so pending is observed on the stream first. |
| **A4** | provider withdraws the question, turn stays alive (`control_cancel_request`) | after the driver confirms Q1 pending: stub emits `{"type":"control_cancel_request","request_id":"cu_q1"}` (trigger: touch `<TRACE>/<ROW>.cancel`); waits for the SDK's `control_response` for `cu_q1` (`behavior:"deny"`, message "User cancelled tool execution."); then emits replacement assistant `tool_use toolu_q2` + `can_use_tool cu_q2`; on allow → tool_result + text + `result success` | checkpoint **K** = stream shows Q2 pending. Then `dispatchCommand{runtime-request.respond, requestId=Q2, answers:{"Red or Blue?":"Blue"}}` | **At K:** Q1 still `pending` (no cancelled event for Q1 with sequence < Q2's pending event); `projection_runtime_requests` has **2** pending `user_input` rows. After answer and turn completion: Q2 `resolved`; then `provider-turn.updated` completed triggers `dismissNativeUserInputs`, which cancels Q1 only now. | **At K:** Q1 cancelled trio (provider-attributed) with sequence **<** Q2 pending; exactly **1** pending `user_input` (Q2). After completion: Q2 resolved, run completed, no second Q1 cancel. | At K the snapshot/events show two pending questions (the stale card stays answerable in the UI) | At K only Q2 pending; event Q1 `cancelled` arrives before Q2 | **YES (decisive)**. Assert at K, not at the end. Trace: `cu_q1` deny response present in both revisions (the SDK writes it), so the fixture behaves identically. Only server output differs. |
| **B** | question answered normally | on allow for `cu_q1`: tool_result + text + `result success`; record the allow body | `runtime-request.respond{requestId=Q1, answers:{"Red or Blue?":"Blue"}}` | Q1 pending → `resolved` (with answers); `provider-turn.updated` completed; dismiss finds nothing pending; run completed. **No cancelled event.** | identical | identical | identical | no (unchanged-path guard). Assert trace allow = `{behavior:"allow", updatedInput:{questions:Q, answers:{"Red or Blue?":"Blue"}}, toolUseID:"toolu_q1"}`. |
| **C** | abort after the answer was already sent | as B, but **after receiving** the SDK's allow `control_response` for `cu_q1`, emit `{"type":"control_cancel_request","request_id":"cu_q1"}` (stale), then tool_result + text + `result success` | as B | as B; the stale cancel is a no-op in the SDK (controller already deleted in `finally`); no cancelled event | identical. The PR branch is unreachable: the callback already returned allow. | identical | identical | no (no spurious cancellation). Assert: zero `status:"cancelled"` events for Q1 in both, Q1 `resolved`. |

Optional A1′ (steer, not required): `message.dispatch{dispatchMode:{type:"steer_active",targetRunId}}` while Q1 is pending, with the stub doing nothing. Steering alone does not abort the callback, so Q1 stays pending in **both** revisions. Only useful to show steering is not a withdrawal trigger; A4 already covers provider-side replacement.

## Scoped tests to run alongside (both revisions)

- `vp test run apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts -t "withdraws an aborted AskUserQuestion"`: exists only in AFTER (L~2320). In BEFORE, run the AFTER test file against BEFORE source to show it fails: the `["pending","cancelled"]` assertion gets `["pending"]`.
- `vp test run apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts -t "dismisses only native questions"`: documents why A1–A3 converge.

## Risks the capture must check

- If the claudeAgent provider status gates launch on probe auth, the stub's initialize `account` must satisfy it. Confirm with the first `launchThread`.
- `--version` probe exec of the `.mjs`: needs the exec bit; if the probe fails the instance may show error status.
- Every row must show the stream observed Q pending before the action. Otherwise A1–A3 could pass vacuously.
