# Proof audit, pass 1: thread subscription recovery

Issue [#23041](https://git.cafe/pingdotgg/t3code/issues/23041). Branch `fix/issue-23041`. Reviewed [16e2e5c656](https://git.cafe/pingdotgg/t3code/commit/16e2e5c6561e8f1892a2322628c9258d99bd384d) against [edf5a24b56](https://git.cafe/pingdotgg/t3code/commit/edf5a24b568371b27f23cc5fff92b4a225df3276), the supplied `origin/main`; the local `main` ref was not used. Read-only audit of `/home/ubuntu/wt/issue-23041`, 2026-10-27. No browser, server, device, source edits, pushes, or forge comments.

## Findings

1. **No blocking visual-proof gap. Required capture set: empty (0 viewport × theme × fixture combinations).** This diff changes shared subscription state and extracts an existing backoff formula; it changes no React component, markup, styling, viewport rule, or theme rule. AGENTS.md explicitly requires fail-first tests for logic/state/protocol defects. The blank pane is the downstream symptom of a dead subscription, not a spacing/layout defect. Before/after images are not a prerequisite for this fix.
2. **The regression proof is red/green.** The new test injects a stream defect with no cache and an unavailable HTTP snapshot, awaits the error, advances TestClock, requires a second subscription without a session replacement or reselection, then delivers a healthy snapshot and requires live data with the error cleared. I independently ran the head test against the two changed production modules loaded from the exact base commit through a scratch-only Vite load overlay: it fails at `subscriptionCount === 2` with actual `1`. On the head, the complete thread-sync and RPC-client suites pass: **48/48 tests**. [Baseline failure log](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e49148b0fac8282f/baseline-red.log); [head focused-suite log](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/1a3b9f097493e79b/focused-tests.log); [scratch baseline overlay used for replay](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/5e5796406957cc1b/baseline.config.mjs). History also places the regression test in [6e353ff685](https://git.cafe/pingdotgg/t3code/commit/6e353ff685) before the implementation commit.
3. **A still cannot prove automatic recovery.** If the lead elects to add illustrative product evidence, use the one optional recording pair below. A healed screenshot alone cannot distinguish retry from manual navigation, shell arrival, HTTP recovery, reconnect, foreground wakeup, or cache restoration.
4. **Scope the claim.** This proves recovery from a terminated detail subscription. It does not identify the reporter's original trigger, establish a Windows/Electron-specific cause, or add visible error/retry UI. The regression test injects a defect at the decoded-stream boundary rather than running the HTTP/socket decoder. That is faithful to the failure boundary: installed Effect's RPC chunk decoder uses `Effect.orDie`, forwards the failure to the individual request, and leaves the socket reader alive. A real-wire optional recording must exercise that decoder rather than simply setting atoms.
5. **Mobile shares the fix, not the web blank rendering.** A shell-less main mobile route can redirect to a new draft as soon as the detail error appears, thereby releasing the failed route before retry. Do not claim this change guarantees automatic recovery of that native navigation case. A listed/cached native thread that remains mounted can consume recovered detail. Native end-to-end behavior was not exercised in this audit.

## Changed code and caller/render inventory

There are **zero changed components**. These are the changed functions and their downstream render branches; unrelated ChatView features are not a new visual test surface.

- [threads.ts](https://github.com/pingdotgg/t3code/blob/16e2e5c6561e8f1892a2322628c9258d99bd384d/packages/client-runtime/src/state/threads.ts#L880): `makeEnvironmentThreadState` now wraps its thread follower in a retry loop. A successful applied batch resets `defectRetries`; a non-interrupt failure sets the generic stream error, sleeps, and starts a fresh follower. Interrupts propagate. Delays are 250 ms, 500 ms, 1 s, 2 s, 4 s, 8 s, 16 s, then 30 s capped. Retries still run the existing input builder: deleted parks forever; cached/present data resumes after its sequence; absent data loads HTTP (`present` / `missing` / `unavailable`); completion-marker support and bounded/compact capabilities are retained. `makeEnvironmentThreadState` is called by `threadStateChanges`, then `createEnvironmentThreadStateAtoms`, plus tests.
- [rpc/client.ts](https://github.com/pingdotgg/t3code/blob/16e2e5c6561e8f1892a2322628c9258d99bd384d/packages/client-runtime/src/rpc/client.ts#L107): `subscriptionRetryDelay` has exactly two production call sites: the existing expected-failure retry branch in `subscribeDynamicMapped`, and the new thread follower. The former uses the same formula as before. Expected domain errors, authorization failures, transport failures, session changes, and wakeups retain their existing handling. This is not a global change making every RPC defect retry.
- The state factory is instantiated in [web state](https://github.com/pingdotgg/t3code/blob/16e2e5c6561e8f1892a2322628c9258d99bd384d/apps/web/src/state/threads.ts#L36) and [mobile state](https://github.com/pingdotgg/t3code/blob/16e2e5c6561e8f1892a2322628c9258d99bd384d/apps/mobile/src/state/threads.ts#L40). Web consumers include `ThreadRouteView` for unlisted threads; ChatView and panels through derived detail atoms; running-thread keep-alive mounts; and LegacySidebar's detail prewarmer. Mobile consumers include selection, selected detail/projection/feed hooks, and selected-worktree resolution. Consumers of derived detail atoms use the same per-environment/per-thread state, not independent retry implementations.
- [ThreadRouteView](https://github.com/pingdotgg/t3code/blob/16e2e5c6561e8f1892a2322628c9258d99bd384d/apps/web/src/components/ThreadRouteView.tsx) has one JSX caller, [ChatRouteLayout](https://github.com/pingdotgg/t3code/blob/16e2e5c6561e8f1892a2322628c9258d99bd384d/apps/web/src/routes/_chat.tsx#L37). Props are `target` (`server` reference or `draft` id) and `background` (Settings/Usage keeps the preceding target mounted and inert). Draft session renders draft ChatView with expanded narrow-screen composer; a promoted draft renders server ChatView and redirects with the same key; a missing draft redirects home. Server `ready` renders ChatView. Server `loading` renders ChatView only with a shell row; otherwise it renders the empty inset. `missing` clears pending file drops and redirects home only when bootstrap is authoritative, another thread/draft exists, and the view is foreground. Background suppresses redirects. Draft-to-server handoff keys preserve the ChatView through promotion.
- [Route resolution](https://github.com/pingdotgg/t3code/blob/16e2e5c6561e8f1892a2322628c9258d99bd384d/apps/web/src/threadRoutes.ts#L33) reads: draft-thread existence; shell-row existence/deletedAt; shell status (`live` makes bootstrap authoritative); detail presence/status; detail projection deletedAt/archivedAt. Draft or nondeleted shell wins `ready`; nonauthoritative bootstrap yields `loading`; deleted shell or `detail.status=deleted` yields `missing`; absent projection remains `loading` **even with an error**; a present active projection is `ready`, archived/deleted projection is `missing`. Whether any other thread/draft exists is a zero-versus-positive branch, not a reason for multiple list-count fixtures.
- [ChatView](https://github.com/pingdotgg/t3code/blob/16e2e5c6561e8f1892a2322628c9258d99bd384d/apps/web/src/components/ChatView.tsx#L505) consumes detail/status through atoms; [resolveThreadSyncPhase](https://github.com/pingdotgg/t3code/blob/16e2e5c6561e8f1892a2322628c9258d99bd384d/apps/web/src/threadSync.ts) distinguishes shell missing, no detail (`loading`), existing detail (`syncing`), and live/deleted (no sync label). Cached detail remains visible on failure. Empty and nonempty timelines differ visually but recovery logic does not depend on message length, account count, name length, provider, or theme. Loading/sync labels have an existing delayed display.
- [Native route](https://github.com/pingdotgg/t3code/blob/16e2e5c6561e8f1892a2322628c9258d99bd384d/apps/mobile/src/features/threads/ThreadRouteScreen.tsx#L279) distinguishes missing ids, selected shell/detail or pending-creation shell, hydrating, main-route unavailable (draft redirect), and inspector unavailable (reconnect/manage action). [Selection](https://github.com/pingdotgg/t3code/blob/16e2e5c6561e8f1892a2322628c9258d99bd384d/apps/mobile/src/state/use-thread-selection.ts#L155) can synthesize a shell from detail or queued creation. [Content presentation](https://github.com/pingdotgg/t3code/blob/16e2e5c6561e8f1892a2322628c9258d99bd384d/apps/mobile/src/features/threads/threadContentPresentation.ts) renders existing detail first, otherwise deleted/error/offline unavailable or connected/loading. Pending creation bypasses detail presentation with preparing/failed creation states. Android's opening loader uses its existing embedded presentation; iOS uses its existing loader presentation. No platform branch changed.

## Fixture matrix

All visual statuses below are **source-derived, not captured**. “Affected” describes a reachable state/transition, not changed markup. `recording` marks any fixture driven by time or interaction; a still is insufficient for its transition claim. Every capture is waived for the required proof. Normal routes are real app rows; injected rows are real app rows only when driven through real HTTP/WS as described below. Direct atom/component mocks must be labelled **harness capture** and cannot establish real transport recovery.

| ID / fixture | Data and branch / visible result | Diff affects it? | Real app seed or driver | Evidence mode if captured | Pass-1 status |
|---|---|---|---|---|---|
| W1 — unlisted cold failure → recovered empty thread | Shell is live, target B has no shell row, draft, retained state or disk cache; HTTP unavailable; one socket decode defect. Before: detail `empty+error`, blank inset. After: new subscription, valid active projection with zero visible items, ChatView/header/composer appears without navigation. | **Yes; primary regression.** | Procedure P below; create B by real `thread.create`, route `/{environmentId}/{threadId}`. Keep HTTP failing and shell held through recovery. | **recording** | Required state proof **RED/GREEN verified**. Optional capture not taken. |
| W2 — unlisted cold failure → transcript | W1 except healthy target has at least one synthetic visible message/turn item. Recovered ChatView shows messages. | Yes; same recovery branch as W1. | P with an already-created synthetic stopped conversation; ensure B is withheld from shell and use a fresh browser profile. | **recording** | Collapsed with W1 for proof; no extra capture required. |
| W3 — listed, no detail | Target shell exists, no projection; connected; empty/cached/synchronizing status shows chat chrome and delayed “Loading messages...” instead of W1's absent ChatView; healthy detail restores content. | Yes if its detail follower dies; no route-layout change. | Select a listed synthetic target with cold detail cache; P HTTP/socket faults only, without holding its existing shell. | **recording** | Source-derived control; no required capture. |
| W4 — cached/live detail survives defect | Shell listed or shell-less with present active projection; visible transcript remains; state becomes cached/error then synchronizing, then live. Existing delayed “Syncing messages...” may show. | Yes; retained data continues while retrying. | Open synthetic target to populate cache/live detail, then inject one target detail defect. Remain on the route. | **recording** | Cache/domain/session controls passed in focused suite; defect-specific warm transition source-derived. No required capture. |
| W5 — healthy load, no defect | Listed or unlisted valid thread; active detail loads over HTTP or socket; empty and nonempty visual endpoints are W1/W2. | Healthy behavior unchanged. | Normal UI route; no fault injector. Fresh profile for cold load. | **recording** | Existing HTTP/socket fallback tests passed; no required capture. |
| W6 — bootstrap incomplete, no shell | Shell status is not live; no draft/shell; absent or even present detail is held behind `loading`, leaving empty inset until bootstrap completes. | Retry can repair detail; independent shell bootstrap is still required. | Delay initial shell completion in a proxy; visit valid B. Release shell to show completion. | **recording** | Source-derived control; no required capture. |
| W7 — definitive missing, other threads exist | Authoritative shell; deleted shell, definitive HTTP miss/detail deleted, or shell-less archived/deleted projection; another thread/draft count > 0. Foreground redirects home and clears pending drops. | Missing semantics unchanged; retry must not resurrect it. | Visit a real deleted/archived/nonexistent target with another synthetic thread. A structured missing response is needed; arbitrary 503 is not missing. | **recording** | Deletion and HTTP-missing tests passed; no required capture. |
| W8 — definitive missing, environment otherwise empty | Same missing shapes as W7; other threads/drafts count = 0. No navigation is issued; inset remains empty. | Unchanged. | Isolated environment with no other visible/draft threads; visit deleted/missing target. | **recording** | Source-derived zero-count control; no required capture. |
| W9 — draft exists | `target.kind=draft`, live draft session; draft ChatView/hero, forced expanded mobile-width composer. Server route with matching draftThread also counts ready before shell membership. | Draft display unchanged; server follower relevant only after handoff. | New-thread UI; `/draft/{draftId}`. For server draft fallback, submit and retain pending draft during promotion. | **recording** | Source-derived unaffected branch; no required capture. |
| W10 — promoted draft | Draft session gone, promoted redirect present; server ChatView with same draft key, URL replaced; later visits use shared unkeyed view. | Retry can help subsequent server detail; promotion logic unchanged. | Send first message through normal draft flow. For controlled recovery use P only after promotion; a retained draft can mask W1, so W1 uses a direct cold route. | **recording** | Source-derived unaffected handoff; no required capture. |
| W11 — draft missing | Neither draft session nor promoted redirect; navigate home, no ChatView. | Unchanged. | Fresh browser with an unknown draft id route. | **recording** | Source-derived unaffected branch; no required capture. |
| W12 — Settings/Usage overlay | `background=true`; retained server/draft target remains mounted and inert; overlay is visible, route redirects suppressed, existing sync may continue underneath. | Retry can proceed beneath modal; no foreground visual branch changed. | Open Settings or Usage from a thread, inject fault, close overlay. | **recording** | Source-derived control; no required capture. |
| M1 — native opening / shell-less error | Missing route ids or hydrating absent target shows “Opening thread…”. A detail error stops hydration; a main Thread route without inspector replaces itself with a new draft. | Shared state affected, but draft redirect may unmount before retry; no guaranteed native automatic recovery claim. | Native route to synthetic B; P transport faults; iOS/Android loaders keep their existing presentation. | **recording** | Source-derived native limitation; no native capture required for web symptom claim. |
| M2 — native shell-less inspector error | No selected shell/detail, hydration ends; inspector remains with “Thread unavailable”, reconnect action (registered) or manage-environments action (unregistered). | A registered still-mounted detail can retry; unregistered case is unchanged. | Open target in native inspector and apply P; unknown environment exercises manage action separately if needed. | **recording** | Source-derived native branch; no required capture. |
| M3 — native selected thread, detail missing/cached | A real shell selects content; absent detail gives loading, error gives “Could not load conversation”, deleted gives deleted unavailable, offline gives “Messages not cached”; present cached/live detail gives ready. Retry can replace missing/error content with ready. | Yes for mounted faulty detail; existing-detail presentation unchanged. | Select listed native target; use cold cache for absent-data cases, warmed cache for ready; inject target faults or disconnect for offline control. | **recording** | Source-derived native consumer; no required capture. |
| M4 — native pending creation | Pending creation supplies synthetic shell/prompt; preparing or failed creation state bypasses absent-detail presentation. | Creation states unchanged. | Start a task normally; a deliberately failed creation is a separate existing control, not the subscription bug. | **recording** | Source-derived unaffected branch; no required capture. |

Widths/themes/platforms: required capture cross-product is empty. Optional illustration uses **web, 1280 × 800 CSS px, dark, W1, before + after recordings** with matching sidebar configuration. That one pair demonstrates the shared web/desktop-renderer path; it does not establish Windows-shell behavior. Light theme, narrow web width, desktop hash routing, native iOS/Android, provider variants, local/relay/tunnel variants, long titles, large histories, and multiple environment counts introduce no changed layout branches here and are not required additional capture rows. W9's narrow-screen composer and native loader variants are inventoried above but unchanged. Sidebar/command-palette/keybinding/history/direct-link entry points converge on the same server target; Settings/Usage is W12. No new RPC, MCP capability, provider adapter, setting, or user action is introduced.

## Nonvisual behavioral fixtures

These do not justify screenshots. They delimit what the regression test does and does not prove.

| ID | Input / expected behavior | Changed? | Pass-1 status / appropriate proof |
|---|---|---|---|
| L1 | First decoded-stream defect; empty cache; HTTP unavailable; same session; automatic second subscribe; good snapshot clears error and reaches live. | Yes | **Verified red/green** by added test. No capture needed. |
| L2 | Repeated defects without a successful applied batch; delay grows from 250 ms to capped 30 s. | Yes, new use of existing formula | Source-derived; exact cap sequence not exercised by added regression. Additional proof, if wanted, belongs in TestClock tests, not a timed video. |
| L3 | Successful applied batch, then another defect; next delay resets to 250 ms. | Yes | Source-derived; new test covers successful recovery, not a subsequent fault/reset. No visual proof requirement. |
| L4 | Consumer scope closes or navigation releases it during backoff; interruption is propagated, no retry outlives scope. | Yes, guard in new loop | Source-derived for new loop; existing cancellation tests pass. A defect-during-backoff cancellation test was not added or executed separately. |
| L5 | HTTP `present`/`missing`/`unavailable`, warm resume cursor, completion marker, bounded/compact history, deletion. | Existing behavior reused on retry | Focused suites pass. New regression uses unavailable HTTP and no completion marker; it does not cross every option with a defect. |
| L6 | Expected domain failure, authorization denial, transport failure, replacement session, foreground wakeup. | Existing formula extracted, handling unchanged | Focused suites pass for their existing covered paths. No claim that every error is retried by the new loop. |

## Procedure P: optional fault-injected real-app recording

This is a **recipe, not an executed capturer or checked-in diagnostic feature**. Ordinary UI/settings/RPC cannot instruct the server to emit a malformed snapshot. Equivalent temporary fault injection is necessary; normal navigation stress is not a deterministic seed.

1. The lead/capturer starts an isolated development environment per `test-t3-app`, with a synthetic project and a visible control thread A. Never use live userdata. Keep the dev app single-origin; do not set `VITE_HTTP_URL` or `VITE_WS_URL`. Use a fresh isolated browser profile/context so B has neither persistent client cache nor retained state.
2. Put a disposable HTTP/WebSocket reverse proxy between that real client and its real dev origin, or apply an equivalent temporary diagnostic patch outside the committed diff. Store its source, event log, and each new timestamped run under the lead's scratch directory. It must proxy authenticated HTTP, WS, and normal traffic; do not publish pairing tokens/cookies in evidence. The proxy must parse/re-encode the protocol's JSON messages, preserving unrelated messages and RPC ids.
3. Complete shell bootstrap normally first. Then gate **only shell subscription updates** on this client, leaving the initial authoritative shell and A visible. Create valid synthetic target B through the app's authenticated `orchestration.dispatchCommand` with `thread.create` (synthetic project id, new thread/command ids, title “Recovery fixture”, valid configured model/runtime/interaction selection, null branch/worktree). No provider turn is necessary for W1's empty ChatView endpoint. Hold every shell update that would make B appear; do not let a later shell snapshot reveal it. Alternatively filter B from shell snapshots/updates consistently. Avoid creating it through the tested browser's draft flow: draft fallback would bypass the blank condition.
4. For target B only, return HTTP **503** from `GET /api/orchestration/threads/{B}/bounded` and the full `GET /api/orchestration/threads/{B}`. Current web/mobile use the bounded loader first. A plain full-snapshot-only failure will not reproduce the current path. Keep 503 active through the recovery recording so a successful HTTP retry cannot be mistaken for successful socket resubscription. Do not return structured not-found: that parks the thread as deleted.
5. On client-to-server WS messages, identify `_tag: Request`, `tag: orchestration.subscribeThread`, `payload.threadId: B`; record its `id`. On the first matching server-to-client `_tag: Chunk`, find the `kind: snapshot` in `values` and replace that snapshot's `projection` with `null`. Corrupt **exactly one target snapshot across the entire run**, not once per request. Preserve valid outer JSON, `_tag`, requestId, and all other subscriptions. This must happen **after server serialization**, so the real client schema decoder fails. Returning an invalid typed object before the server encoder, disconnecting the socket, throwing in a proxy, setting an error atom, or filling the pane with a mocked component are not equivalent evidence.
6. Start the recording, then navigate directly to `/{environmentId}/{B}` (desktop uses `#/{environmentId}/{B}` through the same web route). Leave the tab foreground, keep that URL selected, and do not resize, navigate away, reconnect, reload, or release shell membership. The first subscription dies; the existing socket/config/shell should remain usable. In the base, no second detail request is made; the pane remains blank for the recorded interval. In the head, the first retry is approximately 250 ms plus real load/transport work: another HTTP attempt gets 503, a second thread subscription gets an unmodified snapshot, and ChatView mounts. If a longer blank interval is needed for legibility, gate the *healthy retry response* at the proxy and explicitly label that artificial delay; do not alter product retry timing.
7. Log target subscription attempts, the single snapshot mutation, HTTP statuses, stable connection/session, and the point at which healthy detail is delivered. Assert shell B remained withheld, the route stayed selected, and no reconnect/wakeup/reselection caused recovery. Capture any diagnostic status through external logs, not product UI. Stop recording before optional manual A→B reselection; if showing that workaround on the base, label it explicitly. A title/composer is a sufficient W1 recovery oracle; a synthetic transcript is W2.
8. Use identical faults and synthetic data for base/head, and label both recordings **fault-injected real app; original incident trigger unconfirmed**. One dark 1280×800 pair is enough if the lead wants illustration. A direct store/component-state driver instead of this real decoder path is a **labelled harness capture** and does not replace L1's state proof or establish wire recovery. Keep the injection uncommitted, restore any diagnostic source edits in the capturer's own checkout, upload all evidence before cleanup, and stop only owned processes by tracked PID.

## Coverage, ownership, and cleanup

Read the exact three-file diff, thread state/loading/apply paths, RPC failure classification and installed decoder boundary, route caller/resolver, web sync presentation, native route/selection/content presentation, the issue, and existing focused tests. Executed only the two focused suites on head and one baseline-overlay regression. No broad checks, browser, native client, dev server, fault proxy, real Windows run, or capture was launched. No lead-owned worktree was created, modified, or removed. Required production source and test files remain clean. All audit evidence is uploaded before removal of the step-owned scratch subdirectory. The lead-owned parent scratch directory is preserved.

FRICTION: none

VERDICT: SUFFICIENT
