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" },