# PR #22775 proof audit pass 1: web and shared surface matrix

Head `e8fbc9baea51552c05e76828bb06e36f4a33c60c`. BEFORE = merge-base `8acbefefc51b4d281dcd04108c82baa1ab902b0e`, AFTER = head. Server rows R1–R5 carry over (server files byte-identical to 866e273265, already audited SUFFICIENT). This matrix covers only the **web** surface and the **other/shared** surface (`packages/shared/src/orchestrationV2ThreadError.ts`, `packages/client-runtime`).

All usage-limit failures below are **SYNTHETIC FIXTURES**: a provider `error` turn item with `failure.class = "usage_limit"`, `code = "usageLimitExceeded"`, and a chosen `resetAt`. No real Claude quota or credentials are involved.

## What the user sees for a usage-limited thread (web)

| UI element | Source | Condition |
|---|---|---|
| Composer banner "Usage limit reached" / "Resets <local time>", actions **Resume at reset** ⇄ **Cancel auto-resume**, **Snooze until reset** | `ChatView.tsx` `limitRecoveryBanner` → `chat/UsageLimitRecoveryBanner.tsx` | `serverRuntime.status === "failed" && lastErrorClass === "usage_limit" && activeThreadShell.latestRun`. `runId` = **shell** `latestRun.runId`; "Cancel auto-resume" shows only when `thread.limitRecovery.runId === runId`. Actions are rendered only if `resetAt > stoppedAt`. Snooze is disabled once `now >= resetAt` (a client timer flips it). |
| Composer primary button **"Resume thread"** (replaces Send when the draft is empty) | `ChatView.tsx` `resumableRunId` → `ComposerPrimaryActions` `showResume` → `useComposerResume` (`startThreadTurn{manualContinuationOfRunId}` or `resumeThreadQueue`) | `resumableRunId !== null || hasHeldQueuedRuns` |
| Sidebar row status pill **"Limited"** and hover card **"Usage limit reached"** (warning colour) | `Sidebar.logic.ts:914`, `SidebarThreadRow.tsx:348/675` | shell `thread.runtime.status === "failed" && lastErrorClass === "usage_limit"` |
| Timeline thread-error banner (`ChatBannerLane`) | `ChatView.tsx` `timelineThreadError` | Suppressed while the recovery banner owns the usage-limit error |

Data paths: `serverRuntime = deriveThreadRuntime(serverProjection)` runs on the client (client-runtime → shared `usageLimitRunPresentedAsLatest` → `usageLimitBlockedRun`). Shell `latestRun`/`runtime` comes from the server shell SQL (`ProjectionStore.ts` "blocked" join, changed in this PR). So web AFTER behaviour depends on both the shared filter (client) and the server shell SQL. Both must agree on the limited source L, or the banner shows "Resume at reset" for an already-armed L.

## Row matrix

Fixture timeline: L = source run, failed with a synthetic usage_limit, `resetAt = T_reset`. W = wake run, whose first root input is a `notification` (`source.kind = background_task`, outcome completed) and which completes. M = continuation.

