From 5822948fe915542f1f916618d8905c3b4715d676 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 05:45:46 +0900 Subject: [PATCH 1/6] Harden tenant settings audit and edit state --- CHANGELOG.md | 3 +++ backend/app/main.py | 3 ++- backend/tests/test_api.py | 11 +++++++++++ frontend/src/components/AdminPanel.test.tsx | 11 +++++++++++ frontend/src/components/AdminPanel.tsx | 2 +- 5 files changed, 28 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72e273461..cb3854381 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ All notable changes to this project are documented here. Format follows ### Fixed +- Tenant settings now refresh their audit timestamp on every successful update, + and the year field stays visibly blank while an administrator edits it. + - Tenant identity metadata migration now repairs blank legacy settings before adding non-empty and copyright-year constraints, so existing Compose volumes can replay the migration without losing valid tenant-provided values. diff --git a/backend/app/main.py b/backend/app/main.py index 3059f385d..018401b30 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -751,7 +751,8 @@ async def update_tenant_settings( brand_name = EXCLUDED.brand_name, system_name = EXCLUDED.system_name, copyright_year = EXCLUDED.copyright_year, - copyright_holder = EXCLUDED.copyright_holder + copyright_holder = EXCLUDED.copyright_holder, + updated_at = now() """, brand_name, system_name, diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index e85745317..5beb6733c 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -1321,6 +1321,12 @@ def test_settings_patch_requires_post_admin(client, demo_analyst_token, seeded_d "copyrightHolder": "LineageWeave Demo", } + with psycopg2.connect(seeded_db["dsn"]) as connection: + with connection.cursor() as cursor: + cursor.execute( + "update tenant_settings set updated_at = '2000-01-01T00:00:00Z' where tenant_settings_id = 1" + ) + legacy = client.patch( "/api/settings", json={"brandName": "Legacy Client Brand"}, @@ -1334,6 +1340,11 @@ def test_settings_patch_requires_post_admin(client, demo_analyst_token, seeded_d "copyrightHolder": "LineageWeave Demo", } + with psycopg2.connect(seeded_db["dsn"]) as connection: + with connection.cursor() as cursor: + cursor.execute("select updated_at from tenant_settings where tenant_settings_id = 1") + assert cursor.fetchone()[0].year > 2000 + confirm = client.get("/api/settings", headers={"Authorization": f"Bearer {demo_analyst_token}"}) assert confirm.json() == legacy.json() diff --git a/frontend/src/components/AdminPanel.test.tsx b/frontend/src/components/AdminPanel.test.tsx index c4d5ced6f..4fad94259 100644 --- a/frontend/src/components/AdminPanel.test.tsx +++ b/frontend/src/components/AdminPanel.test.tsx @@ -106,6 +106,17 @@ describe("AdminPanel", () => { expect(fetchMock).not.toHaveBeenCalled(); }); + it("keeps a cleared copyright year visibly blank while editing", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /Tenant settings/ })); + const yearInput = screen.getByRole("spinbutton", { name: "Tenant copyright year" }); + + fireEvent.change(yearInput, { target: { value: "" } }); + + expect(yearInput).toHaveValue(null); + expect(screen.getByRole("button", { name: "Save settings" })).toBeDisabled(); + }); + it("does not submit unchanged settings through the form", () => { const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index b5e6f0798..91eccf05e 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -345,7 +345,7 @@ export function AdminPanel({ currentTenantConfig, onTenantConfigChange, accessTo updateDraftField("copyrightYear", Number(e.target.value))} aria-label={t("Tenant copyright year")} min={COPYRIGHT_YEAR_MIN} From 779d33771620f8e130e2ff6a84932ea3039af892 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 05:51:02 +0900 Subject: [PATCH 2/6] Keep tenant settings no-op saves disabled --- CHANGELOG.md | 3 ++- frontend/src/components/AdminPanel.test.tsx | 10 ++++++++++ frontend/src/components/AdminPanel.tsx | 4 ++-- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb3854381..a9a79fa14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,8 @@ All notable changes to this project are documented here. Format follows ### Fixed - Tenant settings now refresh their audit timestamp on every successful update, - and the year field stays visibly blank while an administrator edits it. + keep the year field visibly blank while it is edited, and disable no-op + whitespace-only saves. - Tenant identity metadata migration now repairs blank legacy settings before adding non-empty and copyright-year constraints, so existing Compose volumes diff --git a/frontend/src/components/AdminPanel.test.tsx b/frontend/src/components/AdminPanel.test.tsx index 4fad94259..bf9a44f76 100644 --- a/frontend/src/components/AdminPanel.test.tsx +++ b/frontend/src/components/AdminPanel.test.tsx @@ -128,6 +128,16 @@ describe("AdminPanel", () => { expect(fetchMock).not.toHaveBeenCalled(); }); + it("keeps the save action disabled for whitespace-only edits", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /Tenant settings/ })); + fireEvent.change(screen.getByRole("textbox", { name: "Tenant brand name" }), { + target: { value: " LineageWeave " }, + }); + + expect(screen.getByRole("button", { name: "Save settings" })).toBeDisabled(); + }); + it("refreshes the draft when the fetched tenant config arrives", () => { const { rerender } = render(); diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index 91eccf05e..1f7d3c7e9 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -366,10 +366,10 @@ export function AdminPanel({ currentTenantConfig, onTenantConfigChange, accessTo || draftConfig.copyrightYear < COPYRIGHT_YEAR_MIN || draftConfig.copyrightYear > COPYRIGHT_YEAR_MAX || ( - draftConfig.brandName === currentTenantConfig.brandName + draftConfig.brandName.trim() === currentTenantConfig.brandName && draftConfig.systemName === currentTenantConfig.systemName && draftConfig.copyrightYear === currentTenantConfig.copyrightYear - && draftConfig.copyrightHolder === currentTenantConfig.copyrightHolder + && draftConfig.copyrightHolder.trim() === currentTenantConfig.copyrightHolder ) } > From f101ab938f844ea12fa78b1cdf4ea1adaf07e962 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 05:57:14 +0900 Subject: [PATCH 3/6] fix: decouple blank year input from zero --- frontend/src/components/AdminPanel.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index 1f7d3c7e9..e2b776c10 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -345,8 +345,8 @@ export function AdminPanel({ currentTenantConfig, onTenantConfigChange, accessTo updateDraftField("copyrightYear", Number(e.target.value))} + value={Number.isNaN(draftConfig.copyrightYear) ? "" : draftConfig.copyrightYear} + onChange={(e) => updateDraftField("copyrightYear", e.target.value === "" ? Number.NaN : Number(e.target.value))} aria-label={t("Tenant copyright year")} min={COPYRIGHT_YEAR_MIN} max={COPYRIGHT_YEAR_MAX} From 2150bcc9668bf529fbd9e6e0f92394be065377be Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 06:04:23 +0900 Subject: [PATCH 4/6] fix: roll back unauthorized Global Ask turns --- CHANGELOG.md | 4 ++ backend/app/global_ask_history.py | 39 +++++++++++ backend/app/main.py | 7 ++ backend/tests/test_api.py | 104 ++++++++++++++++++++++++++++++ 4 files changed, 154 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9a79fa14..023f34ea8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ All notable changes to this project are documented here. Format follows ### Fixed +- Global Ask now re-authorizes cited posts under row locks inside the same + persistence transaction, rolling back a raced turn without poisoning the + conversation or exposing an unauthorized answer (Issue #362). + - Tenant settings now refresh their audit timestamp on every successful update, keep the year field visibly blank while it is edited, and disable no-op whitespace-only saves. diff --git a/backend/app/global_ask_history.py b/backend/app/global_ask_history.py index b4840cbcd..1f15cd38b 100644 --- a/backend/app/global_ask_history.py +++ b/backend/app/global_ask_history.py @@ -14,6 +14,10 @@ class GlobalAskConversationNotFound(LookupError): """The requested conversation is absent or owned by another account.""" +class GlobalAskEvidenceChanged(RuntimeError): + """A cited post became unauthorized before the new turn could commit.""" + + def conversation_title(question: str) -> str: """Use the first question as a bounded, readable transcript label.""" compact = " ".join(question.strip().split()) @@ -250,6 +254,32 @@ async def fetch_conversation( } +async def _ensure_citations_visible( + conn: asyncpg.Connection, + conversation_id: UUID, + turn_ordinal: int, + cited_post_count: int, + can_see_post: Callable[[asyncpg.Record], bool], +) -> None: + """Lock and re-authorize new citations before their transaction commits.""" + rows = await conn.fetch( + """ + select relation.cited_post_id::text as post_id, + post.post_title, post.visibility_code, post.corporate_entity_id, + post.author_account_id, post.source_detail_state_code + from global_ask_turn_citation relation + join source_post post on post.post_id = relation.cited_post_id + where relation.global_ask_session_id = $1 + and relation.turn_ordinal = $2 + for share of post + """, + conversation_id, + turn_ordinal, + ) + if len(rows) != cited_post_count or any(not can_see_post(row) for row in rows): + raise GlobalAskEvidenceChanged + + async def persist_turn( conn: asyncpg.Connection, user_account_id: str, @@ -260,6 +290,7 @@ async def persist_turn( source_post_ids: Iterable[str], cited_post_ids: Iterable[str], cited_post_evidence: Iterable[dict[str, Any]], + can_see_post: Callable[[asyncpg.Record], bool] | None = None, ) -> UUID: source_ids = list(dict.fromkeys(str(post_id) for post_id in source_post_ids)) source_set = set(source_ids) @@ -348,5 +379,13 @@ async def persist_turn( "update global_ask_session set updated_at = now() where global_ask_session_id = $1", conversation_id, ) + if can_see_post is not None: + await _ensure_citations_visible( + conn, + conversation_id, + ordinal, + len(cited_ids), + can_see_post, + ) assert conversation_id is not None return conversation_id diff --git a/backend/app/main.py b/backend/app/main.py index 018401b30..15350050f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -137,6 +137,7 @@ from backend.app.five_w1h_ingestion import load_five_w1h_slots from backend.app.global_ask_history import ( GlobalAskConversationNotFound, + GlobalAskEvidenceChanged, conversation_exists, fetch_conversation, list_conversations, @@ -3254,7 +3255,13 @@ async def ask_agent( response["source_post_ids"], response["cited_post_ids"], response["cited_post_evidence"], + can_see_post=lambda row: _can_use_post_for_analysis(account, row), ) + except GlobalAskEvidenceChanged as exc: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Ask Agent is unavailable: authorized evidence changed; retry the question", + ) from exc except GlobalAskConversationNotFound as exc: raise HTTPException(status.HTTP_404_NOT_FOUND, "conversation not found") from exc response["conversation_id"] = str(persisted_conversation_id) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 5beb6733c..cd4662a5a 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -126,6 +126,11 @@ / "migrations" / "0104_two_word_database_identifiers.sql" ) +_GLOBAL_ASK_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0105_global_ask_conversation_history.sql" +) _TENANT_IDENTITY_METADATA_MIGRATION = ( Path(__file__).resolve().parents[2] / "migrations" @@ -287,6 +292,7 @@ def seeded_db(demo_analyst_token): cur.execute(_PROJECT_BOUND_EVENT_MIGRATION.read_text()) cur.execute(_TENANT_SETTINGS_MIGRATION.read_text()) cur.execute(_IDENTIFIER_MIGRATION.read_text()) + cur.execute(_GLOBAL_ASK_MIGRATION.read_text()) cur.execute(_TENANT_IDENTITY_METADATA_MIGRATION.read_text()) cur.execute(_AFFILIATION_SCOPE_FACET_MIGRATION.read_text()) cur.execute(_EVENT_CLUE_MIGRATION.read_text()) @@ -3959,6 +3965,104 @@ def answer(self, question: str, sources) -> object: assert "raw-global-provider-secret" not in response.text +def test_global_ask_rejects_raced_citation_without_poisoning_session( + client, demo_analyst_token, seeded_db, monkeypatch +) -> None: + """A citation that becomes W is rolled back, while the session remains usable.""" + from lineageweave.post_chat import ChatAnswer + + class _RaceChatClient: + available = True + calls = 0 + raced_post_id: str | None = None + + def answer(self, question: str, sources) -> ChatAnswer: + self.calls += 1 + if self.calls == 2: + cited_post_id = sources[0].post_id + self.raced_post_id = cited_post_id + race_conn = psycopg2.connect(seeded_db["dsn"]) + race_conn.autocommit = True + try: + with race_conn.cursor() as cur: + cur.execute( + "update source_post set source_detail_state_code = 'W' where post_id = %s", + (cited_post_id,), + ) + finally: + race_conn.close() + return ChatAnswer("answer must be rolled back", (cited_post_id,)) + return ChatAnswer(f"safe answer {self.calls}") + + fake_client = _RaceChatClient() + monkeypatch.setattr("backend.app.main._post_chat_client", lambda: fake_client) + monkeypatch.setattr( + "backend.app.main.cited_post_evidence", + lambda sources, cited_ids: [ + {"post_id": cited_ids[0], "facts": [{"kind": "source_field", "text": "synthetic evidence"}]} + ] + if cited_ids + else [], + ) + headers = {"Authorization": f"Bearer {demo_analyst_token}"} + + first = client.post("/api/ask", json={"question": "Public post"}, headers=headers) + assert first.status_code == 200, first.text + conversation_id = first.json()["conversation_id"] + + raced = client.post( + "/api/ask", + json={"question": "Public post", "conversation_id": conversation_id}, + headers=headers, + ) + assert raced.status_code == 503 + assert "answer must be rolled back" not in raced.text + assert "retry" in raced.text + + with psycopg2.connect(seeded_db["dsn"]) as check_conn, check_conn.cursor() as cur: + cur.execute( + "select count(*) from global_ask_turn where global_ask_session_id = %s", + (conversation_id,), + ) + assert cur.fetchone()[0] == 1 + cur.execute( + "select count(*) from global_ask_turn_citation where global_ask_session_id = %s", + (conversation_id,), + ) + assert cur.fetchone()[0] == 0 + cur.execute( + "select count(*) from global_ask_turn_evidence where global_ask_session_id = %s", + (conversation_id,), + ) + assert cur.fetchone()[0] == 0 + + restore_conn = psycopg2.connect(seeded_db["dsn"]) + restore_conn.autocommit = True + try: + with restore_conn.cursor() as cur: + cur.execute( + "update source_post set source_detail_state_code = null where post_id = %s", + (fake_client.raced_post_id,), + ) + finally: + restore_conn.close() + + follow_up = client.post( + "/api/ask", + json={"question": "Public post", "conversation_id": conversation_id}, + headers=headers, + ) + assert follow_up.status_code == 200, follow_up.text + assert follow_up.json()["conversation_id"] == conversation_id + + with psycopg2.connect(seeded_db["dsn"]) as check_conn, check_conn.cursor() as cur: + cur.execute( + "select count(*) from global_ask_turn where global_ask_session_id = %s", + (conversation_id,), + ) + assert cur.fetchone()[0] == 2 + + def test_keymen_provider_error_does_not_leak_raw_error( client, demo_analyst_token, seeded_db, monkeypatch ) -> None: From 19c68217ae0c57d4496009b6447fda79007e68ed Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 06:05:23 +0900 Subject: [PATCH 5/6] fix: close tenant settings review gaps --- backend/tests/test_api.py | 9 +++++---- frontend/src/components/AdminPanel.test.tsx | 3 +++ frontend/src/components/AdminPanel.tsx | 2 +- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 5beb6733c..213b0300c 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -14,6 +14,7 @@ import os import uuid +from contextlib import closing from pathlib import Path import jwt @@ -1321,8 +1322,8 @@ def test_settings_patch_requires_post_admin(client, demo_analyst_token, seeded_d "copyrightHolder": "LineageWeave Demo", } - with psycopg2.connect(seeded_db["dsn"]) as connection: - with connection.cursor() as cursor: + with closing(psycopg2.connect(seeded_db["dsn"])) as connection: + with connection, connection.cursor() as cursor: cursor.execute( "update tenant_settings set updated_at = '2000-01-01T00:00:00Z' where tenant_settings_id = 1" ) @@ -1340,8 +1341,8 @@ def test_settings_patch_requires_post_admin(client, demo_analyst_token, seeded_d "copyrightHolder": "LineageWeave Demo", } - with psycopg2.connect(seeded_db["dsn"]) as connection: - with connection.cursor() as cursor: + with closing(psycopg2.connect(seeded_db["dsn"])) as connection: + with connection, connection.cursor() as cursor: cursor.execute("select updated_at from tenant_settings where tenant_settings_id = 1") assert cursor.fetchone()[0].year > 2000 diff --git a/frontend/src/components/AdminPanel.test.tsx b/frontend/src/components/AdminPanel.test.tsx index bf9a44f76..dbcbd394f 100644 --- a/frontend/src/components/AdminPanel.test.tsx +++ b/frontend/src/components/AdminPanel.test.tsx @@ -134,6 +134,9 @@ describe("AdminPanel", () => { fireEvent.change(screen.getByRole("textbox", { name: "Tenant brand name" }), { target: { value: " LineageWeave " }, }); + fireEvent.change(screen.getByRole("textbox", { name: "Tenant system name" }), { + target: { value: " LineageWeave Intelligence " }, + }); expect(screen.getByRole("button", { name: "Save settings" })).toBeDisabled(); }); diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index e2b776c10..6e741e313 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -367,7 +367,7 @@ export function AdminPanel({ currentTenantConfig, onTenantConfigChange, accessTo || draftConfig.copyrightYear > COPYRIGHT_YEAR_MAX || ( draftConfig.brandName.trim() === currentTenantConfig.brandName - && draftConfig.systemName === currentTenantConfig.systemName + && draftConfig.systemName.trim() === currentTenantConfig.systemName && draftConfig.copyrightYear === currentTenantConfig.copyrightYear && draftConfig.copyrightHolder.trim() === currentTenantConfig.copyrightHolder ) From d1ea448845715a899572a251994ae68679ef991f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 06:08:07 +0900 Subject: [PATCH 6/6] docs: record Global Ask atomic citation boundary --- docs/adr/0126-global-ask-conversation-history.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/adr/0126-global-ask-conversation-history.md b/docs/adr/0126-global-ask-conversation-history.md index cf0ad2e0c..36d552b3b 100644 --- a/docs/adr/0126-global-ask-conversation-history.md +++ b/docs/adr/0126-global-ask-conversation-history.md @@ -27,6 +27,13 @@ path owns only the requesting account's conversations and re-applies the current post visibility rule before returning source titles, citations, or evidence. +Before the persistence transaction commits, cited `source_post` rows are +locked with `FOR SHARE` and the same analysis-visibility predicate is applied +again. If a citation became unauthorized or disappeared, the transaction +raises a stable retryable failure and rolls back the new session, turn, +citations, and evidence together. This closes the race between evidence +retrieval and transcript persistence without exposing the discarded answer. + This is transcript persistence, not a new long-context prompt contract. The orchestrator continues to receive the current question and its bounded, authorized evidence set. Conversation summarization or cross-turn reasoning @@ -38,5 +45,7 @@ requires a separate ADR and upstream orchestrator contract. * A user cannot read another account's conversation by changing a UUID. * Revoked post visibility removes that post's source/citation projection from history; the stored answer remains account-owned transcript data. +* A visibility change during a turn cannot leave a partially persisted or + unusable conversation; the reader retries after the source boundary settles. * The UI can select an existing conversation or start a new one without changing the existing `/api/ask` evidence contract.