# PR #22836 proof matrix (pass 1, read-only audit)

- Head `6c15210198de997acd26c2cee02a749ddcce57f6`; merge-base `8d3b061121fe65669d5d43d011b48cd3027a4108`. Current GitCafe title: "fix(server): fork Claude threads after project root changes". The "…and Claude resume…" wording in the brief is stale.
- Production delta: one hunk in `ClaudeAdapterV2.ts` `forkThread` (~L10397). `forkOptions` drops `dir: runtimePolicy.cwd` and keeps only `upToMessageId`. Nothing else in production changes.
- Downstream effects with no diff: `forkOptions` also flows into `getSessionMessages(forked.sessionId, options)` for the fork head cursor. That happens in-process (`layerQueryRunner.forkSession`, L1186-1191) and in `claudeHistoryWorker.ts` L28. Both now search every project dir in the Claude home.
- Not changed: resume (`makeClaudeQueryOptions` `resume`/`resumeSessionAt`, L9283; it never passed `dir`) and `subagentLaunchToolUseId` (still `dir: runtimePolicy.cwd`, L5047, residual and deferred to PR #17596 per the issue). "Resume" is only exercised indirectly, as the fork's first turn resuming the forked session id at the new cwd.
- Replay matcher: `makeClaudeSessionForkFrame` (testkit L470) emits `dir` iff `options.dir !== undefined`. Fixtures therefore pin the exact before/after outbound shape: head fixtures fail at merge-base code and vice versa.

## Matrix

| id | scenario | branch / code path | named fixture / test | before (merge-base) | after (head) | how a running server reaches it |
|---|---|---|---|---|---|---|
| R1 | **Core bug.** Fork after the project workspaceRoot moved (`/work/app` → `/work/app/platform`). Source transcript is under the old slug. Host default Claude home. | `forkThread` → `layerQueryRunner.forkSession`, `usesHostConfig=true`, in-process `forkClaudeSession` + `getSessionMessages` | **No direct test.** The rewritten adapter test uses a custom `CLAUDE_CONFIG_DIR`, which takes the worker path (R2). Gap. | SDK throws `Session <id> not found in project directory for /work/app/platform`. Error chain: `ClaudeAgentSdkQueryRunnerError(forkSession)` → `ProviderAdapterForkThreadError`. Fork thread's first turn fails with "Provider turn failed to start". Fork thread is persisted with no native provider thread. | Fork succeeds. New session jsonl is written under the **old** slug dir. `nativeThreadRef.nativeId` = new id. `forkHeadMessageId` is set. First fork turn resumes the forked id with `cwd` = new root and completes. | `project.create` (or MCP `t3_project_create`) at A → `thread.create` + Claude turn → `project.update` / `t3_project_update{workspaceRoot:B}` (emits `project.meta-updated`) → `thread.fork` / `t3_thread_fork` from a response → send a message in the fork (`t3_thread_send`). |
| R2 | Same as R1 with a custom Claude home (`CLAUDE_CONFIG_DIR` ≠ host). | `forkClaudeSessionInWorker` → `claudeHistoryWorker.ts` (`forkSession` + `getSessionMessages` with passed options) | `ClaudeAdapterV2.test.ts` › "ClaudeAdapterV2 native fork" › **"forks a session from its original directory after the workspace root changes"**. Uses the real SDK, temp `configDir/projects/-old-workspace/<id>.jsonl`, cwd `/workspace`. | Real SDK rejects: session not found under `-workspace`. Test fails. | Fork jsonl is created in `-old-workspace` and contains "inherited answer". `forkCalls[0].options` = `{upToMessageId}` only. The next `open` has `resume = forkedId`, `cwd = /workspace`. | Same as R1, with the provider instance configured with a custom home path. |
| R3 | Control: fork with cwd unchanged since the source turn | same as R1/R2 | `ThreadFork.integration.test.ts` › "creates an idle app fork and resolves it with Claude native session fork on first dispatch" (`thread_fork_native`); › "rolls back a Claude native fork to an earlier fork-local turn" (`thread_fork_native_fork_local_rollback`) | Works. Outbound frame contains `dir:/tmp/claude-replay-<scenario>`. | Works. Outbound frame has no `dir`. Persisted state and fork lineage are otherwise identical. | `thread.fork` without a project update. |
| R4 | Fork at a prior turn (earlier `upToMessageId`) | `resolveClaudeForkUpToMessageId` returns a non-synthetic boundary id | "keeps a Claude native fork stable when the source thread rolls back" (`thread_fork_native_prior_turn_source_rollback`); the `thread_fork_native_prior_turn` claude fixture is only referenced by the recorder (the test at L392 is the codex variant) | `{dir, upToMessageId}` | `{upToMessageId}`. Truncation is unchanged. | `t3_thread_fork` with `sourcePoint:{type:"run",runId:<earlier>}`. |
| R5 | `upToMessageId === undefined` (no `providerTurnId` / `sourceProviderTurns`, i.e. a whole-thread fork) | `forkOptions = {}` | No dedicated test or fixture | `{dir}`: full copy restricted to the cwd slug | `{}`: full copy, searches all slugs | `t3_thread_fork` with `sourcePoint:{type:"latest_stable"}` on a path that has no provider turn id (verify which callers omit it). |
| R6 | `runtimePolicy.cwd === null` | Before: the `dir` key was already omitted. After: same. | none | `{upToMessageId}` (SDK searched all projects already) | identical. **No behavior change.** | Thread with no workspace cwd (scratch / null cwd). |
| R7 | Sibling forks, then merge-back into the source | Two `forkThread` calls on the same source; merge-back does not call `forkSession` itself | `ThreadMergeBack.integration.test.ts` › "merges two sibling fork deltas into the original $driver thread" (claude variant, `thread_merge_back_siblings`, `session.fork:1/2`) | Works when cwd is unchanged. With a moved root, both forks fail (R1), so there is nothing to merge back. | Works. Lineage `forkedFrom` and merge-back deltas are unchanged. | `t3_thread_fork` ×2 → turns in each → `t3_thread_merge_back{sourceThreadId,targetThreadId,sourcePoint}`. Add `t3_project_update` before the forks for the moved-root variant. |
| R8 | Fixtures changed but **not consumed by any test**: `thread_fork_native_continue`, `thread_fork_native_siblings` (claude) | recorder-only (`scripts/record-claude-agent-sdk-replay-fixture.ts` L298/308) | — | n/a | n/a. These are consistency-only edits. | — |
| R9 | **Risk:** the same session id appears in two project slugs (e.g. after the issue's symlink workaround, or across worktrees) | SDK unrestricted search picks the first match | none | `dir` disambiguated (but failed after a move) | Ambiguous. Likely benign, since ids are UUIDs and a symlink points to the same file. Worth one probe: the symlink workaround present + fork. | Manual: create the symlink, then fork. |
| R10 | Residual: subagent launch lookup after a root move | `subagentLaunchToolUseId` `dir: cwd` | `claude_subagent_resume_after_restart` (unaffected) | still cwd-pinned | **unchanged.** It is still broken after a move; this is out of scope by the issue's own statement. | Restart recovery of a running subagent after `t3_project_update`. |
| R11 | Parent continue after a root move (issue step 3) | resume path, untouched | none specific | works | works (control) | `t3_thread_send` on the source after `t3_project_update`. |

## Drivers

- **Real Claude turns are feasible.** `claude` is 2.1.303. The hub is at `ANTHROPIC_BASE_URL=https://nucbox-1.tail131df4.ts.net:8318`. The PR body says R1/R11/R3 were already run live with MCP calls (linked runtime transcript). Live runs cost hub quota and are nondeterministic, so they aren't needed for a differential proof.
- **Cheapest faithful driver (recommended):** a vitest using the real `layerQueryRunner` / real SDK `forkSession` against an isolated temp Claude home holding a hand-written transcript under `projects/-old-workspace`. This is exactly the head test for R2. Run the **head test file** against **merge-base production code**: copy `ClaudeAdapterV2.test.ts` from head into a merge-base worktree. Expect a failure with `not found in project directory`. Then run it at head and expect a pass. Command: `vp test run apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts -t "original directory"`.
- **Close the R1 gap:** add a variant where `CLAUDE_CONFIG_DIR` in both the adapter environment **and** `HostProcess.Environment` points to the temp home. That gives `usesHostConfig=true`, which exercises the in-process `forkClaudeSession` + `getSessionMessages` branch. No `~/.claude` writes.
- Replay rows R3/R4/R7: `vp test run apps/server/src/orchestration-v2/testkit/ThreadFork.integration.test.ts apps/server/src/orchestration-v2/testkit/ThreadMergeBack.integration.test.ts` at head (pass). At merge-base with head fixtures they fail on the `dir` frame mismatch. That only proves the frame shape, not provider behavior.
- An end-to-end server driver (optional): an isolated `vp run dev` with `--home-dir` under the worktree, driven over MCP `t3_project_update` → `t3_thread_fork` → `t3_thread_send`. It needs real Claude turns and must never point at `~/.t3/userdata`.

## Hard-stop scan

The diff touches only the Claude adapter fork options, the testkit recorder, the adapter test, and replay fixtures. No auth, permissions, approvals, RPC scopes, contracts, or MCP tool surface changed. The PR body also states that the MCP contract is unchanged.
