Implementation Plan: Fix UI State Bugs on Browser Refresh
Branch: 2026-05-15-fix-refresh-state-bugs | Date: 2026-05-15 | Spec: spec.md
Input: Feature specification from docs/docs/specs/2026-05-15-fix-refresh-state-bugs/spec.md
Summary
After a hard browser refresh, two UX defects appear in the chat UI:
- Duplicate sidebar tab: the previously-active conversation appears twice — once under its original source (e.g., web/persisted) and once under "Autonomous" — and both are visually treated as selected.
- Unexpected Read-Only Audit Mode: admin users are dropped into the "Read-Only Audit Mode" banner on conversations they did not navigate to via the admin audit-logs / feedback views.
Investigation pinpoints two root causes, matching the user's hypotheses (access levels changing after refresh + autonomous-message posting into the chat):
- Bug #2 root cause (access-level / audit mode): The server-side authorization helper
requireConversationAccess(inui/src/lib/api-middleware.ts) returnsaccess_level: 'admin_audit'for any admin user on any conversation they don't own — including autonomous-source conversations — and that branch is checked before thesource === 'autonomous'shared-readonly fallback. The UI (ChatContainer→ChatPanel) then translatesadmin_auditinto the audit banner regardless of whether the user actually arrived from the admin audit-logs/feedback views in this session (i.e., regardless of the?from=audit-logs|feedbackquery param that drivesadminOrigin). - Bug #1 root cause (duplicate autonomous tab): The autonomous sidebar adapter
loadAutonomousConversationsFromService(inui/src/store/chat-store.ts) merges synthesized autonomous conversations into store state by filtering onsource === 'autonomous'. If a conversation with the same canonical id already exists in state but is not tagged withsource === 'autonomous'(because it was rehydrated from Zustand-persisted state, was returned by the generic conversations API without thesourcefield, or had a differentsourceat the time it was last stored), it survives in theothersbucket while the synth produces a fresh autonomous entry — producing two sidebar items with the same underlying conversation id.
The fix is UI-only and is grouped into seven conceptual change clusters (six code-side + one spec-side clarification). The numbered list below is a high-level mental model, NOT a one-to-one mapping to tasks — tasks.md expands these seven clusters into ~25 granular items (T001–T043), most of which are tests, dependency gates (T028), or sub-tasks created by the three prior /speckit.analyze re-reviews (T012a, T013a, T015a, T015c, T029a, T029c). See "Phase 2 stop point" below for the canonical task ledger.
- Dedupe-by-id at three sites in
chat-store.ts—onRehydrateStorage(back-to-back-F5 + autonomous-disabled resilience),loadConversationsFromServer, andloadAutonomousConversationsFromService. Autonomous-synthesized copy wins on collision; user-typed messages are merged in bymessage.idso nothing is lost. Winner-selection is deterministic and NaN-safe for legacy entries with missing/invalidupdatedAt(finding N5 in/speckit.analyzere-run). - Cross-loader clobber fix (
Inv-G, T015c, finding N1 in/speckit.analyzere-run) — convertloadConversationsFromServer's finalset(...)to the callback form so autonomous-source entries written by the other loader between snapshot-read and write are NOT discarded. (A same-loader guard forloadAutonomousConversationsFromServicewas considered and dropped: the Map-based dedupe inside that loader is idempotent on its own snapshot, so two concurrent invocations still converge to a duplicate-free final state — the cross-loader case is the only one that requires explicit coordination.) - Split-denylist
partializetest (Inv-E) — enforce by parsed-tree walk with two scopes (top-level + recursive) soChatMessage.role(legitimate non-authorization sender field) is not falsely flagged. The accompanying code comment onpartializedocuments both the denylist and the broader principle: persist conversation data only — never transient/loading/UI flags. - Server-side authorization reorder in
api-middleware.ts—requireConversationAccessresolvessource === 'autonomous'toshared_readonlybefore the admin-audit fallback. Admins viewing autonomous chats get the same read-only treatment as everyone else. - Client-side audit-banner gate with
shared_readonlyfallback inChatContainer.tsx(T026; finding N3 in/speckit.analyzere-run; Inv-C2, option A) —readOnlyReason === 'admin_audit'only when (a) the API returnedaccess_level === 'admin_audit'AND (b) the live URL?from=audit-logs|feedbackis present. When the server returnsadmin_auditbut the gate evaluates false (no?from=, or unrecognized value), route the case through the existingshared_readonlyUI branch (composer hidden, standard sharing banner, no admin back-link) so the user does not face silent send-failures. No newreadOnlyReasonvalue is introduced; theChatPanelrendering and prop-type union remain unchanged. Presentation-only; the server remains authoritative for authorization. - Security audit gate (T028) — before the server-side reorder lands, statically verify (a) no write-side handler grants extra power on
access_level === 'admin_audit', and (b) admin views of autonomous chats are not audit-logged via theadmin_auditsignal in a way the fix would silently break. Green on both is the precondition for T025. (FR-008 / Inv-F persistence-side coverage is enforced end-to-end by the T013 + T013a pair: T013a's defensivestripDenylistedKeysruns insidepartializeand removes every denylisted key at write-time even if a future setter wrote one onto store state; T013 is a real Inv-E gate that injects the denylisted keys onto the seed state and asserts they are absent from thepartializeoutput — closing the seed-shape blind spot that finding A1 flagged in the earlier draft. Together they make a separate staticrgaudit for store-setter writes redundant: any future write of a denylisted key to store state still lands in clean persisted output because T013a strips it, and T013 fails immediately if that strip ever regresses.) - Spec-level FR-004 clarification (finding N7 in
/speckit.analyzere-run) — codifies that the in-session "I am acting as an auditor" flag is the live URL?from=parameter (cross-conversation carry-over allowed) AND that unrecognized?from=values MUST be treated as absent (with theshared_readonlyfallback in change 5 ensuring no silent write-fail). Resolves an ambiguity between FR-004 anddata-model.mdInv-C.
Technical Context
Language/Version: TypeScript 5.x, Node 20+
Primary Dependencies: Next.js 16 (App Router), React 19, Zustand (state + persist middleware), next-auth (useSession), Tailwind CSS
Storage: MongoDB (chat history) via existing API routes — no schema change; Zustand localStorage persistence under key caipe-chat-history (localStorage mode only)
Testing: Jest + React Testing Library (make caipe-ui-tests / npm test); existing unit suites: ui/src/store/__tests__/chat-store.test.ts, ui/src/components/chat/__tests__/ChatPanel.test.tsx, ui/src/components/autonomous/__tests__/synthesize-conversation.test.ts, ui/src/app/api/__tests__/admin-audit-access.test.ts
Target Platform: Browser (Chromium, Firefox, Safari) — UI runs server-side rendered + hydrated by Next.js
Project Type: Web application (Next.js frontend backed by Next.js API routes — single repo)
Performance Goals: No regression on sidebar render time or refresh-to-paint; dedupe pass is O(n) over conversations (n < 1k in practice)
Constraints: No backend (Python) changes; no database schema or migration changes; preserve current localStorage persistence key and shape for backward compatibility
Scale/Scope: Per-user UI state; typical user has 10–500 conversations across sources (web, autonomous, slack, shared)
Constitution Check
Reviewed against .specify/memory/constitution.md (CAIPE Constitution v1.0.0).
| Principle | Status | Notes |
|---|---|---|
| I. Worse is Better | PASS | Fix is minimal, concrete code changes in 3 files (chat-store.ts, ChatContainer.tsx, api-middleware.ts); no new abstractions introduced. |
| II. YAGNI | PASS | Only fixing the two reported defects. No speculative refactor of the conversation-source model or audit-mode plumbing. |
| III. Rule of Three | PASS | The dedupe-by-id pattern is added once at the merge site; not extracted into a shared helper yet. |
| IV. Composition over Inheritance | PASS | Changes are functional/data transformations on existing store/middleware modules. |
| V. Specs as Source of Truth | PASS | This plan and the linked spec drive the work. Tests will reference spec FRs. |
| VI. CI Gates Are Non-Negotiable | PASS | Will run npm run lint and npm test for the UI workspace; new tests cover FR-001…FR-010. |
| VII. Security by Default | PASS | Server-side authorization tightening (requireConversationAccess) is more restrictive, not less: autonomous → shared_readonly (no write) instead of admin_audit. Non-admin users still cannot reach admin_audit under any path. No secrets touched. |
Gate result: All gates pass. No complexity-tracking entries needed.
Project Structure
Documentation (this feature)
docs/docs/specs/2026-05-15-fix-refresh-state-bugs/
├── plan.md # This file (/speckit.plan output)
├── spec.md # Feature spec
├── research.md # Phase 0 — root-cause analysis & options
├── data-model.md # Phase 1 — UI state shape & invariants
├── quickstart.md # Phase 1 — manual reproduction & verification steps
├── checklists/
│ └── requirements.md # Spec quality checklist (from /speckit.specify)
└── tasks.md # Phase 2 output (/speckit.tasks — NOT created here)
Note: no contracts/ directory — this feature does not change any API contract surface that's worth versioning; the server-side change is an authorization-policy fix and is covered by data-model.md invariants + tests. No db-migration.md — storage is unchanged.
Source Code (repository root)
ui/
├── src/
│ ├── store/
│ │ ├── chat-store.ts # MODIFY — dedupe-by-id in autonomous merge,
│ │ │ # in loadConversationsFromServer merge,
│ │ │ # AND in onRehydrateStorage (back-to-back-F5
│ │ │ # resilience); plus callback-form set() in
│ │ │ # loadConversationsFromServer to prevent
│ │ │ # cross-loader clobber (Inv-G)
│ │ └── __tests__/
│ │ └── chat-store.test.ts # ADD test cases for refresh + dedupe + rehydrate +
│ │ # cross-loader interleave + split-denylist partialize
│ ├── components/
│ │ └── chat/
│ │ ├── ChatContainer.tsx # MODIFY — gate admin_audit on adminOrigin (Inv-C);
│ │ │ # route the admin-without-recognized-origin case
│ │ │ # through the existing shared_readonly branch
│ │ │ # (Inv-C2 option A, finding N3). No ChatPanel
│ │ │ # or prop-type changes — only ChatContainer's
│ │ │ # readOnlyReason derivation.
│ │ └── __tests__/
│ │ ├── ChatContainer.test.tsx # ADD (NEW FILE) — gate test, leak test,
│ │ │ # cross-conversation ?from= carry-over test,
│ │ │ # shared_readonly fallback tests (admin + no
│ │ │ # recognized origin)
│ │ └── ChatPanel.test.tsx # ADD ChatPanel-only rendering regression guards
│ ├── lib/
│ │ └── api-middleware.ts # MODIFY — autonomous resolves before admin_audit
│ └── app/
│ └── api/
│ └── __tests__/
│ └── admin-audit-access.test.ts # ADD test for autonomous + admin path
Structure Decision: This is a UI-only fix in the existing Next.js workspace under ui/. There is no second project; no new directories. All changes live in three existing files plus their colocated test files. No backend (Python) changes.
Database migrations
N/A — no db-migration.md.
This feature does not introduce, rename, or migrate any persisted storage. MongoDB schemas and indexes are unchanged. The Zustand localStorage persistence key (caipe-chat-history) and its partialize shape are unchanged; the dedupe pass operates on already-persisted state at read time, so existing client state remains valid.
Phase 0 — Research
Deliverable: research.md in this directory. It answers:
-
Why does refresh produce duplicate autonomous sidebar entries?
- Trace:
loadAutonomousConversationsFromService(chat-store.ts ~L1108–L1177) merges synth entries by filteringstate.conversationsonsource === 'autonomous'. Non-autonomous-tagged entries with the same id survive inothers. Refresh restores the active conversation from Zustand-persisted state (localStorage mode) without a guaranteedsourcetag, andloadConversationsFromServerseparately preservesactiveConversationIdeven if the server didn't return it (chat-store.ts ~L1000–L1005). The synth fires independently and produces a second entry with the canonical id. - Decision: Dedupe by
idafter both loaders run. When two entries share an id, prefer the autonomous-synthesized one forsource/messages/a2aEventsif present, and keep any user-typed messages by merging onmessage.id(the existing logic at L1157–L1161 already does this for the autonomous case — we just need to make sure no second copy escapes intoothers). - Rationale: One-line invariant ("no two sidebar entries share an id") is simpler than re-architecting the source model. Matches Constitution I (Worse is Better).
- Alternatives considered:
- Reconcile
sourceon every fetch — rejected: requires touching every loader and risks losing source for legitimately non-autonomous conversations that share an id by coincidence (unlikely, but more invasive). - Remove Zustand
persistentirely in MongoDB mode — rejected: persistence is used for many other UX wins (drafts, selected turn, etc.); out of scope.
- Reconcile
- Trace:
-
Why does refresh sometimes put the user in Audit Mode?
- Trace:
requireConversationAccess(api-middleware.ts L443–L529) checks owner → public-share → user-share → team-share → email-grant → admin (admin_audit) → autonomous (shared_readonly) → forbid. The admin check (L514–L517) fires before the autonomous check (L525–L527).ChatContainer(L201–L203) storesaccess_levelverbatim, andChatPanelrenders the audit banner wheneverreadOnlyReason === 'admin_audit', regardless of whether the URL contains?from=audit-logs|feedback(theadminOriginsearchParam). - On refresh, the URL of an autonomous or other admin-viewed conversation typically does not carry the
?from=...query param (the user navigated to the conversation slug directly, or the param was lost across a SPA navigation), soadminOriginisnullbutaccess_level === 'admin_audit'— banner shows, "Back to Feedback" defaults in. - Decision (two-part, defense in depth):
- Server: reorder
requireConversationAccessso thesource === 'autonomous'branch returnsshared_readonlybefore the admin fallback. Admins viewing autonomous conversations get the same view as everyone else. - Client: in
ChatContainer, when computingreadOnlyReason, only honoradmin_auditwhenadminOriginis set ('audit-logs'or'feedback'). When the API returnsadmin_auditbut noadminOrigin(or an unrecognized value), the user is admin-browsing — route through the existingshared_readonlyUI branch (Inv-C2, option A): composer hidden, standard sharing banner, no admin back-link. This avoids a silent send-failure UX (the server still rejects writes foradmin_auditregardless of URL state) without introducing a newreadOnlyReasonvalue or changing theChatPanelprop-type union. Presentation-only; the server remains authoritative for authorization.
- Server: reorder
- Rationale: Server fix closes the root authorization mislabel for autonomous; client fix closes the gap for any other "admin viewing not-their-own conversation" path (legitimate audit must come from the audit-logs/feedback views).
- Alternatives considered:
- Persist
adminOriginacross refresh in the URL — rejected: URL is the source of truth; we should not silently restore admin/audit context. - Always show audit banner for admins on others' conversations — rejected: violates the spec (FR-004, FR-005) and confuses admins doing day-to-day work.
- Persist
- Trace:
-
Does the
localStorage-persisted state need cleanup?- Decision: No mass clear. The existing one-time cleanup at chat-store.ts ~L2087–L2097 already removes a stale legacy cache key. We do ensure the dedupe runs on rehydration (via
onRehydrateStorageor naturally at the nextloadAutonomousConversationsFromServicecall after mount) so users with stale persisted state are healed on first refresh after this fix ships. - Alternative: Bump the persist key version — rejected: drops the user's draft/turn-selection state unnecessarily.
- Decision: No mass clear. The existing one-time cleanup at chat-store.ts ~L2087–L2097 already removes a stale legacy cache key. We do ensure the dedupe runs on rehydration (via
-
Best practices applied:
- Zustand persistence: minimize partialize shape; never persist mode/permission flags. (Already followed —
a2aEvents,streamEvents, and per-session flags are excluded.) - Next.js App Router: query params drive in-session context; do not restore from
localStorage. - Defense in depth (Constitution VII): both server and client enforce; server is authoritative.
- Zustand persistence: minimize partialize shape; never persist mode/permission flags. (Already followed —
Phase 1 — Design & Contracts
Deliverables in this directory:
-
data-model.md— UI state invariants:- Inv-A:
chatStore.conversationsMUST NOT contain two entries with the sameid, regardless ofsource. Enforced at three sites (rehydrate, server loader, autonomous loader) so the invariant holds in every storage mode and across back-to-back F5 refreshes — seedata-model.md"State transitions" and "Back-to-back-refresh resilience". - Inv-B: A conversation's
sourceis set exactly once per fetch path (server response, autonomous synth, or fallbackundefined). When two paths produce the sameid, autonomous wins for thesourcelabel; messages from either are merged by message id (existing logic). - Inv-C:
readOnlyReason === 'admin_audit'MAY only be rendered in the UI when (a) the API returnedaccess_level === 'admin_audit'and (b)adminOrigin ∈ {'audit-logs', 'feedback'}is present in the current URL searchParams. The?from=param is a per-page-load signal sourced from the live URL and is intentionally allowed to carry across cross-conversation navigation in the same session — seedata-model.md"Cross-conversation?from=carry-over". - Inv-C2 (new — Option A
shared_readonlyfallback): When the server returnsaccess_level === 'admin_audit'but the Inv-C gate evaluates false (no?from=, or unrecognized?from=value), the UI MUST render the existingshared_readonlyread-only treatment —readOnlyReason === 'shared_readonly', composer hidden, standard sharing banner, no admin back-link. No newreadOnlyReasonvalue is introduced; theChatPanelrendering and prop-type union are unchanged. FR-005 still holds because non-admin users cannot reachaccess_level === 'admin_audit'server-side, so this fallback branch is unreachable for non-admins. - Inv-D: For a conversation with
source === 'autonomous', the server MUST returnaccess_level === 'shared_readonly'(or'owner'if the user owns it), never'admin_audit'. - Inv-E: Zustand-persisted state MUST NOT include
access_level,readOnlyReason,adminOrigin, or other authorization/session signals. Enforced via a split denylist (top-level vs recursive) so thatChatMessage.role(a non-authorization sender field) is not falsely flagged — seedata-model.mdInv-E table. - Inv-F (new — FR-008 coverage): Every read-only-trigger UI flag (
admin_audit,shared_readonly,agent-deleted,agent-disabled) is derived per render from the current session, current URL, or current server response. None is persisted. Persistence-side enforcement is in T013 (thepartializetree-walk). - Inv-G (new — cross-loader merge safety): When
loadConversationsFromServerandloadAutonomousConversationsFromServicewrite toconversationsconcurrently, neither write may discard the other's contribution. Implemented by converting the server loader's final imperativeset(value)to the callback formset((state) => ...)so autonomous-source entries written between snapshot-read and write are preserved. (Same-loader re-entry of the autonomous loader is already safe by construction: the Map-based dedupe inside that loader is idempotent on its own snapshot.)
- Inv-A:
-
quickstart.md— manual reproduction and verification:- Repro 1 (duplicate tab): seed an autonomous task; open it in the chat; hard refresh; verify the sidebar shows exactly one entry.
- Repro 2 (audit mode): sign in as an admin; open the admin's own conversation; hard refresh; verify no audit banner. Then open an autonomous conversation (no
?from=...); refresh; verify no audit banner. Then enter via/admin?tab=audit-logs→ conversation; verify audit banner appears; refresh; verify behavior matches FR-004 (the URL?from=audit-logsshould still be present in the legitimate flow). - Repro 3 (non-admin safety): sign in as a non-admin; confirm under no circumstance is the audit banner shown after any refresh.
-
contracts/— N/A. No external interface contract changes. The internal Next.js/api/chat/conversations/[id]response shape is unchanged; only the values ofaccess_levelfor autonomous-source conversations under admin sessions change (admin_audit → shared_readonly). This is covered as a server invariant indata-model.mdand asserted inui/src/app/api/__tests__/admin-audit-access.test.ts. -
Database migrations — not applicable; see section above.
-
Agent context update: Run
bash .specify/scripts/bash/update-agent-context.sh cursor-agentto refreshAGENTS.md/CLAUDE.mdrecent-changes blocks. No new technology is introduced; the update is a no-op or appends only the feature name.
Constitution Re-check (post-design)
Re-evaluated all gates with the design above:
- Worse-is-Better, YAGNI: Confirmed — three small functional edits, no new modules.
- Security by Default: Confirmed — server-side change tightens autonomous to
shared_readonlyinstead ofadmin_audit; client change adds a gating condition, never relaxes one. Non-admins remain locked out of audit mode at multiple layers. - CI Gates: Confirmed — added tests cover new invariants; existing tests should pass unchanged.
Gate result: All gates still pass.
Phase 2 — Stop point
Per the speckit.plan workflow, this command stops here. Task breakdown happens in /speckit.tasks. The expected high-level task list (refined post-/speckit.analyze re-review):
- Server: reorder
requireConversationAccessinui/src/lib/api-middleware.tsso autonomous-source returnsshared_readonlybefore the admin fallback (Inv-D). - Server tests: extend
ui/src/app/api/__tests__/admin-audit-access.test.tsto assert (admin + autonomous source → shared_readonly) and (admin + non-autonomous, non-owner → admin_audit unchanged). - Store: in
loadAutonomousConversationsFromService(ui/src/store/chat-store.ts), add a final dedupe-by-id pass onfinalso no two entries share anid; when collision happens, prefer the autonomous-synth copy (Inv-A, Inv-B). - Store: also apply the dedupe-by-id pass in
loadConversationsFromServerfor the[...serverConversations, ...localOnlyPreserved]concatenation (defense in depth). - Store: also apply the dedupe-by-id pass inside
onRehydrateStorageso the rehydrate path is self-healing on first paint, before any network call. Closes the back-to-back-refresh edge case and thelocalStoragemode +autonomousAgentsEnabled = falseedge case (finding C2 in/speckit.analyze). - Store tests: extend
ui/src/store/__tests__/chat-store.test.tswith (a) a refresh-rehydration scenario asserting one entry per id after both loaders run; (b) a rehydrate-only scenario asserting the dedupe runs before any loader is invoked. - UI: in
ui/src/components/chat/ChatContainer.tsx, gatereadOnlyReason === 'admin_audit'onadminOrigin ∈ {'audit-logs', 'feedback'}(Inv-C). When the API returnsadmin_auditwithoutadminOrigin, route through the existingshared_readonlyUI branch (Inv-C2, option A). - UI tests: extend
ui/src/components/chat/__tests__/ChatPanel.test.tsxwith regression guards for the existing read-only rendering branches, and addui/src/components/chat/__tests__/ChatContainer.test.tsxcovering: gate-fail (no banner), audit-context leak guard (admin viewing their own conversation), cross-conversation?from=carry-over (intentional behaviour per Inv-C cross-conversation clause, finding C5), and theshared_readonlyfallback branch (Inv-C2, finding N3 in/speckit.analyzere-run). - Verify Zustand
partializedoes not leakaccessLevel/adminOrigin. The regression test uses a split denylist (top-level vs recursive) so thatChatMessage.roleis not falsely flagged (finding C1). - T028 security audit: verify (a) no write-side handler grants extra power on
access_level === 'admin_audit', and (b) admin views of autonomous chats are not audit-logged via theadmin_auditsignal in a way the fix would silently break. Green on both is the precondition for step 1 above. (Persistence-side coverage of Inv-F is enforced end-to-end by step 9 above.) - Cross-loader clobber fix (finding N1 in
/speckit.analyzere-run): T015c — convertloadConversationsFromServer's finalset(...)to the callback form so the autonomous loader's writes are not clobbered by an interleaving server-loader write. Inv-G indata-model.md. Adds an interleave Jest test. - Lint and run UI test suite; update spec/plan with any deviations.
See tasks.md for the granular task breakdown including all NEW tasks (T012a, T015a, T015c, T029a, T029c) introduced by the /speckit.analyze re-reviews.
Code Comment Conventions
These conventions are derived from the existing style in the three target source files (ui/src/store/chat-store.ts, ui/src/components/chat/ChatContainer.tsx, ui/src/lib/api-middleware.ts) and MUST be matched by every new comment introduced by this feature. Tests, documentation, and PR descriptions are NOT bound by these rules — they may reference spec/task/finding identifiers freely. Only source-file comments are constrained.
DO
- Explain why, not what. The code already shows what; the comment captures the rationale, trade-off, or non-obvious constraint that the code itself cannot convey. Examples in the codebase:
chat-store.tsL882:// Prevent multiple simultaneous loads(rationale, not narration).api-middleware.tsL519–L524: a 6-line paragraph above the autonomous branch explaining what an autonomous-agent conversation is and why any authenticated user gets read-only access. The code itself is a 3-lineifblock.
- Use single-line
//comments by default. Multi-line is acceptable when the rationale needs a paragraph (e.g.,api-middleware.tsL519–L524,chat-store.tsL1885–L1889). - Reserve
// ALL-CAPS LABEL:tags for safety / recovery / migration branches. This style already exists for crash-recovery and heal paths (chat-store.tsL1912// CRASH RECOVERY:, L1916// HEAL:). New comments MAY use this convention when introducing a parallel safety branch — e.g.,// REHYDRATE DEDUPE:,// CROSS-LOADER MERGE:. Keep tags short (one or two words) and consistent within a file. - Reference other source files by relative path or symbol name in plain prose. Example from
chat-store.tsL1887: "services/chat_history.py". Backticks around symbol names are NOT required in comments (the codebase uses both styles inconsistently; default to no backticks to match the dominant convention inchat-store.tsandapi-middleware.ts). - Inline
//for short property-level rationale. Example:a2aEvents: [], // Don't persist events (too large)(chat-store.tsL1873). Use sparingly and only when the property's value alone is misleading. - Reference invariants by their canonical name (
Inv-A,Inv-C,Inv-G) where the comment explains a non-obvious safety property. The invariant name acts as a stable cross-reference intodata-model.mdfor a future reviewer. This is the ONE allowed form of "documentation pointer" inside source comments.
DO NOT
- Do NOT cite spec IDs, finding IDs, task IDs, or PR / commit numbers in source-file comments. The spec lives in
docs/docs/specs/2026-05-15-fix-refresh-state-bugs/and is the authoritative reference; cluttering source with// FR-004 / finding N3 / T026 / PR #1234makes comments stale the moment those identifiers change and adds zero information that the code structure does not already convey. Specifically, the following are forbidden in new source-file comments introduced by this feature:- Spec slugs / dates (
2026-05-15-fix-refresh-state-bugs) - Functional-Requirement IDs (
FR-001,FR-008) - Analyze finding IDs (
finding C2,finding N3 in /speckit.analyze,S1,M4) - Task IDs (
T013,T015a,T026) - Tool / workflow names (
/speckit.analyze) - Issue / PR numbers
- Spec slugs / dates (
- Do NOT narrate what the next line does (e.g.,
// Set the variable to true,// Loop over the array). The code is self-evident. - Do NOT use JSDoc / multi-line
/** ... */blocks for inline rationale. The target files do not use JSDoc except on a handful of exported function signatures. Match the local style. - Do NOT add author / date / version markers. Git history is authoritative.
Rationale
The spec, plan, and tasks documents are versioned alongside the code and serve as the durable record of why a change was made and which finding it addresses. Source-file comments serve a different audience — a future maintainer reading the code who needs the minimum non-obvious context to make a safe change. Identifiers like finding N3 are noise to that audience and rapidly stale as the spec evolves; the invariant names (Inv-A…Inv-G) are the only durable cross-reference because they are part of the spec's public contract.
Examples — new comments introduced by this feature
GOOD (matches the conventions; rationale-focused, invariant-tagged, no finding/task IDs):
// Map-based dedupe by id (Inv-A). Autonomous-synth wins on collision so the
// sidebar source label is correct; user-typed messages are merged in below
// by message.id so nothing the user wrote is lost.
const final = Array.from(new Map([...others, ...merged].map(c => [c.id, c])).values());
// REHYDRATE DEDUPE: heals duplicates persisted before this fix shipped and
// makes back-to-back F5 refreshes self-healing without a network call (Inv-A
// site 1). Skip entries with non-string / empty ids to defend against
// tampered or corrupted localStorage.
// Callback-form set() protects Inv-G: if the autonomous loader wrote between
// our snapshot read and this write, the imperative form would silently
// discard those entries. Read the latest state here and re-union.
set((state) => { ... });
// Inv-C: audit banner requires BOTH server admin_audit AND a recognized
// in-session ?from= value. Inv-C2: when the server says admin_audit but the
// gate fails, route through the existing shared_readonly UI branch so the
// user sees a clear read-only banner instead of a silent send-failure. The
// adminOrigin signal is presentation-only — the server is authoritative for
// authorization.
BAD (cites finding / task / FR IDs; reads as a stale changelog):
// Map-based dedupe by id (Inv-A; T014; finding C2 from /speckit.analyze;
// closes FR-001 + FR-002; see docs/docs/specs/2026-05-15-fix-refresh-state-bugs/tasks.md).
// Per finding N3 in the third /speckit.analyze re-review (option A — collapse
// admin_no_origin into shared_readonly), this branch routes the failed-gate
// case through the existing shared_readonly UI; tested by T029c sub-tests 1
// and 2 in ChatContainer.test.tsx.
Enforcement
This is a review-time convention, not a lint rule. PR reviewers SHOULD flag any new source-file comment that cites a finding ID, task ID, or FR ID, and request a rewrite. The convention is documented here so reviewers and authors share the same expectation up front.
Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|---|---|---|
| (none) | — | — |