# Issue 21397 — read-only proof audit, pass 1

Scope: diff against e54e71d725 in /home/ubuntu/wt/issue-21397. Inspected HEAD 42a6c6fa9922ca5b9a99f49308667a6c0bf48c23; implementation became committed during inspection. Diff remains one file, UsageLimitRecoveryBanner.tsx (+13/-11). No repository edits, browser/server launch, or forge mutations performed. This is a proof plan, not executed UI verification.

## Shared real-app fixture

Use the lead's already reproduced failed run + root provider error item with failure class usage_limit. Let R be latestRun.runId, T the runtime/error resetAt (same exact string), S the run completion time, and N browser time. Require S < T. Runtime status must be failed; shell latestRun must exist. Recovery is {runId:R, resetAt:T, snooze:true, autoResume:false}; shell snoozedUntil represents T. Keep the thread unarchived, unsettled, and free of pending runtime requests so the real metadata mutation is accepted. Disable default auto-resume/auto-snooze for this sandbox so they do not confound the proof or launch an agent. Use a stopped run only.

Minimum visual set: row A at 1440px/light, 1440px/dark, 390px/light, 390px/dark (four screenshots, fixed reasonable height, e.g. 900px). This is narrow web, not native mobile. Preserve the normal chat composer and banner stack. Check both action labels, wrapping, clipping, contrast and click targets. Do not multiply every functional row by four skins.

| Row | Small fixture delta / action | Expected proof | Capture |
| --- | --- | --- | --- |
| A — active matching snooze | Shared fixture; T comfortably in future, N < T | Warning shows Usage limit reached, reset description, Resume at reset, and enabled Wake now. At 390px, controls remain readable and reachable; description may intentionally be hidden behind notice details by existing container layout. | Four screenshots: 1440 light/dark + 390 light/dark. Before/after of this exact fixture against base is useful if later required for PR evidence; base omits Wake now. |
| B — wake and reverse | From A, click Wake now; observe persisted shell, then click Snooze until reset before T. Include keyboard activation for one click. | First request carries {runId:R, resetAt:T, snooze:false}; real server clears owned snoozedAt/snoozedUntil; button becomes Snooze until reset. Reverse sends snooze:true, restores owned snooze and Wake now. No provider turn starts. Both controls disable and show Saving... while request pending. | RECORDING, 390 dark; include stable before/after frames. If latency is too short to see Saving..., briefly delay this mutation response in the later authorized browser session. |
| C — expiry without navigation | Shared fixture with T ~10 seconds ahead, autoResume:false; keep view mounted through T, retaining recovery and snoozedUntil. Then reload with same expired metadata. | At reset, Wake now changes to disabled Snooze until reset without click/reload. Resume at reset remains as existing behavior. On reload, expired matching metadata also renders disabled Snooze until reset immediately. No re-snooze request fires. A recording that lets server clear metadata first cannot prove the stale-metadata branch; verify stored/received shell values. | RECORDING, 1440 light, across reset; add post-reload frame to same recording. |
| D — snooze ownership guard | Future T; keep recovery.snooze:true but set snoozedUntil=null, then a different future instant. Other identity inputs stay matching. | Both show enabled Snooze until reset, never Wake now. These two values cover absent and unrelated thread snooze; do not clear an unrelated snooze merely by viewing. Different runId/resetAt also fail the unchanged identity predicate; source inspection suffices for those unchanged guards. | Two functional observations in one 390 dark session; screenshots optional. No recording required for static fixtures. |
| E — new wake action failure / sibling action | From A, reject one Wake now mutation using isolated response fault; then retry successfully. In a fresh future matching snoozed fixture, turn autoResume on, then cancel it. | Failed wake produces existing Could not snooze thread toast, clears pending, retains Wake now and persisted snooze. Retry works. Resume toggle changes only autoResume and its label to Cancel auto-resume, preserving Wake now; cancel restores Resume at reset. Confirm no duplicate dispatch during pending. | RECORDING, 390 dark; may append to B recording. Fault only the intended sandbox request. |

Rows A–C directly prove the changed branches; D protects the ownership distinction that chooses the new label; E checks the newly reachable wake mutation and its shared action state. No separate provider/theme/stack/state cross-product is justified by this one-file tweak. Existing null-reset and T <= S gates still remove actions entirely; inspect them as unchanged controls rather than adding visual fixtures. A reset race between enabled paint and clicking Snooze is already guarded by toggle's Date.now check and the server; optional focused fake-clock test, not another real-app capture row.

## Caller and surface trace

Only caller: apps/web/src/components/ChatView.tsx around line 2583. It gates on failed runtime + usage_limit + latestRun, reads runtime.usageLimitResetAt and shell recovery/snoozedUntil, then uses updateThreadMetadata; Failure is thrown into RecoveryActions' toast path. ComposerBannerStack renders warning/urgent with wrap-actions layout. The desktop app uses this web UI; no Electron-specific code changed. Native mobile has its own UI and is outside this web-only diff. Settings, command palette, providers, contracts, and transport paths were not changed, so no extra fixture families. Existing header Wake in useThreadLifecycle uses the same recovery-aware metadata update when recovery owns snooze; source comparison confirms alignment with the new banner Wake, without requiring a second UI entry-point recording.

## Seed the real web app (instructions only; not run)

Prefer retaining the lead's working isolated fixture rather than rebuilding it. If a fresh sandbox is needed, with that worktree's dev server stopped run `vp run migrate-dev-db` from /home/ubuntu/wt/issue-21397. This snapshots the live ~/.t3/userdata/statev2.sqlite read-only into the worktree's .t3/userdata/statev2.sqlite, preserving recent stopped threads and removing tasks/pending work/auth sessions. Raise --projects / --threads-per-project only if needed. Never start against live state, symlink it, or edit it. Do not overwrite the lead's prepared sandbox blindly.

In the isolated clone, reuse the lead's fixture recipe to keep the failed run, its root error turn item, runtime projection, and shell/recovery consistent. Merely changing a shell label is insufficient: the real metadata service validates the run and error item, exact resetAt, stopped-before-reset, and absence of pending runtime requests. Set future T consistently in the failed error/runtime and recovery plus matching snoozedUntil, with S < T. For C retain the same metadata across the deadline with autoResume false. Use the ordinary web app, not a standalone component harness. Any later launch should follow test-t3-app and explicit browser authorization, use worktree state, and leave VITE_HTTP_URL/VITE_WS_URL unset. No launch was performed in this audit.

## FRICTION

- No current UI proof was captured: this pass was expressly read-only with no browser/server launch. The prior local screenshot is a lead-provided reference, not evidence newly inspected or reproduced here: /home/ubuntu/.t3/userdata/browser-artifacts/browser-screenshot-localhost-mvjwtj3z-9956ade2.png.
- Expiry proof must preserve stale recovery+snoozedUntil long enough to observe the component's own timeout; a server update that clears them can mask the exact branch under audit.
- The new Wake now action reuses an error title saying Could not snooze thread. This is a pre-existing shared handler string newly exposed on wake failure; record it accurately, and let the lead decide whether to refine copy. No source edit proposed by this proof pass.
- Slow/pending and failure states need controlled sandbox response delay/rejection; ordinary fast success does not prove them. This is a capture prerequisite, not a reason to add a large fixture harness.
- No blocking setup friction found by source inspection. ghdrop upload outcome is reported alongside the artifact link.
