What
Post-cutover, the architecture is store-first rendering: useConversationMessagesStore renders, and the AI SDK useChat array is demoted to "transport bookkeeping only" (chat-config.ts:1-14). But the transport still exists per surface (6 useChat instances: AiChatView.tsx:190, GlobalAssistantView.tsx:323-339 ×2, usePageAgentSidebarChat.ts:84-109 ×2) and must be manually re-seeded before every SDK re-invocation because regenerate()/addToolResult() POST its internal array. The result is a standing dual-write protocol the code itself flags as transitional and forbidden-by-default ("rail 11"; hydrateTransportBeforeReinvoke.ts:4-20 "deletion covenant... DELETE ME at the SDK 7 transport swap"; useOwnStreamMirror.ts:11-15 same covenant, deletion gate: grep -rn "TRANSITIONAL" apps/web/src must return zero — currently 13 hits).
Current dual-write inventory (each is a desync opportunity)
AiChatView.tsx:288,307-309 — load path writes store (applyLoad) and transport (setMessages), each behind its own guard.
AiChatView.tsx:860-878 — socket handlers dual-write append/edit/delete into both containers; the shared handlers (conversationCacheSocketHandlers.ts:43-104) are store-only, so AiChatView keeps a private fork (:27-29 explains why).
useAnswerAskUser.ts:111,115,117 — store patch + transport hydrate + addToolResult (has its own stale-snapshot bugs, filed separately).
useCacheMessageActions.ts:105-118,150-157 — edit/delete/retry write both.
- Send paths — optimistic user message to store while
sendMessage appends to transport: SidebarChatTab.tsx:790-833, GlobalAssistantView.tsx:826-854, AiChatView.tsx:1176-1226.
useOwnStreamMirror.ts:114-236 — transport→store direction, copied every tick, mounted 5×.
useMessageActions.ts:134-154 — post-edit reconcile refetch setMessages(loaded) wholesale.
The scars are documented in-code: #2061 clobber guards grown then deleted per-surface (GlobalChatContext.tsx:36-49, SidebarChatTab.tsx:688-692), stale-busy-flag re-send fixed in one hook but not the other, chat-config.ts:4-10 blank-screen/stale-history history.
Ask
The endgame is already named in the covenants: swap to a transport that takes the message trail per-call (the store as the only container), then delete hydrateTransportBeforeReinvoke, useOwnStreamMirror, and the per-surface dual-writes wholesale. This issue is the tracking home for that cutover so the fixes stop being per-desync patches. Until then, every new message-mutating feature has to remember to write two containers in the right order behind the right guards.
Filed from the sources-of-truth audit.
What
Post-cutover, the architecture is store-first rendering:
useConversationMessagesStorerenders, and the AI SDKuseChatarray is demoted to "transport bookkeeping only" (chat-config.ts:1-14). But the transport still exists per surface (6useChatinstances:AiChatView.tsx:190,GlobalAssistantView.tsx:323-339×2,usePageAgentSidebarChat.ts:84-109×2) and must be manually re-seeded before every SDK re-invocation becauseregenerate()/addToolResult()POST its internal array. The result is a standing dual-write protocol the code itself flags as transitional and forbidden-by-default ("rail 11";hydrateTransportBeforeReinvoke.ts:4-20"deletion covenant... DELETE ME at the SDK 7 transport swap";useOwnStreamMirror.ts:11-15same covenant, deletion gate:grep -rn "TRANSITIONAL" apps/web/srcmust return zero — currently 13 hits).Current dual-write inventory (each is a desync opportunity)
AiChatView.tsx:288,307-309— load path writes store (applyLoad) and transport (setMessages), each behind its own guard.AiChatView.tsx:860-878— socket handlers dual-write append/edit/delete into both containers; the shared handlers (conversationCacheSocketHandlers.ts:43-104) are store-only, so AiChatView keeps a private fork (:27-29 explains why).useAnswerAskUser.ts:111,115,117— store patch + transport hydrate +addToolResult(has its own stale-snapshot bugs, filed separately).useCacheMessageActions.ts:105-118,150-157— edit/delete/retry write both.sendMessageappends to transport:SidebarChatTab.tsx:790-833,GlobalAssistantView.tsx:826-854,AiChatView.tsx:1176-1226.useOwnStreamMirror.ts:114-236— transport→store direction, copied every tick, mounted 5×.useMessageActions.ts:134-154— post-edit reconcile refetchsetMessages(loaded)wholesale.The scars are documented in-code: #2061 clobber guards grown then deleted per-surface (
GlobalChatContext.tsx:36-49,SidebarChatTab.tsx:688-692), stale-busy-flag re-send fixed in one hook but not the other,chat-config.ts:4-10blank-screen/stale-history history.Ask
The endgame is already named in the covenants: swap to a transport that takes the message trail per-call (the store as the only container), then delete
hydrateTransportBeforeReinvoke,useOwnStreamMirror, and the per-surface dual-writes wholesale. This issue is the tracking home for that cutover so the fixes stop being per-desync patches. Until then, every new message-mutating feature has to remember to write two containers in the right order behind the right guards.Filed from the sources-of-truth audit.