From c895ee23feb68484e5ed2d430cd44f6aaf0e5273 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 06:23:54 +0000 Subject: [PATCH 1/3] fix: reuse authorized entity ids on global ask cutoff query The final Global Ask source query already binds the knowledge cutoff as $4. Reuse the materialized authorized_entity_ids list as $1 instead of re-listing the original input, and keep the contract in v2.20.1. --- CHANGELOG.d/2.20.1-authorized-entity-ids.md | 4 ++++ CHANGELOG.md | 8 ++++++++ backend/app/post_chat_ingestion.py | 2 +- docs/adr/0125-global-ask-cutoff-and-migration-identity.md | 8 +++++--- frontend/package.json | 2 +- lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- tests/test_ask_project_history.py | 2 ++ tests/test_global_ask_cutoff_postgres.py | 4 ++++ uv.lock | 2 +- 10 files changed, 28 insertions(+), 8 deletions(-) create mode 100644 CHANGELOG.d/2.20.1-authorized-entity-ids.md 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.md b/CHANGELOG.md index c34812a25..89cf4bbef 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.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 0125). + ## [2.20.0] - 2026-08-21 ### Added diff --git a/backend/app/post_chat_ingestion.py b/backend/app/post_chat_ingestion.py index 7ccc723c4..8daf01b1f 100644 --- a/backend/app/post_chat_ingestion.py +++ b/backend/app/post_chat_ingestion.py @@ -743,7 +743,7 @@ async def gather_global_chat_sources( created_at desc, post_id desc limit $3 """, - list(authorized_corporate_entity_ids), + authorized_entity_ids, candidate_ids, limit, cutoff, diff --git a/docs/adr/0125-global-ask-cutoff-and-migration-identity.md b/docs/adr/0125-global-ask-cutoff-and-migration-identity.md index 48745b065..0cf64105b 100644 --- a/docs/adr/0125-global-ask-cutoff-and-migration-identity.md +++ b/docs/adr/0125-global-ask-cutoff-and-migration-identity.md @@ -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/frontend/package.json b/frontend/package.json index bd8c9ff59..cbc4050b7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.20.0", + "version": "2.20.1", "type": "module", "scripts": { "dev": "vite", diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 2cb406a3f..b07c2a3e9 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "2.20.0" +__version__ = "2.20.1" diff --git a/pyproject.toml b/pyproject.toml index 8d34399ec..23544694d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.20.0" +version = "2.20.1" 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..c0327195e 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "2.20.0" +version = "2.20.1" source = { editable = "." } dependencies = [ { name = "certifi" }, From f5878da788a1867d3b2525623fbd94441cac12f0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 17:07:45 +0900 Subject: [PATCH 2/3] fix: keep stacked ADR identities unique --- CHANGELOG.md | 4 ++-- ...ty.md => 0126-global-ask-cutoff-and-migration-identity.md} | 2 +- ...7-recover-tepp-validation-on-canonical-project-history.md} | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) rename docs/adr/{0125-global-ask-cutoff-and-migration-identity.md => 0126-global-ask-cutoff-and-migration-identity.md} (96%) rename docs/adr/{0112-recover-tepp-validation-on-canonical-project-history.md => 0127-recover-tepp-validation-on-canonical-project-history.md} (98%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89cf4bbef..5c54dfa56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ All notable changes to this project are documented here. Format follows - 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 0125). + original input (ADR 0126). ## [2.20.0] - 2026-08-21 @@ -33,7 +33,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/0125-global-ask-cutoff-and-migration-identity.md b/docs/adr/0126-global-ask-cutoff-and-migration-identity.md similarity index 96% 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 0cf64105b..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 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 From 881ae3030995cedf6612c6ed2455f2421070e713 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 02:08:13 -0700 Subject: [PATCH 3/3] fix: reuse shared Global Ask session storage key (v2.20.2) (#360) * fix: reuse shared Global Ask session storage key acceptAnswer and the 409 stale-citation restart wrote or cleared a string-literal sessionStorage key while bootstrap, 404 retry, and logout already used GLOBAL_ASK_SESSION_STORAGE_KEY. A later key rename would desynchronize recovery. Export the constant, use it at every site, and prove a 409 restart rewrites the shared key. * fix: keep stacked ADR identities unique --- CHANGELOG.d/2.20.2-ask-session-storage-key.md | 4 ++ CHANGELOG.md | 8 ++++ ...3-project-history-links-in-ask-surfaces.md | 4 +- docs/product-technical-gap-baseline.md | 4 +- frontend/package.json | 2 +- frontend/src/App.test.tsx | 37 +++++++++++++++++-- frontend/src/App.tsx | 6 +-- lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 10 files changed, 59 insertions(+), 12 deletions(-) create mode 100644 CHANGELOG.d/2.20.2-ask-session-storage-key.md 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" },