diff --git a/CHANGELOG.d/2.20.1-authorized-entity-ids.md b/CHANGELOG.d/2.20.1-authorized-entity-ids.md
new file mode 100644
index 000000000..b53353201
--- /dev/null
+++ b/CHANGELOG.d/2.20.1-authorized-entity-ids.md
@@ -0,0 +1,4 @@
+### Fixed
+
+- Reuse the materialized authorized-entity identifier list as `$1` on the final
+ Global Ask source query.
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 c34812a25..23599b470 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,22 @@ 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
+
+- Global Ask now reuses the already-materialized authorized corporate-entity
+ identifiers on the final cutoff-bounded source query instead of re-listing the
+ original input (ADR 0126).
+
## [2.20.0] - 2026-08-21
### Added
@@ -25,7 +41,7 @@ All notable changes to this project are documented here. Format follows
- Recovered the credential-free TEPP project-history validation boundary on top of
the canonical Buyer timeline. TEPP may return only cutoff-safe temporal
associations over the exact authorized events; the timeline remains readable
- when TEPP is absent, and no result is labelled as a cause (ADR 0112).
+ when TEPP is absent, and no result is labelled as a cause (ADR 0127).
## [2.18.0] - 2026-08-20
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/adr/0125-global-ask-cutoff-and-migration-identity.md b/docs/adr/0126-global-ask-cutoff-and-migration-identity.md
similarity index 80%
rename from docs/adr/0125-global-ask-cutoff-and-migration-identity.md
rename to docs/adr/0126-global-ask-cutoff-and-migration-identity.md
index 48745b065..787393e0c 100644
--- a/docs/adr/0125-global-ask-cutoff-and-migration-identity.md
+++ b/docs/adr/0126-global-ask-cutoff-and-migration-identity.md
@@ -1,4 +1,4 @@
-# ADR 0125 — Bind Global Ask cutoffs and keep migration identities unique
+# ADR 0126 — Bind Global Ask cutoffs and keep migration identities unique
**Decision status:** Accepted on the PR #342 repair branch
**Date:** 2026-08-21
@@ -17,9 +17,11 @@ push rather than leaving the branch itself correct.
## Decision
1. Bind the cutoff as the fourth argument of the final Global Ask source query.
-2. Assign the cutoff schema change the next unique forward migration identity,
+2. Reuse the already-materialized `authorized_entity_ids` list as `$1` instead of
+ re-iterating `authorized_corporate_entity_ids`.
+3. Assign the cutoff schema change the next unique forward migration identity,
`0054`, and update rollback, migration dispatch, and contract tests.
-3. Keep reproduction and regression checks in committed tests. Do not use a
+4. Keep reproduction and regression checks in committed tests. Do not use a
workflow that edits, commits, pushes, or deletes product source at runtime.
## Consequences
@@ -33,7 +35,7 @@ push rather than leaving the branch itself correct.
## Verification
- The synthetic query contract asserts the fourth argument is the requested
- cutoff.
+ cutoff and the first argument is the materialized authorized-entity list.
- The PostgreSQL integration contract executes the final query against a real
local PostgreSQL parser when `LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN` is set.
- Migration identity tests reject duplicate numeric prefixes and require the
diff --git a/docs/adr/0112-recover-tepp-validation-on-canonical-project-history.md b/docs/adr/0127-recover-tepp-validation-on-canonical-project-history.md
similarity index 98%
rename from docs/adr/0112-recover-tepp-validation-on-canonical-project-history.md
rename to docs/adr/0127-recover-tepp-validation-on-canonical-project-history.md
index ade0d032a..5a42d964e 100644
--- a/docs/adr/0112-recover-tepp-validation-on-canonical-project-history.md
+++ b/docs/adr/0127-recover-tepp-validation-on-canonical-project-history.md
@@ -1,4 +1,4 @@
-# ADR 0112: Recover TEPP validation on the canonical project history
+# ADR 0127: Recover TEPP validation on the canonical project history
- Status: Proposed
- Date: 2026-08-21
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 bd8c9ff59..430270dba 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
- "version": "2.20.0",
+ "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 2cb406a3f..e41a13a68 100644
--- a/lineageweave/__init__.py
+++ b/lineageweave/__init__.py
@@ -55,4 +55,4 @@
"sentence_excerpts",
]
-__version__ = "2.20.0"
+__version__ = "2.20.2"
diff --git a/pyproject.toml b/pyproject.toml
index 8d34399ec..d9217bc48 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "lineageweave"
-version = "2.20.0"
+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/tests/test_ask_project_history.py b/tests/test_ask_project_history.py
index c9420be90..0b60743e1 100644
--- a/tests/test_ask_project_history.py
+++ b/tests/test_ask_project_history.py
@@ -204,6 +204,8 @@ async def fetch(self, query: str, *args: object):
source_query, source_args = source_calls[0]
assert "source_deleted_flag" in source_query
assert "created_at <= $4" in source_query
+ assert len(source_args) == 4
+ assert list(source_args[0]) == ["tenant-a"]
assert source_args[3] == CUTOFF
diff --git a/tests/test_global_ask_cutoff_postgres.py b/tests/test_global_ask_cutoff_postgres.py
index 5d07797a8..c09fe13f2 100644
--- a/tests/test_global_ask_cutoff_postgres.py
+++ b/tests/test_global_ask_cutoff_postgres.py
@@ -75,6 +75,10 @@ async def fetch(self, query: str, *args: object):
)
assert result == []
assert boundary.final_args is not None
+ assert len(boundary.final_args) == 4
+ assert list(boundary.final_args[0]) == [
+ "00000000-0000-4000-8000-000000000001"
+ ]
assert boundary.final_args[3] == CUTOFF
finally:
await connection.close()
diff --git a/uv.lock b/uv.lock
index fe7edf1ef..0816f8215 100644
--- a/uv.lock
+++ b/uv.lock
@@ -454,7 +454,7 @@ wheels = [
[[package]]
name = "lineageweave"
-version = "2.20.0"
+version = "2.20.2"
source = { editable = "." }
dependencies = [
{ name = "certifi" },