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

Scope: complete diff [ff525478e2](https://git.cafe/pingdotgg/t3code/commit/ff525478e2) → [4c7f479ac9](https://git.cafe/pingdotgg/t3code/commit/4c7f479ac9), branch `fix/issue-23497`, checkout `/home/ubuntu/wt/issue-23497`. HEAD verified as 4c7f479ac95b503f906c26eb949e8812c615c136; clean working tree. No PR. Static audit only: no captures, browser, server, tests, edits to checkout, push, comments, or merge. Budget ceiling 60 minutes, not a request to spend the whole hour.

## Conclusion and exact scope

Two files changed: composerDraftStore.ts (one fallback expression plus explanatory comment) and modelSelection.test.ts (one regression test). Selected model resolution is unchanged. Previously: nonempty draft options → nonempty thread options → nonempty resolved project/settings options → null. Now: nonempty draft options → if a thread modelSelection exists, its options or null; otherwise resolved project/settings options or null. Existing thread options omitted and options:[] both block default-option inheritance. This is not a change to provider defaults themselves, nor a universal guarantee that outgoing options are absent: descriptor normalization and implicit fastMode:false still apply downstream.

Smallest adequate visual matrix: ONE affected data fixture, FOUR viewport/theme variants, paired BEFORE/AFTER recordings. Each recording supplies three aligned keyframes (resting, focused/expanded, options menu). Thus 8 source videos, 12 still pairs, 24 source stills and 24 diff images (two per pair). Send is recorded in all four variants as part of the same run, not a separate Cartesian product. Controls below require data assertions, not more visual captures. No separate mobile or Electron capture is required for this shared web fallback.

## Real-app seed recipe (for a later authorized capture run)

Use disposable isolated T3 homes and equivalent data at each revision. Never use a live userdata directory. Use the real authenticated destination environment RPC client and `ORCHESTRATION_V2_WS_METHODS.dispatchCommand` for a command with type:`thread.create` (this is a command type, not a standalone RPC method). Supply schema-valid commandId/threadId/projectId, title, createdBy/creationSource, runtimeMode, interactionMode, branch:null, worktreePath:null and modelSelection. Use creation actor/source values from the real app command builder, not guessed enums. Await command receipt/projection, then navigate to the actual server-thread route. Do not seed by sending a first message: that can save the inherited options and erase the defect.

Use Settings → project defaults, or the real `server.updateSettings` command, to set environment `defaultModelSelection`; leave the project override inheriting. A second equivalent setup uses the project-scoped Settings default. ChatView passes the resolved activeProjectSettings.settings.defaultModelSelection, so these collapse to one fixture. Keep provider instance and model identical in the default and saved thread. Select an installed ready model whose live descriptor exposes a select option with a provider default different from the configured default (e.g. effort high versus its actual provider default). Confirm descriptor values before capture; do NOT assume a model slug/default is available. For concrete seed notation let I/M be that real instance/model, P=[{id:"effort",value:"high"}], and D the descriptor's default where D != high. If effort is unavailable, use a genuinely supported visibly different select value and record its id/value/default in the manifest. No synthetic catalog is needed for the minimum set.

For fixture A create `{instanceId:I,model:M}`; set resolved default to `{instanceId:I,model:M,options:P}`. Open in a fresh isolated browser storage profile with no composer draft/sticky selection for that target, no prompt-injected ultrathink, no reported runtime option overrides, no active turn, and a writable connection. Prefer an unstarted thread. Merely navigating from another thread can carry a draft; inspect the actual selection/draft before capture. The provider-default label can be implicit or omitted; verify the displayed value against descriptors rather than inventing a literal label.

## Fixture/data matrix

| ID | Case / conditional | Affected? Expected before → after | Real-app seeding / route | Minimum visual proof |
|---|---|---|---|---|
| A | Existing saved selection, options omitted; nonempty same-instance default P; no nonempty draft options | YES: P → null raw options; traits high → provider default D; dispatch stops borrowing P | thread.create with selection I/M and no options key; default settings recipe above; server thread route | Required: V1–V4 below |
| A2 | Existing selection options:[] | YES, identical to A: helper treats [] as null | Raw schema-valid thread.create selection `{instanceId:I,model:M,options:[]}`; verify persisted projection retains/normalizes it; same defaults | Collapse to A, separate data assertion only |
| B | Existing selection with explicit nonempty options Q (choose Q different from P) | NO: Q → Q | thread.create with options:Q and same settings P | No extra capture; logical negative control |
| C | Nonempty draft option override Q | NO: draft map wins before/after | Open A then select a real trait via composer menu; this writes setProviderModelOptions into real draft store (sticky enabled). Reset storage between fixtures | No extra static fixture; optional override interaction control must be recorded if performed |
| C2 | Draft map missing/empty, or only selections with omitted/[] options | YES when underlying thread is A: map yields null; explicit empty draft options do NOT independently block fallback | Fresh profile naturally supplies missing/empty; model selection without traits can produce optionless draft; if exact map shape cannot be reached through UI, labelled pure-helper harness only | Collapse to A; never claim empty draft is an explicit clearing override |
| C3 | Draft has nonempty options only for another instance | NO for this change: map as a whole wins; selected-instance lookup may be undefined, blocking fallback in both revisions | Select traits on another real instance then switch back if continuation allows; otherwise labelled helper harness for exact map | No extra capture; cross-instance guard assertion |
| D | No thread selection (new draft), default P, no nonempty draft options | NO: P → P; visibly matches A-before but remains valid after | Fresh New thread from project/sidebar/command/keybinding with no carry/sticky override; verify draft has no nonempty options. Persisted thread.create cannot omit required modelSelection | No duplicate visual; negative control. Exact null/undefined helper variants may use labelled harness if new-thread setup already materializes a draft override |
| E | A, but defaults absent, optionless, [], or same visible value as provider default | Raw result unchanged null, or changed provenance with no visible delta | Set/clear default through Settings; thread.create optionless | No visual fixture; assert provenance/dispatch if equal-default explicit values matter |
| F | Default belongs to a different instance than selected thread instance | May change raw map, but selected-instance lookup absent both times; no visible default-option delta | Real two-instance settings/default and saved thread selections | No extra capture |
| G | Catalog ready; model select/boolean/context/agent traits | Potentially affected wherever inherited option is supported; one select represents fallback | A with ready real catalog. Fast/thinking/context/agent only need extra proof if claiming those specific provider behaviors, not required for this one fallback | One representative select only |
| H | Missing/unavailable model, disabled/unavailable provider, empty catalog, no descriptor/no target/hidden traits | May alter options but no guaranteed visible control; OpenCode unavailable preserves explicit options and filters plan when disabled | Use real unavailable saved selection/catalog if needed; exact absent target is a labelled render/helper harness, not a real thread route | No minimum capture; not silently treated as supported visible controls |
| I | Runtime model preservation, custom instance vs legacy draft key, Antigravity empty model fallback | Model-resolution branches unchanged; options change still obeys A | Real installed provider instance/thread when available; impossible catalog shapes only labelled harness | No extra capture |

Null vs undefined thread/default input collapses; options:null is not a valid ModelSelection wire fixture. Nonempty options that descriptors discard can have a raw change without a visible one. Regression test added here covers omitted thread options with Claude effort high; it does not independently prove [] or the full interaction.

## Minimum capture table

All rows use real fixture A, shared web client (desktop uses same web UI). Heights fixed in manifest: recommend 900 at width 1440, 844 at width 390, CSS pixels, DPR 1, 100% zoom. These are web viewport widths, not a claim that native mobile is affected.

| Row | Width | Theme | Affected rendered states | Required media / expectations | Seed |
|---|---:|---|---|---|---|
| V1 | 1440 | Light | Resting traits trigger, focused/expanded composer, open traits menu, send lifecycle | BEFORE/AFTER recording REQUIRED. Three aligned keyframes; inherited high before, provider default after; model unchanged | A real thread.create + settings |
| V2 | 1440 | Dark | Same states, dark tokens | BEFORE/AFTER recording REQUIRED; same assertions, no unrelated theme/layout drift | A reset equivalent seed |
| V3 | 390 | Light | Narrow resting controls and actual measured overflow path; focused composer; traits menu in picker or compact overflow | BEFORE/AFTER recording REQUIRED; open the actual visible affordance. Width alone does not guarantee overflow; record hiddenRestingBlockCount/visible route | A reset equivalent seed |
| V4 | 390 | Dark | Same narrow paths, dark tokens | BEFORE/AFTER recording REQUIRED; no clipping/selected-value mismatch; same send contract | A reset equivalent seed |

If 390 does not overflow, keep the normal picker: do not fabricate a claim that overflow was exercised. To prove a distinct overflow render branch, use actual permitted context controls/window fit that cause traits to move into CompactComposerControlsMenu during the SAME V3/V4 video; add aligned keyframes/diffs only for that extra visible state. Avoid unrelated tool panels just to inflate fixture count. The minimum is conditional on visible layout, not an assumed CSS breakpoint.

## Recording script and capture pipeline

For each V row and revision: start from identical fresh seeded storage, same descriptor/catalog snapshot, font readiness and camera/viewport. Record full interaction continuously. Suggested relative timeline (actual timestamps must be logged, these are a script, not fabricated observations):

| Time | Action / milestone | Evidence |
|---|---|---|
| 00:00–00:02 | Loaded stopped saved thread, pointer away, composer resting | Keyframe R: visible inherited-vs-default trigger; verify selected model stays M |
| 00:02–00:04 | Focus composer; type deterministic benign prompt, e.g. “Reply with OK.” | Keyframe F: expanded control presentation; no ultrathink prefix |
| 00:04–00:07 | Open traits picker or real narrow overflow; do NOT select an option | Keyframe O: selected option/default. Opening menu must not create draft override |
| 00:07–00:09 | Close menu; send by real Send button | Recording mandatory; capture outgoing RPC selection/receipt as sidecar. Before borrows P; after uses descriptor-normalized provider-default selections, which may be omitted or include implicit false |
| 00:09 onward | Await persisted run/message event and provider completion or explicit stopped milestone | Continue through observable milestone, log actual event timestamp and outgoing saved selection. Do not infer options from response text or demand identical provider response latency/text |

If testing keyboard send or reasoning-step keybinding, record it as an interaction, including reset state and exact keys; no screenshot can prove it. They are not additional minimum fixtures. Keyboard reasoning uses getComposerReasoningStep and can write a draft override, so run only after A proof or in fresh state. Likewise navigation/reload/persistence checks are time rows and require recording, not separate static “proof.” Busy/queue/approval/offline interactions are not necessary for this stopped-thread fallback.

Before source MUST be ff525478e2, after MUST be 4c7f479ac9; record full SHAs in capture manifest, never simulate “before” by changing settings at after. Match fixture payloads, provider descriptors, environment settings, local storage, theme, viewport, fonts, zoom, DPR, scroll, prompt, pointer/focus and menu state. Use separate isolated homes/profiles. Never point two revisions at the same mutable DB. If provider behavior/catalog is nondeterministic, retain catalog and dispatch evidence; a labelled provider harness may stabilize transport only when real provider cannot supply it, and must not be presented as real-provider proof.

Each R/F/O before/after pair gets TWO image diffs: (1) threshold 0, absolute pixel differences visibly amplified (e.g. gain 8 with saturation, report gain and changed-pixel count); (2) stated normalized per-channel threshold 0.10 (26/255), report metric, threshold, changed count and fraction. Compare identical full-frame dimensions without registration/rescaling. Do not silently mask the composer/menu or discard antialiasing differences; if volatile regions need masks, publish raw and masked counts with the mask. Threshold is a visualization/reporting parameter, not an invented pass tolerance. Expected change is only default-derived trait value/selection, possibly label width; investigate all other pixels. For optional extra keyframes, apply the same two-diff rule. Pair video steps by actual milestones, not wall-clock frame numbers; timestamps and dispatch sidecars substantiate send, image diffs do not.

## Callers, render paths, platforms, exclusions

| Path / axis | Finding and coverage decision |
|---|---|
| Sole production helper caller | useComposerProviderModel.ts:72. Other references are tests; modelSelection.ts calls getComposerProviderState directly and is not another caller of this helper |
| Parent/data | ChatView resolves active project settings and passes activeThread.modelSelection/default into ChatComposer; hook reads per-target draft and provider entries. Server route covered by A; draft route by D control |
| Selected instance/model | Hook chooses locked/runtime/thread/default candidates; helper resolves instance/legacy model and unavailable preservation; unchanged. Lookup modelState.modelOptions[selectedInstanceId] feeds getComposerProviderState |
| Expanded/resting/overflow | ComposerControlsBar constructs TraitsPicker, resting TraitsPicker with xs/sm/hidden flags, and TraitsMenuContent in CompactComposerControlsMenu when traits overflow. All receive same options. V recordings cover presentations that actually occur |
| Traits conditional | renderTraitsControl requires target and shouldRenderTraitsControls; descriptors select effort/context/agent and booleans thinking/fast. Reported modelSelection can supply display labels; use no running/reported overrides in A |
| Dispatch | getComposerProviderState normalizes descriptor selections; ChatComposer creates selectedModelSelection at 1019 and exposes selection/effort through getSendContext. Selected prompt effort also enters validation/formatting. Prove payload, not just label |
| Keyboard / menu edits | Menu updates setProviderModelOptions with sticky persistence; reasoning-step keybinding at ChatComposer ~3145 uses same effective options. Affected starting value; optional recorded control, no separate minimum visual fixture |
| Multiple models | Draft-only multiple-model send normalizes each explicit selection through resolveMultipleModelTargets; not a separate existing-thread fallback caller. Exclude |
| Settings / palette / new-thread keybinding | Settings writes default input, not another renderer of helper; new-thread entry points converge on draft flow and carry/default resolution. Existing-thread A must be RPC seeded so UI creation does not bake P into thread |
| Providers | Codex/Claude/Cursor/Grok/OpenCode/Antigravity share helper when represented in web catalog. Descriptor availability differs, not fallback algorithm. Cursor implicit Fast=false and OpenCode unavailable/plan filtering remain unchanged. One real supported select suffices; do not claim provider-specific runtime certification |
| Web + Electron | DesktopWindow.ts loads applicationUrl (web), and shares composer code; no Electron/IPC change. Do not duplicate screenshot grid across OS/Electron |
| Native mobile | ThreadRouteScreen.tsx:418 uses composer.modelSelection ?? selectedThread.modelSelection; ThreadComposer.tsx:419 reads selectedThread.modelSelection. No imports/references to changed web helper/store/hook in mobile. Native path unchanged; 390-width web capture is not native mobile proof |
| Local / remote / relay | Input derivation is local web state independent of transport; no contracts/server changes. One writable real connection adequate. Destination settings/instance identities must match. No new remote/tunnel matrix |
| Theme / width | No conditional changed, but requested light/dark × 1440/390 gives four variants; no extra provider × platform × data Cartesian product |
| Reverse states / docs | No added control or entry/exit behavior; no new docs requirement. Controls and model picker may be visually unchanged and must not be described as redesigned |

Source references (after revision): [fallback/helpers](https://git.cafe/pingdotgg/t3code/blob/4c7f479ac9/apps/web/src/composerDraftStore.ts), [hook](https://git.cafe/pingdotgg/t3code/blob/4c7f479ac9/apps/web/src/components/chat/useComposerProviderModel.ts), [composer](https://git.cafe/pingdotgg/t3code/blob/4c7f479ac9/apps/web/src/components/chat/ChatComposer.tsx), [controls](https://git.cafe/pingdotgg/t3code/blob/4c7f479ac9/apps/web/src/components/chat/ComposerControlsBar.tsx), [traits](https://git.cafe/pingdotgg/t3code/blob/4c7f479ac9/apps/web/src/components/chat/TraitsPicker.tsx), [provider normalization](https://git.cafe/pingdotgg/t3code/blob/4c7f479ac9/apps/web/src/components/chat/composerProviderState.tsx), [thread command contract](https://git.cafe/pingdotgg/t3code/blob/4c7f479ac9/packages/contracts/src/orchestrationV2.ts), [selection contract](https://git.cafe/pingdotgg/t3code/blob/4c7f479ac9/packages/contracts/src/modelSelection.ts), [mobile route](https://git.cafe/pingdotgg/t3code/blob/4c7f479ac9/apps/mobile/src/features/threads/ThreadRouteScreen.tsx), [mobile composer](https://git.cafe/pingdotgg/t3code/blob/4c7f479ac9/apps/mobile/src/features/threads/ThreadComposer.tsx), [desktop loader](https://git.cafe/pingdotgg/t3code/blob/4c7f479ac9/apps/desktop/src/window/DesktopWindow.ts), [new-thread defaults](https://git.cafe/pingdotgg/t3code/blob/4c7f479ac9/apps/web/src/hooks/useHandleNewThread.ts).

## FRICTION

| Category | Happened | Avoidance |
|---|---|---|
| Search paths | A few guessed wildcard filenames did not exist; rg returned path errors. No source/data changes resulted | Search directory/file catalog first, then read concrete matches |
| Seed contamination | Static risk identified: UI thread creation/carry/sticky draft can already persist default options, hiding this defect; not observed in a running client | Create optionless saved thread through real command, fresh storage, inspect projection and draft before recording |
| Catalog certainty | No live catalog queried under no-server/no-browser constraint; exact live default label/model availability not certified | Bind I/M/D to real descriptor before capture, record catalog manifest; use labelled harness only for impossible shapes |
| Narrow overflow | Static code uses measured hidden blocks, so 390 does not by itself prove overflow | Record actual visible route and hidden-state observation, add conditional keyframe only if distinct path exists |
| Tooling / authorization | ghdrop skill read; upload authorized. No browser/server/capture or forge mutation attempted | Keep audit artifact in /tmp, upload only this synthetic plan without runtime data/secrets |

This is a proof plan, not completed visual verification. No invented screenshots, timestamps, UI labels, provider payloads, or passing test claims.
