diff --git a/CHANGELOG.d/2.20.2-ask-session-storage-key.md b/CHANGELOG.d/2.20.2-ask-session-storage-key.md new file mode 100644 index 000000000..eb3197e68 --- /dev/null +++ b/CHANGELOG.d/2.20.2-ask-session-storage-key.md @@ -0,0 +1,4 @@ +### Fixed + +- Use one shared Global Ask `sessionStorage` key for bootstrap, persist, 404 + retry, 409 restart, and logout. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c54dfa56..23599b470 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.20.2] - 2026-08-21 + +### Fixed + +- Global Ask now reads, writes, and clears one shared `sessionStorage` key for + bootstrap, successful answers, 404 retry, 409 stale-citation restart, and + logout, so a restart cannot leave a desynchronized session id (ADR 0113). + ## [2.20.1] - 2026-08-21 ### Fixed diff --git a/docs/adr/0113-project-history-links-in-ask-surfaces.md b/docs/adr/0113-project-history-links-in-ask-surfaces.md index b009fe847..7383a904d 100644 --- a/docs/adr/0113-project-history-links-in-ask-surfaces.md +++ b/docs/adr/0113-project-history-links-in-ask-surfaces.md @@ -29,7 +29,9 @@ reusing it as conversation context can disclose facts no longer authorized. Its prose cannot be safely decomposed by source after access changes. 6. A Global Ask session is rejected and restarted when any citation in its persisted continuity context is no longer authorized. Stored summaries are not reused across - that boundary. + that boundary. The browser persists that session identifier under one shared + `sessionStorage` key for bootstrap, successful answers, 404 retry, 409 restart, and + logout; those sites must not copy the key as a string literal. 7. Ask retrieval itself applies the same cutoff and source eligibility before an LLM sees evidence. Prompt bodies, hidden IDs, and unauthorized project counts never enter the project-history link response. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 813d19da3..fccd061b2 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -338,7 +338,9 @@ runtime note into a shipped/live claim. implemented in either Ask surface. - Source publication eligibility and cutoff are applied before Ask retrieval. Persisted answers are withheld when any citation loses visibility, and a Global Ask session with - stale citations must start a new session before prior answer prose is reused. + stale citations must start a new session before prior answer prose is reused. The + browser session identifier uses one shared `sessionStorage` key across bootstrap, + persist, 404 retry, 409 restart, and logout. - The response bounds citation and project counts, discloses truncated project links, and keeps answers readable when a timeline or TEPP validation is unavailable. - Remaining causal-analysis work is explicitly outside this slice: temporal association diff --git a/frontend/package.json b/frontend/package.json index cbc4050b7..430270dba 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.20.1", + "version": "2.20.2", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index a772e57c0..89ba1c659 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1,7 +1,7 @@ import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import App from "./App"; +import App, { GLOBAL_ASK_SESSION_STORAGE_KEY } from "./App"; import { setLocale } from "./i18n"; import { isoWeekFromCreatedAt } from "./isoWeek"; @@ -91,6 +91,7 @@ describe("App, authenticated", () => { deferSecondAsk?: boolean; deferProjectHistory?: boolean; invalidAskSessionOnce?: boolean; + staleAskCitationsOnce?: boolean; meFailed?: boolean; postBody?: string; manyCustomerHints?: number; @@ -1699,6 +1700,16 @@ describe("App, authenticated", () => { }), ); } + if (options?.staleAskCitationsOnce && askRequestCount === 1 && requestBody.session_id) { + return Promise.resolve( + new Response( + JSON.stringify({ + detail: "Global Ask session evidence is no longer authorized; start a new session", + }), + { status: 409, headers: { "Content-Type": "application/json" } }, + ), + ); + } const ready = options?.deferSecondAsk && askRequestCount === 2 ? secondAskReady @@ -1951,7 +1962,7 @@ describe("App, authenticated", () => { }); it("replaces an invalid saved Ask session without requiring storage cleanup", async () => { - window.sessionStorage.setItem("lineageweave.globalAskSessionId", "stale-session"); + window.sessionStorage.setItem(GLOBAL_ASK_SESSION_STORAGE_KEY, "stale-session"); const fetchMock = stubBackend({ invalidAskSessionOnce: true }); render(); @@ -1967,7 +1978,27 @@ describe("App, authenticated", () => { .filter(([url]) => String(url).endsWith("/api/ask")) .map(([, init]) => JSON.parse(String((init as RequestInit).body)) as { session_id?: string }); expect(askBodies.map((body) => body.session_id)).toEqual(["stale-session", undefined]); - expect(window.sessionStorage.getItem("lineageweave.globalAskSessionId")).toBe("session-1"); + expect(window.sessionStorage.getItem(GLOBAL_ASK_SESSION_STORAGE_KEY)).toBe("session-1"); + }); + + it("restarts a Global Ask session whose citations lost visibility using the shared storage key", async () => { + window.sessionStorage.setItem(GLOBAL_ASK_SESSION_STORAGE_KEY, "stale-session"); + const fetchMock = stubBackend({ staleAskCitationsOnce: true }); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "Ask Agent" })); + const ask = await screen.findByRole("region", { name: "Ask Agent" }); + await userEvent.type(within(ask).getByRole("textbox", { name: "Ask a question" }), "Which project?"); + await userEvent.click(within(ask).getByRole("button", { name: "Ask" })); + + expect( + await within(ask).findByText("The cited project is supported by the stored semantic evidence."), + ).toBeInTheDocument(); + const askBodies = fetchMock.mock.calls + .filter(([url]) => String(url).endsWith("/api/ask")) + .map(([, init]) => JSON.parse(String((init as RequestInit).body)) as { session_id?: string }); + expect(askBodies.map((body) => body.session_id)).toEqual(["stale-session", undefined]); + expect(window.sessionStorage.getItem(GLOBAL_ASK_SESSION_STORAGE_KEY)).toBe("session-1"); }); it("labels the Customer Master entity level and Keymen side, never the raw lookup code", async () => { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 526d73316..47ea5cc75 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -118,7 +118,7 @@ import { } from "./analysisRunNavigation"; import "./App.css"; -const GLOBAL_ASK_SESSION_STORAGE_KEY = "lineageweave.globalAskSessionId"; +export const GLOBAL_ASK_SESSION_STORAGE_KEY = "lineageweave.globalAskSessionId"; function orchestratorUnavailableMessage(err: unknown, action: string): string { if (err instanceof BackendError && err.status === 503) { @@ -4624,7 +4624,7 @@ function AskAgentPanel({ function acceptAnswer(nextAnswer: AskAgentResponse) { setAnswer(nextAnswer); setSessionId(nextAnswer.session_id); - window.sessionStorage.setItem("lineageweave.globalAskSessionId", nextAnswer.session_id); + window.sessionStorage.setItem(GLOBAL_ASK_SESSION_STORAGE_KEY, nextAnswer.session_id); } async function handleAsk() { @@ -4648,7 +4648,7 @@ function AskAgentPanel({ acceptAnswer(nextAnswer); } catch (err) { if (err instanceof BackendError && err.status === 409 && sessionId) { - window.sessionStorage.removeItem("lineageweave.globalAskSessionId"); + window.sessionStorage.removeItem(GLOBAL_ASK_SESSION_STORAGE_KEY); setSessionId(undefined); try { acceptAnswer(await askAgent(accessToken, normalized)); diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index b07c2a3e9..e41a13a68 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "2.20.1" +__version__ = "2.20.2" diff --git a/pyproject.toml b/pyproject.toml index 23544694d..d9217bc48 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.20.1" +version = "2.20.2" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/uv.lock b/uv.lock index c0327195e..0816f8215 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "2.20.1" +version = "2.20.2" source = { editable = "." } dependencies = [ { name = "certifi" },