| Row | Kind | State | BEFORE (merge-base) visible | AFTER (head) visible | Expected difference |
|---|---|---|---|---|---|
| **W0** | still | L failed, no wake, now < T_reset | Banner "Usage limit reached · Resets …" with **Resume at reset** and **Snooze until reset**. Composer shows **Resume thread**. Sidebar **Limited**. | Same | **None** (baseline control) |
| **W0a** | recording | W0 plus click **Resume at reset** | Button flips to **Cancel auto-resume** | Same | None (control for arming UI) |
| **W1** | still | L failed, W (notification-first) **completed before T_reset**, not armed | **No banner.** Composer shows **Send** (no Resume thread). Sidebar shows the completed/ready state, **not Limited**. Hover card has no "Usage limit reached". | Banner still shown (Resets T_reset, **Resume at reset**, **Snooze until reset**). Composer shows **Resume thread**. Sidebar **Limited**. | **Core fix.** Banner, Resume, and Limited pill disappear BEFORE and persist AFTER. |
| **W2** | still | As W1, but armed before W (`limitRecovery{runId:L, autoResume:true}`) | No banner, so the armed state is invisible and cannot be cancelled from the UI. Sidebar not Limited. | Banner shows **Cancel auto-resume**. This proves the shell `latestRun.runId === L` matches `limitRecovery.runId`. | Armed state is visible only AFTER. **Watch for the bug class:** "Resume at reset" on AFTER would mean shell/client disagree. |
| **W3** | recording | W1 → W running → W completes before T_reset | Banner disappears when W starts (status running) and **never returns** | Banner disappears while W runs, then **returns** when W completes | Return of the banner after the wake |
| **W4** | recording | W1 state, click **Resume thread** (pre- or post-reset) | Not possible: button absent | Click dispatches `manualContinuationOfRunId = L`. A new user message "Continue where you left off." appears and a run starts (mock peer). Banner clears. | Manual resume reachable only AFTER (server R4 accept carries over) |
| **W4q** | recording | W1 plus a held queued message (`queueHeld:true`) | **Resume thread** is shown (via `hasHeldQueuedRuns`), but the click calls `resumeThreadQueue`, so the queued message runs and L is not continued | Click continues L first; the queued message stays behind the continuation | Which work runs on Resume. Read it from the timeline order. |
| **W5** | recording | W2 (armed) through T_reset | Nothing happens at reset. The thread stays completed with no banner, and no continuation appears. | At reset the sweep dispatches the continuation. A new "Continue…" turn appears, the banner disappears, and the sidebar goes **Working**. Exactly one continuation run. | Auto-resume fires only AFTER (server R1/R2 carry over) |
| **W6** | recording | W1, unarmed, wall clock crosses T_reset with no new run | n/a (no banner) | Banner remains. **Snooze until reset** becomes disabled at T_reset. **Resume at reset** is still clickable. **Resume thread** is still available. | AFTER-only rendering. Confirms the at-reset timer path renders sensibly for a source kept alive by a wake. |
| **W7** | still | L failed, W notification-first **completed after T_reset** (e.g. T_reset + 2 s) | No banner, completed, Send, not Limited | **Same**: no banner, completed, Send, not Limited | **None** (supersession boundary, server R5 carry-over). A difference here is a regression. |
| **W8** | still | L failed, then a **user_message**-first run completed before T_reset | No banner | No banner | **None** (ordinary user turns still supersede) |

Stills for W0, W1, W2, W7, and W8: web at **1440×900 and 390×844, light and dark**, on both revisions (5 rows × 2 widths × 2 themes × 2 revisions = 40 frames). At 390, the banner sits in the collapsed composer stack, so also capture the expanded stack. The sidebar pill needs the 1440 frame, or an opened sidebar sheet at 390. For recordings (W0a, W3, W4, W4q, W5, W6), one 1440 light take per revision is enough.

## Other/shared surface rows

