# PR #22788 proof matrix, pass 2: delayed hydration, native wheel, phase ranges

PR: [#22788](https://git.cafe/pingdotgg/t3code/pulls/22788) ([GitHub mirror](https://github.com/pingdotgg/t3code/pull/22788)), issue [#20366](https://git.cafe/pingdotgg/t3code/issues/20366).

| Label | SHA | What it is |
|---|---|---|
| **B** (before) | [8d3b061121](https://git.cafe/pingdotgg/t3code/commit/8d3b061121fe65669d5d43d011b48cd3027a4108) | merge-base; first parent of the head merge. The fixture files are overlaid as uncommitted changes (see §1). |
| **A** (after) | [6f2d6c63e3](https://git.cafe/pingdotgg/t3code/commit/6f2d6c63e3f252651c0221c8a3dcf9d9f950dbbd) | merge of main into `fix/issue-20366` (parents [da7573a5fc](https://git.cafe/pingdotgg/t3code/commit/da7573a5fc4aea23b8ede26dfd743124a18998c3), 8d3b061121) |

`git diff 8d3b061121 6f2d6c63e3` touches exactly 6 files (+449/−177). That count is the same with and without the path filter, so B→A is the PR and nothing else. Only `ChatView.tsx`, `MessagesTimeline.tsx` and `useTimelineFollow.ts` affect runtime behavior. The two `apps/server/scripts` files are opt-in fixture tooling, and the browser test is not loaded by the app.

This matrix closes three prior gaps:
1. Delayed hydration in the real app, longer than the old retry window, matched on B and A.
2. Native wheel input.
3. Phase ranges within each clip, verified rather than inferred.

It does not repeat the settled controls from pass 1, which pass on both revisions.

## 0. Why the bug needs a held hydration, and what "held" means here

These facts come from reading the code. They decide the mechanism.

- On B, the listener effect in `useTimelineFollow.ts` runs when `activeThreadId` changes. It polls `legendListRef.current?.getScrollableNode()` for 1 + 12 animation frames (`attach(12)`) and then stops.
- While the thread detail loads, `MessagesTimeline` renders no `LegendList`:
  - ChatView computes `threadDetailLoading = resolveThreadSyncPhase(...) === "loading"` (ChatView.tsx:497–501).
  - It passes `hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading}` (ChatView.tsx:3382).
  - With zero rows, MessagesTimeline returns `<div data-timeline-loading="true">` instead (MessagesTimeline.tsx:1046–1054).
- The thread detail is the `orchestration.subscribeThread` stream (`ORCHESTRATION_V2_WS_METHODS.subscribeThread`, packages/contracts/src/orchestrationV2.ts:3101). It travels over `/ws` with `RpcSerialization.layerJson` (client-runtime/src/rpc/session.ts:224, server ws.ts:3218). Every frame is plain JSON:
  - The client sends `{"_tag":"Request","id":…,"tag":"orchestration.subscribeThread",…}`.
  - The server answers with `{"_tag":"Chunk","requestId":…}` and `{"_tag":"Exit","requestId":…}`.
- The bug therefore needs both of these:
  - (a) The thread **shell** is known before the detail, so `activeThreadId` is set and does not change again when the detail arrives. In practice you navigate in-app from the sidebar; a cold load is not enough.
  - (b) The first `subscribeThread` Chunk is held longer than 13 frames.
- On A, the stable `setTimelineList` ref callback reports the node whenever it mounts. The effect keys on `timelineScrollNode`, so the hold length does not matter.

### Hold mechanism: a labelled test-driver WebSocket hold (least invasive)

This is a **TEST-DRIVER INJECTION, no source change, byte-identical on B and A**. It is a Playwright `page.addInitScript`, which runs before the app creates its socket.

- It wraps `WebSocket` so that `send()` records the `id` of any `Request` whose `tag` is `orchestration.subscribeThread` and whose payload names the fixture thread id.
- It queues inbound `message` events whose JSON (a single message or an array) carries one of those `requestId`s, until `window.__fx.release()` is called.
- It counts `requestAnimationFrame` ticks while the hold is active.
- It instruments `EventTarget.prototype.add/removeEventListener`. Vite dev keeps function names, so this counts `handleWheel` / `handleTouchMove` / `handlePointerDown` / `handleScroll` listeners on the timeline scroll node and `handleKeyDown` on `document`.

Nothing is faked in CSS or the DOM. The app really waits for its own data. The script is at `/tmp/ev-22788/driver/hold.js`, outside both worktrees. Its sha256 goes in the manifest.

```js
// hold.js — addInitScript; identical for B and A
(() => {
  const fx = (window.__fx = { holdThreadId: null, held: [], ids: new Set(), holding: false,
    frames: 0, holdStartedAt: null, releasedAt: null, listeners: [], marks: [] });
  const tick = () => { if (fx.holding) fx.frames++; requestAnimationFrame(tick); };
  requestAnimationFrame(tick);
  fx.mark = (name) => fx.marks.push({ name, t: performance.now(), wall: Date.now() });
  const NativeWS = window.WebSocket;
  const matches = (raw) => { try { const m = JSON.parse(raw); return (Array.isArray(m) ? m : [m])
    .some((x) => x && fx.ids.has(String(x.requestId))); } catch { return false; } };
  class HoldWS extends NativeWS {
    send(data) {
      try { for (const m of [].concat(JSON.parse(data))) if (m?._tag === "Request" &&
        m.tag === "orchestration.subscribeThread" && fx.holdThreadId &&
        JSON.stringify(m.payload).includes(fx.holdThreadId)) {
          fx.ids.add(String(m.id)); fx.holding = true; fx.frames = 0; fx.holdStartedAt = performance.now(); fx.mark("hold-start"); } } catch {}
      return super.send(data);
    }
    constructor(...a) { super(...a);
      super.addEventListener("message", (e) => { if (fx.holding && matches(e.data)) {
        e.stopImmediatePropagation(); fx.held.push(e.data); } }, { capture: true }); }
  }
  // Re-dispatch after release so app listeners (addEventListener or onmessage) both see it.
  fx.release = () => { fx.holding = false; fx.releasedAt = performance.now(); fx.mark("hold-release");
    for (const s of fx.sockets) for (const d of fx.held.splice(0)) s.dispatchEvent(new MessageEvent("message", { data: d })); };
  fx.sockets = new Set();
  window.WebSocket = new Proxy(HoldWS, { construct(T, a) { const s = new T(...a); fx.sockets.add(s); return s; } });
  const add = EventTarget.prototype.addEventListener, rem = EventTarget.prototype.removeEventListener;
  const log = (op) => function (type, fn, o) {
    if (fn && /^handle(Wheel|TouchMove|PointerDown|Scroll|KeyDown)$/.test(fn.name))
      fx.listeners.push({ op, type, name: fn.name, t: performance.now(),
        target: this === document ? "document" : (this.closest?.("[data-assistant-citation-viewport]") ? "timeline" : "other") });
    return (op === "add" ? add : rem).call(this, type, fn, o); };
  EventTarget.prototype.addEventListener = log("add");
  EventTarget.prototype.removeEventListener = log("remove");
})();
```

The capture agent must check one thing before relying on the hold. If the sidebar or a hover prefetch subscribes to the thread before navigation, `hold-start` fires too early or never. The driver must then assert that `hold-start` happened **after** the click and **while** `[data-timeline-loading="true"]` was present. If not, set `holdThreadId` only at click time. If the hold still cannot be engaged, mark the row **UNSEEDABLE** and do not substitute anything.

**Fallback, if the in-page hold proves unreliable** (also labelled TEST-DRIVER, no source change): run a Node `ws` relay on its own port between the browser and the dev server's `/ws`. It applies the same filter and holds server→client frames for the matched `requestId` until `/tmp/ev-22788/run/<cell>/hydrate.release` exists. The cost is a second origin. Point the browser at the relay origin and have it forward plain HTTP to Vite, so the app stays single-origin. Do **not** set `VITE_WS_URL`.

Rejected options:
- A dev-only delay in the app source. It changes the audited code.
- Seeder or fixture delays. The seeder controls the provider stream, not hydration, and cannot delay `subscribeThread`.
- CDP network throttling. It slows everything, including Vite modules and the stream, so it would not isolate hydration.

**Measurable hydration criterion (every W row, B and A).**
- At `hold-release`: `__fx.frames ≥ 30`, at least 2.3× the old budget of 13 frames, and wall time ≥ 2000 ms.
- `document.querySelector('[data-timeline-loading="true"]')` was non-null at `hold-start + 100 ms` and at `hold-release − 1 frame`.
- The thread title is visible in the chat header during the hold. This is the proxy that the shell is known, i.e. condition (a).
- Hold length is **2500 ms**, released by the driver. The frame count is the criterion, not the sleep. Headless RAF can throttle, so if `frames < 30`, extend the hold until it reaches 30.

## 1. Isolated setup (never `~/.t3/userdata`, never `~/wt/pr-22943`, never start servers in `~/wt/pr-22788`)

```sh
export PATH="$HOME/.bun/bin:$HOME/.local/bin:$HOME/.local/share/vite-plus/bin:$PATH"
R=/tmp/ev-22788/run; mkdir -p $R/driver
git -C ~/wt/pr-22788 worktree add --detach $R/B 8d3b061121fe65669d5d43d011b48cd3027a4108
git -C ~/wt/pr-22788 worktree add --detach $R/A 6f2d6c63e3f252651c0221c8a3dcf9d9f950dbbd
# Overlay fixture tooling only onto B (server scripts, not the web bundle):
git -C $R/B checkout 6f2d6c63e3 -- apps/server/scripts/acp-mock-agent.ts apps/server/scripts/timeline-follow-fixture.ts
git -C $R/B status --short     # MUST list exactly those two paths, nothing else
(cd $R/B && vp i) ; (cd $R/A && vp i)
mkdir -p $R/home-B $R/home-A $R/ws-B $R/ws-A $R/bar-B $R/bar-A   # homes, workspaces, barrier roots
```

Ports: pick explicit offsets far from anything else. Before starting, check `ss -H -ltnp` and confirm nothing listens on 13814/5774/13815/5775. `~/wt/pr-22943` derives its own ports from its path. If its dev server is up, read its `[dev-runner]` line and choose different offsets; do not stop it. 3773 is the live install; do not touch it.

```sh
(cd $R/B && T3CODE_PORT_OFFSET=41 T3CODE_DEV_SEED=0 vp run dev --home-dir $R/home-B > $R/dev-B.log 2>&1 &  echo $! > $R/dev-B.pid)
(cd $R/A && T3CODE_PORT_OFFSET=42 T3CODE_DEV_SEED=0 vp run dev --home-dir $R/home-A > $R/dev-A.log 2>&1 &  echo $! > $R/dev-A.pid)
grep -m1 '\[dev-runner\]' $R/dev-B.log $R/dev-A.log   # record real ports; expect server 13814/web 5774 and 13815/5775
```

- `--home-dir` outranks the worktree `.t3`. `T3CODE_DEV_SEED=0` prevents `migrate-dev-db` from copying `~/.t3` data. The test homes contain only the fixture.
- Pair Playwright with `node apps/server/src/bin.ts pair --base-dir $R/home-X`, run in that worktree. Parse only the `/pair` URL and never print or upload it.
- When done, stop each server by its recorded PID (`kill $(cat $R/dev-B.pid)`). Never kill by pattern.

Provider (both homes): `node apps/server/scripts/timeline-follow-fixture.ts provider $R/bar-X` prints the instance JSON. Create it through Settings → Providers in that isolated app as `timeline_fixture`. The only differences between B and A are the barrier root path and the absolute `commandArgs` path, which is that worktree's own `acp-mock-agent.ts`.

Companion thread, needed for in-app navigation: seed one **short** fixture thread first, release all five phases, and let it finish. It is the thread you start on, so the long thread's shell is in the sidebar before you click it.

## 2. Server row S0: seeder/mock transcript (status: `transcript`, both B and A)

Run in each worktree with the matching home. Capture stdout/stderr verbatim into `$R/S0-X.txt`.

```sh
node apps/server/scripts/timeline-follow-fixture.ts print | tee $R/fixture-X.json | sha256sum
node apps/server/scripts/timeline-follow-fixture.ts provider $R/bar-X
node apps/server/scripts/timeline-follow-fixture.ts seed $R/home-X $R/ws-X timeline_fixture short   # companion
node apps/server/scripts/timeline-follow-fixture.ts seed $R/home-X $R/ws-X timeline_fixture long    # W1/W2/W3 thread (fresh per run)
D=$(dirname $(ls -t $R/bar-X/*/waiting.json | head -1)); cat $D/waiting.json      # {"kind":"long","phase":"entry",...}
node apps/server/scripts/timeline-follow-fixture.ts release $D entry ; ls $D        # entry.done appears; waiting.json → tool-start
```

Pass criteria:
- The `print` sha256 is identical on B and A. The fixture file is byte-identical across the overlay; pass 1 recorded `c26f312c…b87`, so report a mismatch if this differs.
- `seed` exits 0 and prints JSON with the thread and run IDs. Record them sanitized.
- `waiting.json` advances `entry → tool-start → tool-finish → prose → resumed` as each `.release` is written.
- Each `<phase>.done` appears only after its release.
- The barrier directory name `<pid>-<promptCount>` matches the mock's process.

This row proves that the W rows' stream is the ACP mock driven through `thread start`, with no projection writes.

Phase content (long), from `timelineFollowFixture`:

| Phase | Content |
|---|---|
| entry | P001–P100 in one `agent_message_chunk` |
| tool-start | `tool_call_update` in_progress "Check the harbor log" |
| tool-finish | completed |
| prose | P101–P120 in one chunk |
| resumed | P121–P122 |

Updates are paced 40 ms apart inside a phase. These are phase-barrier chunks, not per-paragraph streaming; the matrix does not claim otherwise.

## 3. Common capture conditions (W rows and stills)

- Driver: Playwright 1.60.0 from `$R/A/node_modules`, run from `$R/driver/run.mjs`, never from inside a worktree. Browser: cached `chromium_headless_shell-1223`. Record its version string.
- Context:
  - `viewport` 1440×900 for W rows, `deviceScaleFactor: 1`, `colorScheme` light, `reducedMotion: 'no-preference'`, `locale 'en-US'`, `timezoneId 'UTC'`.
  - `recordVideo: { dir, size: {width:1440,height:900} }`. Use ffmpeg-1011 from the cache to transcode to mp4.
- Clock: `page.clock.setFixedTime(new Date('2026-01-01T12:00:00.000Z'))`, the fixture epoch, set before `goto`. This fixes `Date.now`/`new Date` only. Timers and RAF stay live, so stream delivery and the race are not affected. Server-stamped durations still differ between runs. Disclose them; do not mask them.
- Input is native, CDP-trusted:
  - Clicks: `page.mouse.click(x, y)` → `Input.dispatchMouseEvent` mousePressed/Released.
  - Keys: `page.keyboard.press('PageUp'|'End')` → `Input.dispatchKeyEvent`.
  - Wheel: `page.mouse.move(720,400)` then `page.mouse.wheel(0,-600)` → `Input.dispatchMouseEvent type:"mouseWheel"`. Use agent-browser if it is installed; it is not on this machine.
  - Never call `dispatchEvent` or set `scrollTop` from page JS after the entry phase.
- Measurement function `m()`, evaluated at each mark:

```js
() => { const v = document.querySelector('[data-assistant-citation-viewport]');
  const n = [...(v?.querySelectorAll('*') ?? [])].find(e => /auto|scroll/.test(getComputedStyle(e).overflowY) && e.scrollHeight > e.clientHeight);
  const p = [...document.querySelectorAll('p')].find(e => { const r = e.getBoundingClientRect(); return r.top >= 0 && /^Paragraph \d{3}\./.test(e.textContent); });
  return { top: n?.scrollTop, height: n?.scrollHeight, client: n?.clientHeight,
    gap: n ? n.scrollHeight - n.scrollTop - n.clientHeight : null,
    pill: !!document.querySelector('button[aria-label="Scroll to end"]'),
    firstVisible: p?.textContent.slice(0, 13), firstVisibleY: p?.getBoundingClientRect().top,
    timelineListeners: __fx.listeners.filter(l => l.target === 'timeline' && l.op === 'add').length
                     - __fx.listeners.filter(l => l.target === 'timeline' && l.op === 'remove').length,
    docKeydown: __fx.listeners.filter(l => l.name === 'handleKeyDown' && l.op === 'add').length
              - __fx.listeners.filter(l => l.name === 'handleKeyDown' && l.op === 'remove').length,
    frames: __fx.frames, t: performance.now() }; }
```

  There is no `data-live-follow` attribute in the DOM. Follow state is observed through its effect:
  - **follow on** = `gap ≤ 2` after a growth phase.
  - **held** = `top` within ±1 px of the pre-growth value, `firstVisible` unchanged, `firstVisibleY` within ±1 px, while `height` grows.
  - The pill is secondary. It is debounced 150 ms and shown whenever the viewport is not at the end, so on B it can show while follow is still on.
- Narration, verified rather than inferred:
  - Call `__fx.mark(name)` at every step.
  - Write `marks.tsv` with clip-relative seconds = `mark.wall − videoStartWall`. `videoStartWall` is the `Date.now()` taken right after `newPage()`, and the clock override does not affect the driver's Node clock.
  - Video only: a recording-only overlay `<div style="position:fixed;right:4px;bottom:4px;z-index:2147483647;pointer-events:none;font:12px monospace">` shows `name t=…s`, and is updated at each mark.
  - **Phase-range verification:** extract one frame per mark with `ffmpeg -ss <s> -frames:v 1` and check that the overlay text matches. A clip passes indexing only if every mark frame matches within 0.2 s.
  - Stills are taken in separate runs **without** the overlay.
- Per cell, write `manifest.json`. It contains:
  - SHA (B or A, plus `git status --short` output)
  - fixture sha256 and hold.js sha256
  - home path
  - real ports from `[dev-runner]` (no token)
  - thread/run IDs
  - browser version
  - viewport, DPR, theme
  - all `m()` samples
  - `marks.tsv`
  - video path

## 4. Web rows (status: `recording` for every row, B **and** A, identical scripted sequence)

Each row is one fresh long-fixture thread per revision, in a fresh barrier directory. Every step is a timestamped mark.

### W1: PageUp mid-stream after a slow hydration

**Setup:**
- Companion short thread exists.
- Seed the long thread and release `entry`, so the detail snapshot already holds P001–P100 and the turn is still running (waiting at `tool-start`).
- Open the app on the companion thread. Set `__fx.holdThreadId = <long thread id>`.

**Steps (mark names in bold):**
1. **nav**: native click on the long thread in the sidebar.
2. **hold-start** is logged automatically. Wait until `frames ≥ 30` and ≥ 2500 ms, then **hold-release** with `__fx.release()`.
3. **mounted**: wait until a `Paragraph 100.` row is in the DOM and 10 more RAF ticks have passed. Take `m()`. If `gap > 2`, scroll once with `End`; that counts as part of setup.
4. **click-prose**: native click at (720, 400) on prose text, to move focus off the composer onto body/timeline.
5. **pageup**: native `PageUp` ×2, 300 ms apart. Wait 400 ms, then take `m()`. Call this sample `S_up`.
6. **tool-start**: run `release $D tool-start`, wait for `tool-start.done` + 500 ms, then `m()`.
7. **tool-finish**: same procedure, then `m()`.
8. **prose**: same procedure (P101–P120), then `m()`. Call this sample `S_hold`.
9. **end**: native `End` key. Wait 600 ms, then `m()`.
10. **resumed**: `release $D resumed`, wait for `.done` + 500 ms, then `m()`. Call this sample `S_res`.
11. **pill variant**: run the same row a second time, but at step 9 native-click `button[aria-label="Scroll to end"]` instead of pressing End. This is W1p and is also `recording`.

**Pass criteria:**
- Hydration criterion §0 holds on both revisions.
- **On both:** at `S_up`, `top` decreased by ≥ 500 px versus mounted and `gap ≥ 500`. This shows native PageUp actually scrolled.
- **B (expected failure):**
  - `timelineListeners == 0` and `docKeydown == 0` at `S_up`.
  - At step 6 or 7 (the first growth or tool boundary), `gap ≤ 2`, i.e. it snapped to the bottom.
  - `firstVisible` at `S_hold` ≠ `firstVisible` at `S_up`.
  - Report the snap's `top` before→after; the reference from the PR body is 2147→8136.
  - If B does **not** snap, the row is **NOT REPRODUCED**. Report it as such; that would mean the race is not the cause in this configuration.
- **A:**
  - `timelineListeners == 4` (scroll, wheel, touchmove, pointerdown, net) and `docKeydown == 1`, with the add timestamps after `hold-release`.
  - From `S_up` through `S_hold`: `|Δtop| ≤ 1`, `firstVisible` identical, `|ΔfirstVisibleY| ≤ 1`, `height` strictly increasing across steps 6–8, and `pill == true` at `S_hold`.
- **Resume, both (A required, B informational):** after step 9 or the pill click, `gap ≤ 2` and `pill == false`. At `S_res`, `height` increased and `gap ≤ 2`, i.e. follow resumed.
- **Indexing:** §3 phase-range verification passes for both clips.

### W2: native wheel up mid-stream after a slow hydration

Same setup and steps as W1, except:
- Step 4 is a native mouse move to (720, 400) with no click.
- Step 5 is **wheel**: `page.mouse.wheel(0, -600)` ×2, 150 ms apart.
- Step 9 is a native pill click. A downward wheel does not restore follow by itself; it only rearms at the end band.

Use the same pass criteria with `S_up` taken after the wheel. Also check on A that `__fx.listeners` shows `handleWheel` added on the timeline before the wheel mark. On B it must be absent.

This is the native counterpart of the committed browser test "lets a wheel up break follow when the list mounts after a slow hydration" (MessagesTimeline.browser.test.tsx:273), which uses synthetic wheel input.

### W3: control, never scrolls

Same setup and hold as W1. After **mounted**, do no input at all. Then run tool-start, tool-finish, prose and resumed with the same releases and waits.

**Pass, both B and A:** `gap ≤ 2` after every growth phase, `pill == false` throughout, and `height` strictly increasing. This proves the hold and A's ref-callback attachment do not break normal live follow. Listener counts are informational: B is 0 and A is 4/1. B follows even with no listeners, because follow is on by default.

### W-hold-length sanity (optional, `recording`, A only)

Run W1 on A with a short hold (≈5 frames, inside the old window). The result should be the same as W1-A. This confirms A does not depend on the hold length. On B it would pass, which reproduces pass 1's settled controls, so it is not required.

## 5. At-rest stills (held state), B and A, two diff images per pair

Four cells: {1440×900, 390×844} × {light, dark}, DPR 1. `colorScheme` is set in the Playwright context and the app follows the system theme; record it. In each cell, run the W1 sequence (PageUp variant) **without** the narration overlay, freshly seeded. At 390 wide, use PageUp after a native tap-free click on prose; touch is out of scope, see §6.

Capture two checkpoints per cell, each as a full-viewport `page.screenshot()` PNG:
- **C-mounted**: at step 3. Expected near-identical on B and A.
- **C-held**: at `S_hold`.

| Row | Viewport | Theme | Checkpoints | Expected |
|---|---|---|---|---|
| ST1 | 1440×900 | light | C-mounted, C-held | C-mounted: diff limited to time/duration labels and the caret; C-held: **behavior under test** (B shows the bottom of P120/tool row, A shows the reading position at `firstVisible` from `S_up`) |
| ST2 | 1440×900 | dark | same | same |
| ST3 | 390×844 | light | same | same |
| ST4 | 390×844 | dark | same | same |

Each of the 8 pairs (4 cells × 2 checkpoints) produces B.png, A.png, **Diff0** and **DiffT**:
- Both sides are alpha-composited onto opaque white. No registration, resizing, cropping or masking.
- **Diff0** = |RGB| × 8, clamped at 255; count pixels with max channel > 0.
- **DiffT** = magenta where max |RGB| > 8/255, black elsewhere; count those pixels.
- Write the counts, the bbox and the threshold or gain to `pixel-diffs.json`.
- C-held must **not** be equalized. The B/A difference is the defect and must be annotated as such.
- Every nonzero C-mounted pixel needs a named cause (clock label, caret, focus ring). Otherwise, fix the setup and recapture.
- Record `m()` beside each still. A's C-held `firstVisible` must equal its `S_up`.

## 6. Out of scope, with reasons

- **Electron:** it renders the same web bundle, so the listener lifetime change is covered by the web rows. Native scrollbar drag and OS momentum scrolling in Electron are not exercised; label them UNVERIFIED, not PASS.
- **Native mobile (React Native):** the follow logic is separate and unchanged by this diff. The 390 wide rows are narrow *web*, not mobile.
- **Touch:** headless Chromium can emit `Input.dispatchTouchEvent`, but the `touchmove` handler only breaks follow once the viewport leaves the end band. Producing a real scroll that way needs `Input.synthesizeScrollGesture` with touch source, which is a synthetic gesture rather than a finger. Rather than over-claim, touch stays HARNESS/UNVERIFIED. Wheel and PageUp cover the same attach path, because all five listeners are installed in one effect from one node.
- **Scrollbar drag, ctrl-wheel, nested scrollers, composer PageUp, iframe chaining, reduced motion:** the listener bodies are unchanged between B and A, as the diff only moves their installation. Attachment is proven by the listener counts in W1/W2. These remain UNVERIFIED as acceptance trials and are not required to close this PR's causal gap.
- **List→placeholder→new list replacement and same-node thread switch:** pass 1 recorded these as PARTIAL/PASS. A's cleanup is visible in the `__fx.listeners` remove entries if a W run navigates away, but no new row is required here.
- **Providers other than the ACP mock:** the defect is in the renderer, so provider type does not matter. The real GPT-6-Astra reproduction in the PR body stays historical evidence.
- **Remote, relay or tunnel connection modes:** the same frames arrive later. The in-page hold models exactly that latency.

## 7. Verdict rule for the pass that executes this

- **SUFFICIENT** only if all of these hold:
  - S0 passes on B and A.
  - W1 and W2 show the B snap **and** the A hold, with listener counts B 0 / A 4+1.
  - W1p and W1 resume pass on A.
  - W3 passes on both.
  - Every clip passes phase-range verification.
  - All 8 still pairs have Diff0 and DiffT outputs with C-mounted deltas explained.
- If B does not reproduce under a verified hold of ≥ 30 frames, report **NOT REPRODUCED** and keep the verdict INSUFFICIENT. Do not tune the setup until B fails.

Audit provenance:
- This pass was read-only. No edits or commits in `~/wt/pr-22788`, no servers, no browsers.
- The worktree and test commands above are for the capture agent.
- The matrix is derived from `git diff 8d3b061121 6f2d6c63e3`, the fixture scripts, and the cited source lines on fs-08, 2026-10-22.
