# PR #22987: fixture matrix (proof audit, pass 1)

- Head (after): `6a346ccad1`, branch `fix/issue-22966`
- Before: `8d3b061121` = `git merge-base HEAD origin/main`. The touched files are byte-identical between `8acbefefc5` (the PR's own base) and `8d3b061121`, so either one works as "before".
- Runtime path: WS RPC `pullRequests.detail` (`WS_METHODS.pullRequestsDetail`, payload `PullRequestRef {projectId, repository, number, host?}`, scope `AuthOrchestrationReadScope`). It calls `PullRequestService.detailUncached`, then `GitHubPullRequestProvider.getChangeRequest`, `readChecks`, and `GitHubPullRequestApi.getPullRequestDetail`. That last step makes the GraphQL `PullRequestCore` read and, only when the rollup is truncated, the `PullRequestCheckContexts` walk.
- Wire shape: `PullRequestDetail` has **no `checksState`**. A client receives only `checks[]` plus the new optional `checksUnavailable`. `checksState` exists only on the provider/API layer (`GitHubPullRequestDetail`), which is what the unit tests assert. On errors the client receives a `PullRequestOperationError` (`operation:"detail"`, `detail`, optional `reason:"not-found"`) or a `PullRequestUnavailableError`.
- In the "Degraded" rows below, "degraded" means `checks = [{name:"Overall check status", status:<rollup state>, description:"Individual checks are unavailable with the current GitHub credential.", url:"<pr.url>/checks"}]` and `checksUnavailable = "Individual checks are unavailable with the current GitHub credential."`. Everything else (body, labels, additions, headSha, comparison, viewer permissions) is preserved. `apps/web` does not read `checksUnavailable` yet, so in the UI the result only shows as the single synthetic check row.
- fs-11 Node cannot reach api.github.com (TLS-intercepting proxy), so **every row uses a stubbed forge**. None of them can run against live GitHub from this box.

## Matrix

| ID | Branch(es) covered | Stub answer (GraphQL `PullRequestCore` unless noted) | Before (`8d3b061121`) | After (`6a346cc`) | Client receives (after) | Unit test (GitHubPullRequestApi.test.ts) | Runtime-drivable? |
|---|---|---|---|---|---|---|---|
| F1 readable checks | happy path, `read(true)`, `detailedChecks=true`, no truncation | 200, full data, contexts with 2 nodes (1 SUCCESS, 1 FAILURE) | detail with 2 real checks | same, 1 request, `detailedChecks:true` sent | `checks:[2 real]`, no `checksUnavailable` | "decodes reviewers, labels and workflow checks…" (L3680) | Yes, stub |
| F2 contexts refused, 200, PAT message, path len 8 | `isRefusedCheckContextsOnly` (status 200, msg "…personal access token", len 8), then `read(false)`, then `toPullRequestCore(_, false)` | req 1: 200 `{data:null, errors:[FORBIDDEN @ repository.pullRequest.commits.nodes.0.commit.statusCheckRollup.contexts]}`; req 2 (`detailedChecks:false`): data with `statusCheckRollup{state:SUCCESS}` | **Error**: `PullRequestOperationError{operation:"detail", detail:"…Resource not accessible by personal access token"}` (GitHubApiResponseError → reason failed) | degraded detail, 2 requests (`detailedChecks` true then false) | degraded, `checks[0].status:"success"` | "preserves full detail when PAT cannot read check contexts: SUCCESS/FAILURE/PENDING" (L3213, httpStatus 200) | Yes, stub. Key proof row. |
| F2b rollup FAILURE / PENDING | rollup → status mapping | same as F2, with `state: FAILURE` or `PENDING` | Error (as F2) | degraded, status `failure` or `pending` | degraded, `checks[0].status` failure/pending | same `.each` rows | Yes, optional |
| F3 contexts refused, HTTP 403, integration message | `isRefusedCheckContextsOnly` status-403 branch, msg "…by integration" | req 1: **HTTP 403** body `{errors:[FORBIDDEN "Resource not accessible by integration" @ …contexts]}`; req 2 as F2 | Error `PullRequestOperationError` | degraded detail | degraded | 403 covered with the PAT message only (`.each` row `httpStatus:403`). **The "by integration" message is not exercised by any detail test** (the L30 fixture is for other reads). | Yes, stub. Fills a test gap. |
| F3b per-node refusal, path len 10 | `isRefusedCheckContext` length-10 branch (`…contexts.nodes.<i>`) with partial data | 200, `data` with `contexts.nodes:[null,null]`, 2 errors at `…contexts.nodes.0/1`; req 2 as F2 | Error | degraded, 2 requests | degraded, other fields from req 2 | "recovers per-check PAT denials adapted from the parent issue" (L3133) | Yes, stub |
| F4 reviewers + contexts refused together (200) | `isRefusedDetailFieldsOnly`, which forces `detailedChecks=false`, `requestedReviewers:0` | req 1: 200 errors `[FORBIDDEN @ …reviewRequests.nodes.0.requestedReviewer, FORBIDDEN PAT @ …contexts]`; req 2 (`false, 0`) data | Error | degraded, 2 requests `[true,100]`, `[false,0]`; `reviewers:[]` | degraded, `reviewers` empty | "handles combined detail denials: unrelated=false perNode=false/true" (L3305) | Yes, stub |
| F4b reviewers refused only | `isRefusedRequestedReviewersOnly`, now inside `read()` | req 1: errors only at `…requestedReviewer`; req 2 (`requestedReviewers:0`) data | detail, reviewers dropped | same (regression guard) | real checks, reviewers without requested ones | reviewer-refusal tests incl. "does not retry a reviewer refusal with $name" (L3042) | Yes, optional |
| F4c combined refusal at HTTP 403 | `isRefusedDetailFieldsOnly` requires 200 and `isRefusedCheckContextsOnly` requires *every* error at contexts, so neither matches | 403 with reviewer and contexts errors | Error | **Error** (no degrade) | `PullRequestOperationError` | none | Yes. Documents a behavior edge. GitHub is not known to send this combination. |
| F5 unrelated errors still surface | negative branches of `isRefusedCheckContext`: generic "Forbidden" msg, RATE_LIMITED (200/403), INTERNAL 500, bad path suffixes (`nodes,-1`, `nodes,0.5`, `"0"`, `totalCount`, `nodes,0,name`, `edges,0`), combined plus an INTERNAL error | e.g. 200 FORBIDDEN "Forbidden" @ …contexts; 500 INTERNAL; 200 PAT msg @ `…contexts.totalCount` | Error | Error, **exactly 1 request** | `PullRequestOperationError` (rate-limited rows become a provider rate-limit error) | "does not degrade unrelated check-context errors: *" (L3478), "does not degrade a PAT denial at an unrelated path: *" (L3528), combined `unrelated=true` | Yes. Pick generic-forbidden plus one bad path. **Run rate-limit rows last or on another host**: they pause the host's `SourceControlRateLimit` and poison later rows. |
| F5b transport failure | `HttpClientError` TransportError is not a `ResponseError` | stub destroys the socket for this PR number | `PullRequestOperationError` ("Could not reach…") | same | same | "keeps GraphQL network errors as failures rather than unavailable checks" (L3563) | Yes (`req.socket.destroy()`) |
| F6 >100 checks walk | `checksTruncated && detailedChecks`, then `readAllCheckContexts`, merged with `pullRequestChecksFromContexts` | Core: contexts `pageInfo{hasNextPage:true,endCursor:"c1"}` (100 nodes); then `PullRequestCheckContexts` pages with the same `headRefOid` | full walked checks | same; the walk now runs under the outer `graphql()` (`allowReserve` is inherited, no longer passed) | `checks:[all]` | "reads every check when the combined response has another page (reviewer refusal: %s)" (L3761) | Yes. >10 pages leaves `checksTruncated:true`, which is internal and not on the wire. |
| F6b walk refused mid-pagination | walk error matches `isRefusedCheckContextsOnly`, then outer `read(false)` (and the reviewer retry if refused) | Core with `hasNextPage:true` and `state:FAILURE`; walk: 200 FORBIDDEN PAT @ …contexts (or `…nodes.1`); Core again with `detailedChecks:false` | Error | degraded, 3 requests (4 with reviewer refusal) | degraded, `checks[0].status:"failure"` | "recovers paginated checks refusal: reviewer=*, perNode=*" (L3383) | Yes, stub |
| F7 head changed during walk | `all.headSha !== core.headSha`, then `readError("Pull request head changed while reading checks.")` (now `Effect.gen` instead of `filterOrFail`) | Core `headRefOid:"aaa…"` with `hasNextPage:true`; walk page `headRefOid:"bbb…"` | `PullRequestOperationError` (head changed) | same message | `PullRequestOperationError{detail:~"head changed"}` | "refuses to combine checks from different head revisions (reviewer refusal: %s)" (L3836) | Yes, stub |
| F8 not found | `found()` / `isNotFoundOnly` | 200 `{data:{repository:{pullRequest:null}}, errors:[{type:"NOT_FOUND"}]}` | `PullRequestOperationError{reason:"not-found"}` | same; `isRefusedCheckContextsOnly` does not match (wrong type/path) | same | existing not-found tests | Yes, stub |
| F9 REST check-runs fallback (`getChangeRequestChecks`) | conditional REST `/check-runs` 403, then GraphQL detail degrades | REST `/api/v3/repos/acme/web/pulls/N` 200; `/commits/<sha>/check-runs` 403 PAT msg; GraphQL as F2 | Error | degraded checks | **Not via `pullRequests.detail`**: reached from the checks poll (`PullRequestService` ~L2790, `getChangeRequestChecks`) | "falls back from conditional REST check-runs PAT refusal…" (L3576) | Partially. It needs the checks RPC/poll path, not detail. Optional. |

Rows that cannot be driven from fs-11 against real GitHub: all of them, because Node is blocked by the proxy CA. Against the stub, every row is drivable. F9 needs the checks path instead of `pullRequests.detail`. F5 rate-limit variants need isolation (separate host/port or last in order).

## Recommended driving mechanism

1. **Stub forge**: a Node `https` server listening on `[::1]:8443` (`github.localtest.me` resolves to `::1` on fs-11) with a self-signed cert, `SAN=DNS:github.localtest.me` (`openssl req -x509 -newkey rsa:2048 -nodes -subj /CN=github.localtest.me -addext subjectAltName=DNS:github.localtest.me`).
   - `POST /api/graphql`: dispatch on the operation name in `body.query` (`query PullRequestCore(`, `query PullRequestCheckContexts(`, viewer/identity queries), and on `variables.number` as the fixture selector (PR #1 = F1, #2 = F2, …). Read `variables.detailedChecks` / `requestedReviewers` to choose the first or retry answer. Log every request as JSONL, so request counts and `detailedChecks` sequences become evidence.
   - `GET /api/v3/user` → `{login:"stub", id:1}` for credential verification. Answer anything unknown with a 404 JSON and log it.
   - The URL shape comes from `gitHubApiUrls()` in `packages/source-control-github/src/server/GitHubRequestPolicy.ts:112`. A non-github.com host becomes `https://<host>/api/graphql` and `https://<host>/api/v3`. There is **no API base-URL env override**. The host is chosen through the project's git remote.
2. **Project**: `git init /tmp/ev-22987/project && git -C … remote add origin https://github.localtest.me:8443/acme/web.git`. `detectSourceControlProviderFromRemoteUrl` (`packages/shared/src/sourceControl.ts:184`, any host with a `github` DNS label) sets kind `github` ("GitHub Self-Hosted"), and `project.host = "github.localtest.me:8443"` (port kept, `PullRequestService.ts:738`).
3. **Credential, no gh**: `GH_HOST=github.localtest.me:8443` and `GH_ENTERPRISE_TOKEN=stub-token`. `environmentToken()` in `GitHubCredentials.ts:104` gives the enterprise token only to the host equal to `GH_HOST`, port included. Alternative: a saved token in the isolated home's settings at `sourceControlHosts.github.tokens["github.localtest.me:8443"]`.
4. **TLS / proxy env for the server process only**: `NODE_EXTRA_CA_CERTS=/tmp/ev-22987/stub-cert.pem` (read once at startup, replaces the current system-bundle value, Node's own roots still apply) and `NO_PROXY=localhost,::1,github.localtest.me`. Avoid `NODE_TLS_REJECT_UNAUTHORIZED=0` unless the CA route fails.
5. **Server and client**: copy the pattern in `apps/server/scripts/verify-background-live.ts` (L200-310):
   - `node apps/server/src/bin.ts start --bootstrap-fd 3` with fd-3 JSON `{mode:"desktop", noBrowser:true, port, t3Home:"/tmp/ev-22987/home-<before|after>", host:"127.0.0.1", desktopBootstrapToken, tailscaleServeEnabled:false, tailscaleServePort:443}`.
   - `POST /oauth/token` (token-exchange with the bootstrap) gives a Bearer token. `POST /api/projects/mutate` `project.create` with `workspaceRoot`. `POST /api/auth/websocket-ticket`, then `ws://…/ws?orchestrationProtocol=…&wsTicket=…`.
   - `RpcClient.make(WsRpcGroup)` → `client["pullRequests.detail"]({projectId, repository:"acme/web", number:N})`. Use a fresh PR number per fixture to avoid detail caching.
   - This avoids `node apps/server/src/bin.ts pair` (which mints standard scopes for browser pairing) and never touches `~/.t3/userdata`.
6. **Before/after**: run the same script once from `/home/ubuntu/wt/pr-22987` (head) and once from a detached worktree at `8d3b061121` (needs `vp i`). Kill only the server PID captured at spawn.

Unverified until capture: whether the GitHub driver needs a `hosts` enable entry for a self-hosted host (`hostChoice` is undefined by default and only `enabled:false` blocks), and the exact viewer/identity query names the stub must answer. Run once with logging and add answers for whatever shows up.