| Row | Consumer | Proof |
|---|---|---|
| S1 `usageLimitBlockedRun` skips a notification-first completed run with `completedAt <= resetAt` | shared, used by server Orchestrator (sweep/Resume/Stop), client-runtime, web ChatView | **Proven** by `threadExecution.test.ts` (head: 11/11 pass, log `client-runtime-test.log`): wake at reset+1 min supersedes → `completed`. Wake at exactly reset (inclusive) → blocked = L. |
| S2 `deriveThreadRuntime` keeps `failed/usage_limit` after a pre-reset wake | client-runtime → web `serverRuntime`, banner condition | **Proven** (`deriveThreadRuntime(woke)` → failed/usage_limit) |
| S3 Running wake → `running`, no error | client-runtime | **Proven** |
| S4 Wake without a notification item, or with the notification on a child node (not root), supersedes | client-runtime | **Proven** (`turnItems:[item]` and `nodeId: child` cases → completed) |
| S5 Earliest root input wins: user_message ordinal 0 before the notification supersedes | client-runtime | **Proven** |
| S6 Missing/invalid `resetAt` → NaN, so no wake is filtered | shared | Not in client-runtime tests. Server regression tests (R8) cover it. Not UI-visible beyond "Reset time unavailable". |
| S7 Web `resumableRunId` prefers L over the completed W | web ChatView | **Not unit-proven.** The logic is inline in ChatView. Only W4/W4q prove it. |
| S8 Shell SQL "blocked" agrees with client `usageLimitBlockedRun` (banner `runId` = L) | server shell → web banner, sidebar, mobile card | Server rows R1/R2 show live shell `failed/usage_limit` with the blocked source L. **W2's UI cross-check is the only rendered proof.** |
| Mobile | `UsageLimitRecoveryCard` reads shell `latestRun`/`runtime`/`limitRecovery` | **N/A by path** (no apps/mobile change). Behaviour does change through the server shell SQL: the W1/W2 equivalents would show the card AFTER and hide it BEFORE. Not verified here. |

## Seeding approach (concrete)

There is no live path to create the wake. The WebSocket transport rewrites client provenance and rejects client-created provider notifications (see server R4 notes). So stage history offline, then serve the UI.

1. Recover the removed runner: `git show 9fd419a6e9:apps/server/scripts/usage-limit-evidence.fixture.ts` (with `usage-limit-evidence.fixture.json` and `usage-limit-evidence.ts`) into a scratch dir **outside** both worktrees. Copy it into each revision's `apps/server/scripts/` as an untracked file and record its sha256. Its `offline(home,row,stage)` builds `RuntimeLayer.layerEventSink` + Orchestrator against `<home>/userdata/statev2.sqlite`. It creates the project via ProjectStore and dispatches the source turn. It writes the synthetic `error` item `{class:"usage_limit", resetAt}` and the notification wake (`source:{kind:"background_task"}`) through EventSink, then can arm (`limitRecovery`) or hold a queued run. Reuse its R1 (W1), R2 (W2), R4 (W4q), and R5 (W7) stages. Add a W8 stage with a user_message-first run. The script must be pointed at the disposable home only.
2. Per revision and row: `H=/tmp/ev-22775/audit1/home-{before|after}-Wn/.t3`. Stop any server, run the offline stage into `$H`, then `vp run dev --home-dir $H` from that revision's checkout. Read ports from `[dev-runner]`, and open the browser through test-t3-app (with permission). Never touch `~/.t3/userdata`, and never set `VITE_*_URL`. Optionally seed realistic neighbours with `vp run migrate-dev-db` first, so the sidebar is not empty.
3. Clock: there is no clock freeze. The server sweep and `RecoveryActions` timer use the wall clock. Pick `resetDelayMs` per row:
   - W1/W2/W8 stills: `resetAt = now + 30 min`, so the wake completes well before reset and the shot is taken pre-reset.
   - W5/W6 recordings: `resetAt = now + 3–5 min`, stage W immediately, start recording ~30 s before reset, and stop after the continuation run appears. The sweep trace span is the milestone, as in R1.
   - W7: `resetAt = now + 60 s`, wait past it (the fixture does `R+2 s`), then complete W.
   - Browser-only fast-forward (CDP `Emulation.setVirtualTimePolicy` or overriding `Date.now`) would only move the client timer, not the server sweep, so do not use it for W5.
4. The continuation in W4/W5 needs a provider: use the fixture's synthetic Codex mock peer that holds the continuation open (as in R1–R5), not a real Claude account.

## Gaps and risks to watch

- S7 and S8 have no unit proof on the web side. W2 ("Cancel auto-resume" visible AFTER) and W4 (Resume continues L) are the decisive UI rows.
- Previous PR body states "UI rendering … remain unverified" and "No visual layout changed". The layout is indeed unchanged, but the **visibility** of the banner, Resume, and the Limited pill changes (W1/W2). The body should not imply there is no user-visible web change.
