# Proof audit, pass 1 — linked PR badge

PR [#24317](https://git.cafe/pingdotgg/t3code/pull/24317), mirrored as [#24317](https://github.com/pingdotgg/t3code/pull/24317). Requested head [08f47fada7](https://github.com/pingdotgg/t3code/commit/08f47fada71c02c17e5d6efc8ded0b49c2c59664), before [b3d382e100](https://github.com/pingdotgg/t3code/commit/b3d382e1009d5430de8c528aef99a39854a7cbc4). Branch `fix/issue-15702`. Source-derived matrix, 2026-10-27; no captures exist for this pass.

## Findings first

1. **Proof is pending, not sufficient.** All five changed files and the rendering consumers were inspected. No product defect was established by this bounded proof-planning audit. This is not a merge-readiness certification, test execution, or visual verification.
2. **The changed paths are web sidebar cards and slim rows, composer toolbar, sidebar PR search, command-palette PR search, and mobile thread-list cards.** Settled web search needs a hover recording: the new `mixed` switch arm suppresses the old colored hover override. Search badges are spans inside result buttons, not clickable PR-list controls.
3. **Mobile settled/slim rows have no PR badge.** The badge is inside `cardContent`, and the slim branch does not render that content or its custom PR accessibility action. Requiring a settled native `+N` badge would ask the capturer to prove a nonexistent path. Native home search reuses card/slim rows; it does not introduce a separate aggregate renderer.
4. **Android covers the affected mobile branch.** Pixel 9, actual React Native client paired to the synthetic server, is suitable for M1/M2 and the native controls below. Both platforms use the same Tabler ordinary PR icon for `arrow.triangle.pull`; iOS does not use an SF Symbol for that icon. Android does not certify iOS installation, VoiceOver, or the unchanged SF stack icon. Those are not prerequisites for this scoped change.
5. **Do not inherit the earlier matrix's unrelated gates.** Same-thread mixed→closed→mixed timing, native composer suggestions, long-press menus, individual PR route actions, stack-list navigation, and broad four-cell captures of every control are not changed here. They are not required rows. The existing badge click/list action can be smoke-tested while capturing, but no action semantics changed. There is no new timer, loading animation, error UI, navigation implementation, provider adapter, or wire schema in the diff.
6. **Use pinned source/build provenance.** The shared checkout moved away from the requested head during this audit. Remaining reads used explicit `git show` at the requested head. The live GitCafe metadata still reported the requested PR head when checked. A capture of another checkout revision does not satisfy these rows; if the lead rebases, revalidate the three product hunks and record the new head before capturing.

## Render-path inventory

Pinned sources:

- [Shared resolver/type](https://github.com/pingdotgg/t3code/blob/08f47fada71c02c17e5d6efc8ded0b49c2c59664/packages/source-control-core/src/shared/threadPullRequests.ts#L282): no visible links → null; `stack-dismissed` links are filtered; snapshots absent → comparison state open; all-open/all-draft → draft; a single connected native or derived chain with more than one visible link → stack, preserving its old aggregate state. Otherwise one link → number; multiple chains with equal raw states → old aggregate; differing raw states → mixed. Draft/ready is not mixed. A connected stack plus an unrelated link is a non-stack group. Mixed never occurs for a single link or a single connected stack.
- [Web presentation and control](https://github.com/pingdotgg/t3code/blob/08f47fada71c02c17e5d6efc8ded0b49c2c59664/apps/web/src/components/ThreadStatusIndicators.tsx#L168): stack is handled first; non-stack with missing number or URL → null; multiple PRs (`others > 0`) → `+N`; mixed → ordinary PR glyph, `text-muted-foreground`, and `N linked pull requests in different states`. Homogeneous groups retain their state glyph/tone. A single PR uses its status or pending fallback. `render` changes containment: [sidebar](https://github.com/pingdotgg/t3code/blob/08f47fada71c02c17e5d6efc8ded0b49c2c59664/apps/web/src/components/sidebar/SidebarThreadRow.tsx#L1003) passes InlineButton (both card and slim); [branch toolbar](https://github.com/pingdotgg/t3code/blob/08f47fada71c02c17e5d6efc8ded0b49c2c59664/apps/web/src/components/BranchToolbarBranchSelector.tsx#L682) passes ComposerControl. Branch-selector `displayMode='panel'` omits this badge.
- [Web badge interactions](https://github.com/pingdotgg/t3code/blob/08f47fada71c02c17e5d6efc8ded0b49c2c59664/apps/web/src/components/ThreadStatusIndicators.tsx#L274): multi/stack → button opening linked-PR list; single → link; pointer events stop row activation. Tooltip renders the existing individual-link mini-list when links exist, single fallback otherwise, empty otherwise. Mini-list counts/depths/long titles/pending entries/overflow are unchanged. Tooltip/list actions are not additional changed states.
- [Search presentation](https://github.com/pingdotgg/t3code/blob/08f47fada71c02c17e5d6efc8ded0b49c2c59664/apps/web/src/components/ThreadStatusIndicators.tsx#L1029): query unmatched or empty → null; matched → same aggregate presentation. Non-settled uses its tone directly; settled uses secondary-label at rest and state-specific hover classes, with mixed returning no colored override. Callers: [sidebar search](https://github.com/pingdotgg/t3code/blob/08f47fada71c02c17e5d6efc8ded0b49c2c59664/apps/web/src/components/sidebar/SidebarThreadRow.tsx#L1646) and [command palette](https://github.com/pingdotgg/t3code/blob/08f47fada71c02c17e5d6efc8ded0b49c2c59664/apps/web/src/components/CommandPaletteThreadItems.tsx#L111). These are the only callers of ThreadSearchPullRequestNumber. Both support settled rows; only sidebar supplies the subscription-enabled flag. That flag does not replace persisted modern snapshots.
- [Mobile presenter](https://github.com/pingdotgg/t3code/blob/08f47fada71c02c17e5d6efc8ded0b49c2c59664/apps/mobile/src/state/thread-pr-presentation.ts#L60): no link/badge → null; stack → layer count; multiple unrelated → `+N`; single → number. Mixed multiple → state null, draft false, muted text, different-states accessibility label. Draft multiple → state open plus draft true. Pending single also has null state but a number and a status-pending label. Closed aggregates retain rose, while closed singles retain muted text. The [hook](https://github.com/pingdotgg/t3code/blob/08f47fada71c02c17e5d6efc8ded0b49c2c59664/apps/mobile/src/state/use-thread-pr.ts#L41) consumes modern snapshots only when the environment advertises `threadPullRequests:true`; older servers retain existing summary/cache fallback behavior.
- [Mobile card renderer](https://github.com/pingdotgg/t3code/blob/08f47fada71c02c17e5d6efc8ded0b49c2c59664/apps/mobile/src/features/threads/thread-list-v2-items.tsx#L1173): null state → ordinary icon with row's muted tint; text always uses the presenter's class. Cards exist on [HomeScreen](https://github.com/pingdotgg/t3code/blob/08f47fada71c02c17e5d6efc8ded0b49c2c59664/apps/mobile/src/features/home/HomeScreen.tsx#L574) and [ThreadNavigationSidebar](https://github.com/pingdotgg/t3code/blob/08f47fada71c02c17e5d6efc8ded0b49c2c59664/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx#L543). `pane='screen'` uses screen-muted icon tint; `pane='sidebar'` uses drawer-muted tint and drawer/selected background. Home in primary-column layout also uses sidebar pane. Selected cards have no receding opacity; working/waiting unselected cards use existing 0.55 opacity, which is independent of PR state. The [slim branch](https://github.com/pingdotgg/t3code/blob/08f47fada71c02c17e5d6efc8ded0b49c2c59664/apps/mobile/src/features/threads/thread-list-v2-items.tsx#L1235) contains no PR badge. Search retains the same card/slim decision.
- Native [Android icon mapping](https://github.com/pingdotgg/t3code/blob/08f47fada71c02c17e5d6efc8ded0b49c2c59664/apps/mobile/src/components/ui/AppSymbol.tsx#L160) and [iOS custom icon mapping](https://github.com/pingdotgg/t3code/blob/08f47fada71c02c17e5d6efc8ded0b49c2c59664/apps/mobile/src/components/ui/AppSymbol.ios.tsx#L16) both map the affected PR glyph to IconGitPullRequest. The existing press handler/list route and row accessibility action do not change; their labels do change through the presenter.

There is no count cap or count-length conditional beyond zero/one/multiple. `+2`, `+6`, `+12` use the same layout. Use two and six in the primary fixture scene; do not multiply the matrix for every count/state permutation. Long thread titles can be included once to expose accidental crowding; PR title lengths appear only in unchanged lists. Theme changes muted tokens; parent width changes available space. No new width/platform conditional exists. Capability-off, disabled subscriptions, query nonmatches, missing legacy detail, and detail errors cannot enter the new branch and require no separate capture series. Settings, keybindings, command actions, native composer/git controls and individual PR detail rows do not call the changed aggregate presenter. Desktop wraps the same web renderers without a changed IPC path; Electron and remote/tunnel copies are not separate proof rows.

## Synthetic fixture basis and real-app seeding

All rows below are feasible in the real app through its RPC. No harness capture is required by source inspection. Seeding has NOT been executed in this pass; feasibility is based on contracts and orchestrator handling, not an assertion that the future driver ran.

Use an empty isolated T3 home with automatic live-data seeding disabled. Create an empty git workspace under the lead's timestamped scratch run, with a local main branch and no real forge remote. Add it through UI or `projects.mutate` with `type:'project.create'`, unique command/project IDs, title, and workspaceRoot. Create stopped threads using `orchestration.dispatchCommand` / `thread.create`: unique command/thread IDs, returned projectId, fixture title, valid configured modelSelection, runtimeMode/interactionMode from normal app thread creation, branch main, worktreePath null. Creating a thread sends no provider prompt. Do not clone a real conversation or use the developer's live database.

Define **S(fixture)** as: for each synthetic PR identity, dispatch `thread.pull-request.link` with commandId, threadId, host `github.com`, a newly generated fictitious repository slug, a positive number, the corresponding synthetic PR URL, and source manual. Build the URL from the canonical host, generated repository slug and `/pull/<number>`; never create or modify a real forge PR. The resulting link initially has snapshot null and stack null. For each non-pending snapshot dispatch `thread.pull-request-link.sync` with commandId, threadId, the same host/repository/number, snapshot, and stack null. This sync command is explicitly in the public dispatch schema, not the server-internal command union. Its schema requires a non-null snapshot, so create a fresh link and omit sync to obtain pending; do not send `snapshot:null` to sync.

Snapshot minimum: state open/closed/merged; synthetic title; unique headBranch `proof-<fixture>-<number>`; baseBranch main; isDraft false unless specified; updatedAt fixed ISO timestamp or null; syncedAt fixed ISO timestamp. ClosedAt/mergedAt may be fixed valid dates for terminal links. Keep heads distinct and no base matching another head except fixture STACK. Link identities are local synthetic data, not actual forge evidence. Fresh command IDs for every dispatch; await receipts and corresponding shell/projection state, not arbitrary sleeps. Read back visible links and resolved shapes before capturing. Failed external summary reads must not be mistaken for fixture state; confirm the persisted snapshots remain unchanged.

After seeding, set automatic settlement off with `thread.auto-settle.set {enabled:false}` and explicitly `thread.unsettle {reason:'user'}` for the active fixtures. Settle the separate settled clones with `thread.settle` and fixed settledAt. This avoids terminal fixtures disappearing into the settled shelf. Use existing UI preferences to expand the settled shelf. No watches, active runs, tasks, terminals, drafts or real secrets are needed. Both web and Android pair to this same isolated environment with `threadPullRequests:true`.

| Fixture | S(fixture) data / special step | Expected shape; use |
|---|---|---|
| MIX6 | Six independent manual links: three closed, three ready open; one long synthetic thread title | mixed `+6`; primary affected specimen |
| MIX2-T | Two independent links: merged and closed, no open | mixed `+2`; terminal-only semantic boundary, same new visual branch |
| MIX2-P | Two independent links: fresh unsynced link plus closed snapshot | mixed `+2`; pending is open for state comparison; same new visual branch |
| MIX6-S | Clone MIX6 with unique thread ID and explicit settle | mixed web slim and settled search; no native slim badge |
| OPEN | Two unrelated ready-open snapshots | open `+2`; also validate one draft plus one ready stays open |
| DRAFT | Two unrelated open snapshots, both isDraft true | draft `+2` |
| CLOSED | Two unrelated closed snapshots | closed `+2` |
| MERGED | Two unrelated merged snapshots | merged `+2` |
| STACK | Two snapshots, lower merged/head proof-bottom, upper closed/base proof-bottom; same host/repository | one derived chain → stack, layers 2, closed; not mixed |
| ONE | One closed snapshot | individual number; ordinary single-PR control |
| NONE | No links, with legacy linkedPullRequest and branchPullRequest absent | no badge; alternatively create only source stack-dismissed to verify hidden filtering |

OPEN with both snapshots pending remains open `+2`; MIX2-P is mixed; one draft plus one ready remains open; adding an unrelated open link to STACK makes it mixed. These are semantic equivalence checks within the seeder/readback, not extra screenshot rows: they enter already-covered visual branches. A GitLab synthetic URL changes accessibility terminology to merge requests without changing the glyph/count layout; check that label in driver output if using that host, not another full capture set. Capability-false behavior needs a different server capability contract, so if optionally simulated it must be labelled a compatibility harness, not the modern app capture.

To select a web fixture, use `/<environmentId>/<threadId>` after pairing. Sidebar search and command palette must search a linked synthetic PR number (or other PR search term), not just the thread title. Query empty/unmatched deliberately removes the search badge. The settled result must remain visible while its row is hovered. For mobile, select the paired fixture project on Home, and open its thread-navigation drawer for sidebar-pane rows. Keep at least two active MIX6 clones so one card can be selected while another is unselected in the same drawer.

## Minimum capture matrix

All requested cells mean **paired before/after** at the exact SHAs above, same synthetic data, same app preferences and framing. L/D = light/dark. W = 1440×900 CSS px. N = 390×844 CSS px. A = actual Pixel 9 Android emulator in portrait at its native viewport, record logical size, pixel size, density, OS/API, app build and served JS revision. Do not relabel N web captures as native. Use `vp run mobile:install android` on the leased device host before Metro; the lead/capturer owns that authorized setup and teardown.

The minimum is two wide web theme sessions per SHA with grouped controls, one narrow light representative per SHA, and two Android theme sessions per SHA. Record DPR/zoom/theme, actual fixture IDs/counts, source SHA, backend address without credentials, and app readiness in the evidence index. Screenshots should retain enough surrounding UI to identify the surface. A single image may satisfy several rows if each badge is legible. Pair crops/full scenes consistently; unrelated age/focus/sidebar changes must be identified rather than attributed to this diff.

`recording` means continuous video is required because the row's evidence depends on search/hover/focus interaction. Ordinary navigation to a static route is setup, not an extra interaction claim. Include accessibility snapshots/driver label output alongside stills; a screenshot alone cannot prove accessible text.

| Row | Diff affects? / visually distinct state | Data and real-app seed / entry | Minimum viewport × theme | Medium / expected evidence | Android covers? | Pass-1 status |
|---|---|---|---|---|---|---|
| W1 | Yes: normal sidebar card, mixed | S(MIX6), plus MIX2-T and MIX2-P nearby; active shelf, select a fixture route | W×L,D; N×L with sidebar open (may share narrow session) | still at W, **recording** opening narrow sidebar at N; ordinary muted PR icon, `+6`/`+2`; no overall state claim. Long title does not displace badge | N/A | PENDING captures/recording |
| W2 | Yes: sidebar settled slim mixed, rest→hover/focus | S(MIX6-S), expand settled shelf | W×L,D | **recording**; row's idle inherited muting and badge's muted hover/focus appearance; no return to green/red | N/A | PENDING recording |
| W3 | Yes: composer toolbar mixed | S(MIX6), select route, branch selector in toolbar mode; also select MIX2-T once | W×L,D; N×L | still; muted ordinary icon and total `+N` fit ComposerControl. Narrow scene checks toolbar fit; panel mode has no such badge | N/A | PENDING captures |
| W4 | Yes: active sidebar PR-search mixed | S(MIX6); search its synthetic linked PR number | W×L,D | **recording** query→result; `+6` muted span, changed accessible label; query clear restores ordinary list | N/A | PENDING recording |
| W5 | Yes: settled sidebar PR-search mixed hover | S(MIX6-S); same PR-number query; keep settled result visible | W×L,D | **recording** rest→hover; secondary-label stays neutral, no state hover override. This directly covers added switch arm | N/A | PENDING recording |
| W6 | Yes: active command-palette PR-search mixed | S(MIX6); open command palette and query linked PR number | W×L,D | **recording** query→result; same muted span, correct +N/label in palette containment | N/A | PENDING recording |
| W7 | Yes: settled command-palette PR-search mixed | S(MIX6-S), and a settled MIX2-T clone for changed terminal glyph; query linked PR number | W×L,D | **recording** query→settled result; secondary-label remains neutral, terminal mixed uses ordinary PR glyph. The palette is not proof of the named sidebar-row hover rule; W5 is | N/A | PENDING recording |
| M1 | Yes: native home screen-pane card mixed | S(MIX6), MIX2-T, MIX2-P; actual Home active cards, no running provider | A×L,D | still; ordinary Tabler PR icon, screen-muted tint, muted text, +N; accessibility label different states and row action label starts Show | **Yes**, affected JS and ordinary PR glyph shared with iOS | PENDING native captures |
| M2 | Yes: native drawer/sidebar-pane card mixed, selected and unselected backgrounds | S(two active MIX6 clones); open first thread, show drawer with its selected card and second unselected card; Home primary-column uses this same pane | A×L,D | **recording** opening drawer; drawer-muted icon tint, muted text against both backgrounds | **Yes**, shared presenter/card/pane logic; does not claim iOS gestures or VoiceOver | PENDING native recording |
| C1 | No: homogeneous open aggregate | S(OPEN), active shelf; web sidebar, select composer; native Home card | W×L; A×L | still; green ordinary icon +2 remains. Draft/ready and all-pending variants share this rendered aggregate; verify their shape via readback | **Yes** for native card/control | PENDING control |
| C2 | No: homogeneous draft aggregate | S(DRAFT), active shelf; same surfaces as C1 | W×L; A×L | still; draft glyph/muted +2 remains, not mixed | **Yes**, draft custom icon also shared | PENDING control |
| C3 | No: homogeneous closed aggregate | S(CLOSED), explicitly active; same surfaces as C1 | W×L; A×L | still; closed glyph and web red / native aggregate rose +2 remain | **Yes**, closed custom icon also shared | PENDING control |
| C4 | No: homogeneous merged aggregate | S(MERGED), explicitly active; same surfaces as C1 | W×L; A×L | still; merged glyph/violet +2 remains | **Yes**, merged custom icon also shared | PENDING control |
| C5 | No: connected stack despite differing states | S(STACK), explicitly active; same surfaces as C1 | W×L; A×L | still; stack glyph and `2`, closed tone. Capturing Android stack is a control, not iOS SF-symbol certification | **Yes** for Android stack/control; iOS stack symbol unchanged and not certified | PENDING control |
| C6 | No: single PR | S(ONE), active row; select on web to show toolbar number | W×L; A×L | still; individual number, not +N; native closed single text remains muted | **Yes** for native card/control | PENDING control |
| C7 | No: no visible links | S(NONE), active row; select on web to show toolbar absence | W×L; A×L | still; no aggregate/ghost +N; preserve no legacy/branch fallback | **Yes** for native card/control | PENDING control |
| C8 | No: native settled slim absence | S(MIX6-S), expand settled shelf on Home/drawer | A×L | **recording** expanding shelf; slim row has **no PR badge**, even though helper can derive mixed data. Same absence in settled search | **Yes**, shared slim branch; no iOS-specific claim | PENDING control recording |

W1/W3 wide pairs can share a scene; C1–C7 can share grouped list scenes and a short indexed toolbar selection sequence. If one records that sequence instead of static prepared routes, label it recording. W4/W5 and W6/W7 may each share a continuous video with active and settled fixtures simultaneously visible. Videos may have indexed milestones but may not be replaced by extracted frames. Controls need not repeat in every width/theme because the changed mixed token is already exercised in both themes and narrow containment is sampled by W1/W3. Include at least one unchanged control in the dark/narrow affected scenes as a reference.

No new recording row is required for pending snapshots: they are deliberately persisted static input here, not a claim about waiting for a fetch. No elapsed-time promise is made. Optional clicking mixed badge to the existing linked-PR list is useful integrated smoke coverage but is not a gate for this branch-only presentation change; any claimed click/navigation proof must be a recording and use real navigation rather than switching to another seeded page.

## Coverage and lifecycle

Covered: complete five-file diff at requested before/head; resolver chain/filter/state branches; web control wrappers and both search callers; mobile presenter, capability gate, home/drawer card and slim containment, platform symbol implementations; public seed command schemas and server link/sync updates; earlier matrix/audit as untrusted comparison. No captures, tests, fixture mutations, servers, emulators, leases, browser sessions, pushes or comments were performed by this auditor. Scratch only; lead checkout and parent scratch remain owned by the lead. The uploaded report is retained publicly, and this step's scratch is removed after upload.

FRICTION:

- brief | Shared checkout changed revision during read-only inspection; explicit git-show reads preserved requested-head coverage | Keep the review revision immutable, or require pinned git-show reads in the brief.
- tool | Web fetch could not read the earlier public Markdown artifacts; curl fetched both successfully | Serve artifact Markdown in a web-fetch-compatible response, or specify direct download.

VERDICT: INSUFFICIENT <all affected rows: captures pending>
