Phase 0 — Research: Fix UI State Bugs on Browser Refresh
Feature: 2026-05-15-fix-refresh-state-bugs
Date: 2026-05-15
This document records the root-cause investigation, the decisions taken to resolve them, and the alternatives considered. It is the input to plan.md Phase 1 design.
Question 1 — Why does a duplicate sidebar entry appear after refresh?
What the code does today
Two store actions populate useChatStore.conversations independently, both fire on (or shortly after) page mount, and they are merged into the same list:
loadConversationsFromServer({ source?: 'autonomous' | 'web' })—ui/src/store/chat-store.ts~L873–L1042. Pulls all the user's conversations from/api/chat/conversations(MongoDB). Each item carries an explicitsourcefield if present on the server document. The merge step (~L992–L1011) also preserves any local-only conversation that is either actively streaming or is theactiveConversationId, regardless of whether it appears in the server response.loadAutonomousConversationsFromService()—ui/src/store/chat-store.ts~L1060–L1182. Calls the autonomous-agents service, synthesizes one Conversation per task usingsynthesizeConversationForTask(inui/src/components/autonomous/synthesize-conversation.ts), and merges them back into the store. The merge filtersstate.conversationsinto:existingAutonomous— entries withsource === 'autonomous'(used to preserve user-typed messages on resync).others— entries withsource !== 'autonomous'(kept as-is).- then concatenates
[...others, ...merged]and sorts.
The conversation id for a synthesized autonomous Conversation is task.chat_conversation_id ?? uuidv5("task:<task.id>", AUTONOMOUS_NS) (canonicalConversationId, synthesize-conversation.ts L67–L70). The same id is what the autonomous publisher writes to MongoDB as chat_conversation_id, so in the happy path the server-fetched record and the synthesized record share an id.
Why duplicates appear
The autonomous merge filters existingAutonomous strictly by source === 'autonomous'. Anything with the same id but a different (or missing) source value falls into others and survives untouched. Then the synth produces a fresh entry with source === 'autonomous'. The final [...others, ...merged] array contains two entries with the same id.
Concrete paths that produce a same-id-without-source entry on refresh:
- Zustand persisted state (localStorage mode). The persisted snapshot at key
caipe-chat-historyincludesconversations(withmessages, ids, etc.). If the user's previously-active conversation was an autonomous one whosesourcefield hadn't been written into the in-memory copy yet (race againstloadAutonomousConversationsFromService, or persisted before the autonomous publisher populatedchat_conversation_id), the persisted entry rehydrates withsourceundefined orsource: 'web'. ChatContainerAPI fallback (apiClient.getConversation). On refresh of a deep link/chat/<uuid>,ChatContainer.tsxL196–L218 builds aLocalConversationfrom the conversation detail endpoint and pushes it into the store withoutsource(the local mapping object at L204–L214 simply omits the field). If that uuid happens to match a synthesized autonomous canonical id, the entry withoutsourceends up inotherswhile the synth produces a parallelsource: 'autonomous'entry.loadConversationsFromServerlocal-only preservation (L1000–L1005) keeps theactiveConversationIdeven if the server didn't return it. If that active conversation came from path (1) or (2), the same id-without-sourceis preserved into the post-merge list and then duplicated by the autonomous synth.
The user's observation — "a duplicate of the tab appears and is selected along with the autonomous chat" — matches exactly: the persisted/active copy stays selected (because activeConversationId is restored), and the synth adds a second item under the Autonomous section. Two entries, both clickable, both visually selected (since both have the same id and the sidebar likely highlights by id-equality).
Decision
Add a final dedupe-by-id pass after both loaders run. When two entries share an id, prefer the autonomous-synthesized copy for source / messages / a2aEvents if present, and merge messages by message.id (the existing synth-merge logic at L1157–L1161 already keeps user-typed messages by id). Concretely:
- In
loadAutonomousConversationsFromService, change the final assembly from[...others, ...merged]to a Map-based dedupe keyed byid, with the synth entry winning when both exist. This single change is sufficient. - In
loadConversationsFromServer, also run a dedupe-by-id over[...serverConversations, ...localOnlyPreserved]to harden against any other code path that pushes an entry into the store (defense in depth).
Rationale
- One-line invariant ("no two sidebar entries share an id") is conceptually simpler than reconciling the source field everywhere it's written.
- Matches Constitution I (Worse is Better) and Constitution II (YAGNI): no new abstraction, no model rewrite, only a transformation at the merge sites.
- Backward compatible: any code that reads
state.conversations.find(c => c.id === uuid)already assumes uniqueness; the fix matches that assumption.
Alternatives considered
- Reconcile
sourceon every fetch path — Rejected. Touches more files, risks regressions where conversations legitimately carry a non-autonomous source. - Drop the
partializeconversationsentry from Zustand persistence in MongoDB mode — Rejected. Persistence is used to make the sidebar feel instant on refresh and to preserve in-progress drafts. Removing it widens the scope and degrades UX. - Bump the Zustand persist version (force clear) — Rejected. Loses unrelated user state (input drafts, selected turn, unviewed flags).
Question 2 — Why does refresh sometimes put the user in Read-Only Audit Mode?
What the code does today
requireConversationAccess in ui/src/lib/api-middleware.ts L443–L529 returns { conversation, access_level } where access_level is one of 'owner' | 'shared' | 'shared_readonly' | 'admin_audit'. The order of checks is:
- Owner →
owner sharing.is_public→sharedorshared_readonlysharing.shared_withincludes the user →shared/shared_readonly- Team grant →
shared/shared_readonly - Email-grant subcollection →
shared/shared_readonly session.role === 'admin' || session.canViewAdmin === true→admin_audit(L514–L517)conversation.source === 'autonomous'→shared_readonly(L525–L527)- Else → 403
The /api/chat/conversations/[id] route returns { ...conversation, access_level }. ChatContainer.tsx L200–L203 stores that value as accessLevel, and computes:
const isReadOnly = accessLevel === 'admin_audit' || accessLevel === 'shared_readonly';
const readOnlyReason = accessLevel === 'admin_audit' ? 'admin_audit'
: accessLevel === 'shared_readonly' ? 'shared_readonly'
: undefined;
ChatPanel.tsx L1837–L1865 renders the Read-Only Audit Mode banner with a "Back to Audit Logs" / "Back to Feedback" link whenever readOnlyReason === 'admin_audit', regardless of adminOrigin. adminOrigin only influences which back-link is shown (defaulting to "Back to Feedback" when null).
Why audit mode appears unexpectedly
Two converging defects:
- Server: For admin users (
session.role === 'admin'), opening any conversation they don't own — including autonomous-source conversations — falls into theadmin_auditbranch at step 6 above. Step 7 (autonomous →shared_readonly) is never reached for admins. Effectively, an admin who looks at autonomous tasks daily (the design intent of the autonomous tab) is always told "you are auditing this conversation." - Client:
ChatContainerdoes not requireadminOriginto display the audit banner. So even when the user navigates to a conversation by clicking a sidebar item (no?from=...query param) or by hard-refreshing a deep link (query params often lost across redirects, or absent in the persisted URL), the banner triggers from the API-returnedaccess_levelalone.
Result: a signed-in admin who refreshes on any non-owned conversation — most reliably an autonomous one — sees the audit banner and is locked out of the composer.
Decision (defense in depth)
- Server fix (root cause for autonomous case): Reorder the checks in
requireConversationAccessso thesource === 'autonomous'branch returnsshared_readonlybefore the admin fallback. Admins viewing autonomous conversations get the same read-only-but-non-audit view that all other users get. Legitimate admin audit access to private user conversations (the original intent) is preserved. - Client fix (covers any other code path that could mis-classify): In
ChatContainer.tsx, when derivingreadOnlyReason, gateadmin_auditonadminOrigin ∈ {'audit-logs', 'feedback'}. When the API returnsadmin_auditbutadminOriginis null, treat the page as a normal admin view — no audit banner, no audit back-link. (Write attempts are still blocked at the API layer because the server returnsadmin_auditand the existing POST/PUT routes reject non-owner mutations; client-side banner suppression is presentation-only.)
Rationale
- Server change closes the most common path (admin on autonomous) at the authority layer (Security by Default, Constitution VII).
- Client change closes the remaining presentation gap (admin viewing other non-owned conversations) without weakening any authorization decision.
- Together they implement spec FR-004 (audit context tied to in-session admin navigation) and FR-005 (non-admins never see audit banner — already true, preserved).
Alternatives considered
- Persist
adminOriginacross refresh — Rejected. URL/search-params are the source of truth for in-session navigation context; silently restoring admin/audit context from persisted state is exactly what FR-008 forbids. - Always show audit banner for admins on others' conversations — Rejected. Contradicts FR-004 and produces the exact bug the user reported.
- Remove
admin_auditentirely — Rejected. Legitimate admin auditing (reached from the audit-logs / feedback views) is a real feature; we just need to scope it correctly.
Question 3 — Does persisted client state need a one-time cleanup?
Decision
No mass clear is needed, but the dedupe pass MUST run at rehydrate time (not only at network-load time) — see Question 4.
- The dedupe-by-id pass heals duplicate entries the next time either loader runs after this fix ships. Users who refresh once after the upgrade and wait for the loaders to complete will see a clean sidebar.
- However, network loaders are not the only entry point. The dedupe pass MUST also run inside
onRehydrateStorageso that (a) the duplicate is healed on the very first paint after rehydrate, before any network call, and (b)localStoragemode users who haveautonomousAgentsEnabled = false(where neitherloadConversationsFromServernorloadAutonomousConversationsFromServicedoes any meaningful work) are still healed. See Question 4 for the back-to-back-refresh analysis. - The audit-mode fix is purely runtime: it depends on the current URL
adminOriginand the current API response, neither of which is persisted. Existing persisted state cannot carry forward an erroneous audit banner across the upgrade. - A defensive comment will be added next to the Zustand
partializeconfiguration noting thataccess_level,readOnlyReason,adminOrigin, and any other authorization/session flag MUST NOT be added to the persisted shape (Inv-E indata-model.md).
Alternatives considered
- Bump persist version — Rejected. Loses unrelated user state. Not required because the bugs are runtime-derived once dedupe is in place at all three sites (rehydrate + both network loaders).
Question 4 — What happens if the user refreshes twice in rapid succession?
Why this matters
Reported during /speckit.analyze re-review: the original plan only added the dedupe pass at network-load time (loadConversationsFromServer and loadAutonomousConversationsFromService). Both are async. A user who has a poisoned localStorage (a duplicate persisted before this fix shipped) and slams F5 twice in rapid succession can land in a state where the persisted-duplicate is rehydrated, no loader has yet completed + persisted the deduped state, and the second F5 rehydrates the same poisoned localStorage again. The fix never catches up.
Storage-mode analysis
| Scenario | Pre-rehydrate-dedupe behaviour | Post-rehydrate-dedupe behaviour |
|---|---|---|
| MongoDB mode, any refresh count | No localStorage persistence — store starts empty, loaders run, dedupe applies, clean. | Same. (Rehydrate dedupe is a no-op in MongoDB mode because there's nothing to rehydrate.) |
localStorage mode + autonomous enabled, single refresh | Brief flicker showing the duplicate, then loadAutonomousConversationsFromService heals it and partialize writes clean state. | No flicker — rehydrate is self-healing on first paint. |
localStorage mode + autonomous enabled, back-to-back refresh | If the second F5 fires before the first refresh's loader completes + persists, the same poisoned localStorage is rehydrated. Possible to never converge if the user keeps mashing F5. | Each rehydrate is independently self-healing. The very first paint after F5 already shows the deduped sidebar. Loader completion is no longer required for correctness. |
localStorage mode + autonomousAgentsEnabled = false, any refresh | loadAutonomousConversationsFromService early-returns (only filters source === 'autonomous'), and loadConversationsFromServer early-returns (localStorage mode skips server sync). The dedupe pass never runs. Duplicate persists indefinitely. | Rehydrate dedupe runs unconditionally on every rehydrate. Heals on first paint. |
Decision
Add a Map-based dedupe-by-id pass to onRehydrateStorage in chat-store.ts. Insertion order on collision: source === 'autonomous' wins; otherwise more messages wins; otherwise most-recent updatedAt wins. This complements the network-load dedupe sites and makes the rehydrate path self-healing in every storage mode.
Rationale
- Defense in depth (Constitution VII): three independent dedupe sites (rehydrate, server loader, autonomous loader) — removing any one still leaves correctness coverage in the most common modes.
- Worse-is-Better: the rehydrate dedupe is ~10 lines using the same
Map<id, Conversation>pattern as the loader sites; no new abstraction. - Survives unbounded back-to-back F5 presses without requiring loader completion or network availability.
Alternatives considered
isLoadingAutonomousmodule-level guard (considered and dropped) — A same-loader concurrency guard symmetric to the existingisLoadingConversationswas considered for the autonomous loader but is not required for correctness. The Map-based dedupe insideloadAutonomousConversationsFromServiceis idempotent on its own snapshot, so two concurrent invocations of the same loader still produce a duplicate-free final state. The intermediate write order is non-deterministic but the final state converges. The cross-loader case is the one that needs explicit coordination, and that is handled by T015c's callback-formset(...)(Inv-G).BroadcastChannelto dedupe across tabs — Over-engineering for this bug. Multi-tab race is a separate, lower-priority edge case.
Question 5 — Can the two loaders clobber each other when they interleave? (finding N1)
Why this matters
Reported during the second /speckit.analyze re-review. The two network loaders run on independent timers and lifecycles:
loadConversationsFromServer(chat-store.ts L873–L1042) reads its snapshot at L954, buildssortedConversations, and writes via the imperative formset({ conversations: sortedConversations, ... })at L1018. The imperative form does NOT re-read the latest store state at write time — it overwrites with the value computed from the stale snapshot.loadAutonomousConversationsFromService(chat-store.ts L1108–L1177) writes via the callback formset((state) => { ... }), which always sees the latest state.
If the autonomous loader's callback set(...) lands between the server loader's snapshot read (L954) and the server loader's imperative set(...) (L1018), the autonomous loader's freshly-written entries are silently overwritten by the server loader's stale snapshot. Reproducer: a refresh that fires both loaders in parallel — the autonomous loader is typically faster (fewer fan-out calls) and writes first; the server loader then overwrites and loses the autonomous entries until the next 30-second autonomous resync.
Why a same-loader guard alone is not enough
A same-loader guard (e.g., adding an isLoadingAutonomous boolean symmetric to the existing isLoadingConversations) only short-circuits a second invocation of the same loader. It explicitly does not coordinate between the server and autonomous loaders, because they pull different data sources and one waiting on the other would double the worst-case refresh latency. The cross-loader interleave (server's stale-snapshot imperative set(...) overwriting autonomous's freshly-written callback set(...)) is unaffected by any same-loader guard.
Decision
Convert loadConversationsFromServer's final set(...) to the callback form, and explicitly preserve any autonomous-source or streaming entries that appeared in the latest state but not in the snapshot. See data-model.md "Inv-G" and tasks.md T015c. The Map-based dedupe pass already required by T015 stays in place as defense in depth; T015c only changes WHICH list goes through the dedupe (snapshot-only → snapshot ∪ cross-loader-additions).
Rationale
- Worse-is-Better: a one-line change from
set(value)toset((state) => value)plus a small filter expression. No new abstraction, no shared mutex. - Preserves parallel I/O latency: both loaders still run unblocked; only the write step coordinates.
- Defense in depth: the dedupe-by-id invariant is preserved; any future code that writes to
conversationsoutside the loaders is still healed at rehydrate (Inv-A site 1) and at the loader sites.
Alternatives considered
- Single shared mutex (
isLoadingConversationsOrAutonomous) — Rejected as the default. Serializes the loaders, doubling worst-case refresh latency. Acceptable as a fallback if the callback-form fix proves intricate in review. - Pure
useReducer-style atomic merge in a custom Zustand middleware — Rejected. Over-engineering; YAGNI (Constitution II).
Phase 0 addendum: write-path and audit-log audit (T028)
Date: 2026-05-15
Scope: Read-only static codebase audit confirming the precondition for T025 (the server-side reorder of requireConversationAccess). Two questions per tasks.md T028.
1. Write-path safety (S1) — GREEN
Audit command: rg "admin_audit|shared_readonly|access_level" ui/src/app/api -n -t ts. Every match in a write-side handler (POST/PUT/PATCH/DELETE) was inspected:
-
ui/src/app/api/chat/conversations/[id]/messages/route.tsL81 — POST handler:if (access_level === 'admin_audit' || access_level === 'shared_readonly') {
throw new ApiError('Read-only access — cannot add messages', 403, 'FORBIDDEN');
} -
ui/src/app/api/chat/conversations/[id]/turns/route.tsL99 — POST handler:if (access_level === "admin_audit" || access_level === "shared_readonly") {
throw new ApiError("Read-only access — cannot write turns", 403, ...);
}
Both handlers treat admin_audit and shared_readonly identically as "no write." No write-side handler grants additional capability based on access_level === 'admin_audit' alone. Reclassifying admin-on-autonomous from admin_audit to shared_readonly (T025) does not change any write-path decision: the user was blocked from writing before, and is blocked from writing after.
Flag: GREEN. T025 may proceed.
2. Audit-log behaviour (S2) — GREEN
Audit command: rg "audit_log|auditLog|access_level.*admin_audit" ui/src/app/api -n -t ts. Inspection of ui/src/app/api/admin/audit-logs/:
-
route.ts,[id]/route.ts,[id]/messages/route.ts,export/route.ts,owners/route.ts— every audit-logs route gates on theauditLogsEnabledserver config andrequireAdmin(session)(which keys onsession.role === 'admin' || session.canViewAdmin), NOT onaccess_level === 'admin_audit'. Theaccess_levelvalue returned byrequireConversationAccessis never read by an audit-log writer. -
No code path was found that conditionally writes an audit-log entry based on
access_level === 'admin_audit'vs.'shared_readonly'. There is no implicit "admin viewed an autonomous conversation asadmin_audit" log entry that the T025 reclassification would silently disable. Audit logs in this codebase are list/export views overconversationsandmessagescollections, gated by admin role — they are not produced by the access-decision helper.
Flag: GREEN. T025 may proceed without an additional log-preservation follow-up.
Conclusion
Both flags are GREEN. T025 (autonomous-before-admin reorder in requireConversationAccess) is safe to land:
- No write-side regression: all writers already treat
admin_auditandshared_readonlyidentically. - No audit-log regression: no writer keys on
access_level === 'admin_audit'.
T013 + T013a together cover the FR-008 / Inv-F persistence-side enforcement (the partialize strip + the explicit-key-injection test).
Best practices applied
- Defense in depth (Constitution VII): both server (
requireConversationAccess) and client (ChatContainer) enforce the audit-mode condition. Removing either alone would still leave the user covered. - Idempotent merges (Zustand pattern): dedupe-by-id at the assembly step is the canonical fix when multiple async loaders contribute to the same list.
- Session vs persisted state separation (Next.js + Zustand): authorization, permission, and navigation-origin signals must always be derived from the current session and URL, never restored from
localStorage. - No backend changes beyond the authorization helper: keeps the blast radius small. No MongoDB schema, no migration, no Python code touched.