From 4d67a55bf907639e3208c3cf35faee2755f3e6fa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 18:08:52 -0700 Subject: [PATCH 01/27] test(red): require authorization-safe Ask project histories --- tests/test_ask_project_history.py | 286 ++++++++++++++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 tests/test_ask_project_history.py diff --git a/tests/test_ask_project_history.py b/tests/test_ask_project_history.py new file mode 100644 index 000000000..782e3dd62 --- /dev/null +++ b/tests/test_ask_project_history.py @@ -0,0 +1,286 @@ +"""Contracts for project histories attached to post-scoped and Global Ask.""" + +from __future__ import annotations + +import asyncio +from datetime import datetime, timezone +from types import SimpleNamespace + +import pytest +from fastapi import HTTPException + +from backend.app import main +from backend.app.ask_project_history import ( + AskEvidenceProjection, + global_ask_session_citations_authorized, + read_authorized_ask_evidence, +) +from backend.app.auth import CurrentAccount +from backend.app.post_chat_ingestion import gather_global_chat_sources + + +CUTOFF = datetime(2026, 8, 20, 12, 0, tzinfo=timezone.utc) + + +class _EvidenceConnection: + """Query-shaped double for current citation and project evidence.""" + + def __init__(self, rows: list[dict[str, object]]) -> None: + self.rows = rows + self.calls: list[tuple[str, tuple[object, ...]]] = [] + + async def fetch(self, query: str, *args: object): + self.calls.append((query, args)) + return self.rows + + +def test_authorized_ask_evidence_groups_exact_projects_and_preserves_citation_order() -> None: + conn = _EvidenceConnection( + [ + { + "post_id": "00000000-0000-4000-8000-000000000002", + "post_title": "Second evidence", + "citation_ordinal": 2, + "project_key": "P-100", + "project_name": "Synthetic renewal", + "truth_status_code": "inferred", + "truth_order": 1, + }, + { + "post_id": "00000000-0000-4000-8000-000000000001", + "post_title": "First evidence", + "citation_ordinal": 1, + "project_key": "P-100", + "project_name": "Synthetic renewal", + "truth_status_code": "observed", + "truth_order": 0, + }, + ] + ) + + result = asyncio.run( + read_authorized_ask_evidence( + conn, + cited_post_ids=[ + "00000000-0000-4000-8000-000000000001", + "00000000-0000-4000-8000-000000000002", + ], + corporate_entity_ids=["tenant-a"], + knowledge_cutoff=CUTOFF, + ) + ) + + assert result.all_citations_visible + assert [post["post_title"] for post in result.cited_posts] == [ + "First evidence", + "Second evidence", + ] + assert result.project_histories == ( + { + "project_key": "P-100", + "project_name": "Synthetic renewal", + "focus_post_id": "00000000-0000-4000-8000-000000000001", + "source_post_ids": [ + "00000000-0000-4000-8000-000000000001", + "00000000-0000-4000-8000-000000000002", + ], + "knowledge_cutoff": "2026-08-20T12:00:00Z", + "truth_status_code": "observed", + }, + ) + query, args = conn.calls[0] + assert "source_draft_code" in query + assert "source_deleted_flag" in query + assert "created_at <= $3" in query + assert args[2] == CUTOFF + + +def test_authorized_ask_evidence_fails_closed_when_any_citation_is_hidden() -> None: + conn = _EvidenceConnection( + [ + { + "post_id": "00000000-0000-4000-8000-000000000001", + "post_title": "Visible evidence", + "citation_ordinal": 1, + "project_key": None, + "project_name": None, + "truth_status_code": None, + "truth_order": None, + } + ] + ) + + result = asyncio.run( + read_authorized_ask_evidence( + conn, + cited_post_ids=[ + "00000000-0000-4000-8000-000000000001", + "00000000-0000-4000-8000-000000000099", + ], + corporate_entity_ids=["tenant-a"], + knowledge_cutoff=CUTOFF, + ) + ) + + assert not result.all_citations_visible + assert result.project_histories == () + + +def test_global_ask_session_reauthorizes_every_persisted_citation() -> None: + class SessionConnection: + def __init__(self) -> None: + self.call = 0 + + async def fetch(self, query: str, *args: object): + del args + self.call += 1 + if "global_ask_turn_citation" in query: + return [ + {"cited_post_id": "00000000-0000-4000-8000-000000000001"}, + {"cited_post_id": "00000000-0000-4000-8000-000000000099"}, + ] + return [ + { + "post_id": "00000000-0000-4000-8000-000000000001", + "post_title": "Visible evidence", + "citation_ordinal": 1, + "project_key": None, + "project_name": None, + "truth_status_code": None, + "truth_order": None, + } + ] + + authorized = asyncio.run( + global_ask_session_citations_authorized( + SessionConnection(), + session_id="00000000-0000-4000-8000-000000000010", + corporate_entity_ids=["tenant-a"], + knowledge_cutoff=CUTOFF, + ) + ) + assert not authorized + + +def test_global_source_retrieval_applies_cutoff_and_publication_eligibility() -> None: + calls: list[tuple[str, tuple[object, ...]]] = [] + + class CaptureConnection: + async def fetch(self, query: str, *args: object): + calls.append((query, args)) + return [] + + asyncio.run( + gather_global_chat_sources( + CaptureConnection(), + lambda _row: True, + ["tenant-a"], + question="synthetic project", + limit=2, + knowledge_cutoff=CUTOFF, + ) + ) + + candidate_queries = [query for query, _args in calls if "matched_in" in query] + source_queries = [query for query, _args in calls if "array_position($2::uuid[], post_id)" in query] + assert candidate_queries + assert all("source_draft_code" in query and "created_at <= $3" in query for query in candidate_queries) + assert source_queries + assert "source_deleted_flag" in source_queries[0] + assert "created_at <= $4" in source_queries[0] + + +class _Acquire: + def __init__(self, connection: object) -> None: + self.connection = connection + + async def __aenter__(self) -> object: + return self.connection + + async def __aexit__(self, exc_type, exc_value, traceback) -> None: + return None + + +class _Pool: + def __init__(self, connection: object) -> None: + self.connection = connection + + def acquire(self) -> _Acquire: + return _Acquire(self.connection) + + +def _account() -> CurrentAccount: + return CurrentAccount( + user_account_id="account-1", + external_subject_id="subject-1", + display_name="Synthetic analyst", + preferred_locale="en", + corporate_entity_ids=frozenset({"tenant-a"}), + permission_codes=frozenset({"post_read"}), + ) + + +def test_stored_post_chat_omits_an_answer_after_citation_access_is_lost(monkeypatch) -> None: + async def visible_post(*_args, **_kwargs): + return {"post_id": "post-1"} + + async def stored_chats(*_args, **_kwargs): + return [ + { + "question_text": "What happened?", + "answer_text": "A formerly authorized answer.", + "cited_post_ids": ["hidden-post"], + "cited_posts": [{"post_id": "hidden-post", "post_title": "Hidden"}], + "_knowledge_cutoff": CUTOFF, + } + ] + + async def hidden_evidence(*_args, **_kwargs): + return AskEvidenceProjection( + all_citations_visible=False, + cited_posts=(), + project_histories=(), + project_histories_truncated=False, + knowledge_cutoff="2026-08-20T12:00:00Z", + ) + + monkeypatch.setattr(main, "_load_visible_post", visible_post) + monkeypatch.setattr(main, "fetch_persisted_chats", stored_chats) + monkeypatch.setattr(main, "read_authorized_ask_evidence", hidden_evidence) + + result = asyncio.run( + main.read_post_chat( + post_id="post-1", + account=_account(), + pool=_Pool(object()), + ) + ) + assert result == {"post_id": "post-1", "exchanges": []} + + +def test_global_ask_rejects_stale_session_context_before_reusing_hidden_prose(monkeypatch) -> None: + async def ensure_session(*_args, **_kwargs): + return "00000000-0000-4000-8000-000000000010" + + async def unauthorized(*_args, **_kwargs): + return False + + monkeypatch.setattr(main, "_post_chat_client", lambda: SimpleNamespace(available=True)) + monkeypatch.setattr(main, "ensure_global_ask_session", ensure_session) + monkeypatch.setattr(main, "global_ask_session_citations_authorized", unauthorized) + + with pytest.raises(HTTPException) as exc_info: + asyncio.run( + main.ask_agent( + request=main.GlobalAskRequest( + question="Continue the prior answer", + session_id="00000000-0000-4000-8000-000000000010", + ), + account=_account(), + pool=_Pool(object()), + valkey=SimpleNamespace(), + ) + ) + + assert exc_info.value.status_code == 409 + assert "start a new session" in str(exc_info.value.detail).lower() From 2465173bc132d931e7772726af47fbff9ffff91d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 18:09:19 -0700 Subject: [PATCH 02/27] test(red): require canonical project histories in Ask answers --- .../AskProjectHistoryLinks.test.tsx | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 frontend/src/components/AskProjectHistoryLinks.test.tsx diff --git a/frontend/src/components/AskProjectHistoryLinks.test.tsx b/frontend/src/components/AskProjectHistoryLinks.test.tsx new file mode 100644 index 000000000..16da5ce7c --- /dev/null +++ b/frontend/src/components/AskProjectHistoryLinks.test.tsx @@ -0,0 +1,123 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { fetchProjectHistory } from "../api"; +import type { ProjectHistoryProjection } from "../projectHistory"; +import { AskProjectHistoryLinks } from "./AskProjectHistoryLinks"; + +vi.mock("../api", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchProjectHistory: vi.fn(), + }; +}); + +const projection: ProjectHistoryProjection = { + contract_version: 1, + project_key: "P-100", + normalized_project_key: "p-100", + project_name: "Synthetic renewal", + focus_event_id: "post-voc", + time_basis_code: "source_post_created_at_fallback", + knowledge_cutoff: "2026-08-20T12:00:00Z", + evidence_boundary_code: "authorized_visible_source_posts", + event_count: 1, + distinct_actor_count: 0, + distinct_observed_actor_count: 0, + truncated: false, + events: [ + { + event_id: "post-voc", + source_post_id: "post-voc", + event_title: "Synthetic VOC received", + event_type_code: "voc_received", + event_type_basis_code: "display_classification", + occurred_at: "2026-02-02T09:00:00Z", + time_basis_code: "source_post_created_at_fallback", + voc_type_code: "voc", + source_stage_code: null, + source_detail_state_code: null, + project_matches: [], + responsibility_evidence: [], + observed_responsibilities: [], + responsibility_transition_code: null, + responsibility_transition_truth_status_code: null, + related_prior_paths: [], + }, + ], +}; + +const link = { + project_key: "P-100", + project_name: "Synthetic renewal", + focus_post_id: "post-voc", + source_post_ids: ["post-voc"], + knowledge_cutoff: "2026-08-20T12:00:00Z", + truth_status_code: "observed" as const, +}; + +describe("AskProjectHistoryLinks", () => { + beforeEach(() => { + vi.mocked(fetchProjectHistory).mockReset(); + }); + + it("loads the canonical timeline at the answer cutoff and preserves source navigation", async () => { + const onOpenPost = vi.fn(); + vi.mocked(fetchProjectHistory).mockResolvedValue(projection); + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /open project history: Synthetic renewal/i })); + + await waitFor(() => { + expect(fetchProjectHistory).toHaveBeenCalledWith( + "token", + "P-100", + "2026-08-20T12:00:00Z", + "post-voc", + ); + }); + expect(screen.getByRole("heading", { name: /project event timeline/i })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /open source record: Synthetic VOC received/i })); + expect(onOpenPost).toHaveBeenCalledWith("post-voc"); + }); + + it("reports truncation and leaves the answer readable when the timeline fetch fails", async () => { + vi.mocked(fetchProjectHistory).mockRejectedValue(new Error("synthetic failure")); + + render( + , + ); + + expect(screen.getByRole("status")).toHaveTextContent(/additional cited projects are not shown/i); + fireEvent.click(screen.getByRole("button", { name: /open project history: Synthetic renewal/i })); + expect(await screen.findByRole("alert")).toHaveTextContent(/project history could not be loaded/i); + expect(screen.getByText("Synthetic renewal")).toBeInTheDocument(); + }); + + it("renders nothing when the answer cites no project identity", () => { + const { container } = render( + , + ); + expect(container).toBeEmptyDOMElement(); + }); +}); From 8f9e8639bc593adb6b45456265c3aa543fe22c2a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 18:15:14 -0700 Subject: [PATCH 03/27] chore: stage project-history Ask integration transform --- tools/apply_project_history_ask_surfaces.py | 1447 +++++++++++++++++++ 1 file changed, 1447 insertions(+) create mode 100644 tools/apply_project_history_ask_surfaces.py diff --git a/tools/apply_project_history_ask_surfaces.py b/tools/apply_project_history_ask_surfaces.py new file mode 100644 index 000000000..714d6567e --- /dev/null +++ b/tools/apply_project_history_ask_surfaces.py @@ -0,0 +1,1447 @@ +"""Apply authorization-safe project-history links to both Ask surfaces.""" + +from __future__ import annotations + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def replace_once(path: str, old: str, new: str) -> None: + """Replace one exact source fragment and fail when branch context drifted.""" + + file_path = ROOT / path + text = file_path.read_text(encoding="utf-8") + count = text.count(old) + if count != 1: + raise RuntimeError(f"expected one anchor in {path}, found {count}") + file_path.write_text(text.replace(old, new, 1), encoding="utf-8") + + +def write_new(path: str, content: str) -> None: + """Create one product file and reject accidental overwrite.""" + + file_path = ROOT / path + if file_path.exists(): + raise RuntimeError(f"refusing to overwrite existing {path}") + file_path.parent.mkdir(parents=True, exist_ok=True) + file_path.write_text(content.strip() + "\n", encoding="utf-8") + + +def append_once(path: str, marker: str, content: str) -> None: + """Append a documented section only when it is not already present.""" + + file_path = ROOT / path + text = file_path.read_text(encoding="utf-8") + if marker in text: + return + file_path.write_text(text.rstrip() + "\n\n" + content.strip() + "\n", encoding="utf-8") + + +def create_backend_projection() -> None: + """Create the authorization-first citation-to-project projection.""" + + write_new( + "backend/app/ask_project_history.py", + r''' +"""Authorization-safe project-history links for Ask responses. + +The module accepts only citation identities already produced by post-scoped or +Global Ask. It re-applies current tenant visibility, source publication +eligibility, and the answer knowledge cutoff before returning citation labels or +project identities. A missing citation fails the whole persisted answer closed; +answer prose cannot be safely decomposed after one of its sources becomes +unauthorized. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping, Sequence +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Protocol + +from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL +from lineageweave.project_history import normalize_project_key + +ASK_CITATION_LIMIT = 64 +ASK_PROJECT_LIMIT = 8 +GLOBAL_ASK_SESSION_CITATION_LIMIT = 256 + +_ELIGIBILITY = SOURCE_POST_ELIGIBILITY_SQL.format(alias="post") +_CITATION_PROJECT_SQL = f""" +with visible_citation as materialized ( + select post.post_id::text as post_id, + post.post_title, + array_position($1::uuid[], post.post_id) as citation_ordinal, + nullif(btrim(post.source_project_code), '') as source_project_code, + nullif(btrim(post.source_project_name), '') as source_project_name + from source_post post + where post.post_id = any($1::uuid[]) + and (post.visibility_code = 'public' + or post.corporate_entity_id::text = any($2::text[])) + and post.created_at <= $3 + and {_ELIGIBILITY} +), project_evidence as ( + select visible_citation.post_id, + coalesce(visible_citation.source_project_code, + visible_citation.source_project_name) as project_key, + coalesce(visible_citation.source_project_name, + visible_citation.source_project_code) as project_name, + 'observed'::text as truth_status_code, + 0::integer as truth_order + from visible_citation + where coalesce(visible_citation.source_project_code, + visible_citation.source_project_name) is not null + union all + select visible_citation.post_id, + coalesce(nullif(btrim(mention.project_key), ''), + nullif(btrim(mention.project_name), '')) as project_key, + coalesce(nullif(btrim(mention.project_name), ''), + nullif(btrim(mention.project_key), '')) as project_name, + 'inferred'::text as truth_status_code, + 1::integer as truth_order + from visible_citation + join post_project_mention mention + on mention.post_id::text = visible_citation.post_id + where coalesce(nullif(btrim(mention.project_key), ''), + nullif(btrim(mention.project_name), '')) is not null +) +select visible_citation.post_id, + visible_citation.post_title, + visible_citation.citation_ordinal, + project_evidence.project_key, + project_evidence.project_name, + project_evidence.truth_status_code, + project_evidence.truth_order + from visible_citation + left join project_evidence + on project_evidence.post_id = visible_citation.post_id + order by visible_citation.citation_ordinal, + project_evidence.truth_order nulls last, + project_evidence.project_name nulls last, + project_evidence.project_key nulls last +""" +_SESSION_CITATION_SQL = """ +select distinct cited_post_id::text as cited_post_id + from global_ask_turn_citation + where global_ask_session_id = $1 + order by cited_post_id::text + limit $2 +""" + + +class AskEvidenceConnection(Protocol): + """Minimal async query port used by this read projection.""" + + async def fetch(self, query: str, *args: object) -> Sequence[Mapping[str, Any]]: + """Execute a bounded read query.""" + + raise NotImplementedError + + +@dataclass(frozen=True) +class AskEvidenceProjection: + """Currently authorized citation labels and exact project links.""" + + all_citations_visible: bool + cited_posts: tuple[dict[str, str], ...] + project_histories: tuple[dict[str, Any], ...] + project_histories_truncated: bool + knowledge_cutoff: str + + def response_fields(self) -> dict[str, Any]: + """Return the public response fields shared by both Ask surfaces.""" + + return { + "cited_posts": list(self.cited_posts), + "project_histories": list(self.project_histories), + "project_histories_truncated": self.project_histories_truncated, + "knowledge_cutoff": self.knowledge_cutoff, + } + + +def ask_knowledge_cutoff(value: object | None = None) -> datetime: + """Return an offset-aware UTC cutoff from a datetime or ISO text.""" + + if value is None: + return datetime.now(timezone.utc) + if isinstance(value, datetime): + parsed = value + elif isinstance(value, str) and value.strip(): + try: + parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00")) + except ValueError as exc: + raise ValueError("knowledge cutoff must be ISO-8601") from exc + else: + raise ValueError("knowledge cutoff must be a datetime or ISO-8601 text") + if parsed.tzinfo is None or parsed.utcoffset() is None: + raise ValueError("knowledge cutoff must include an offset") + return parsed.astimezone(timezone.utc) + + +def _cutoff_text(value: datetime) -> str: + """Serialize one validated cutoff as canonical UTC RFC 3339 text.""" + + return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _bounded_citations( + cited_post_ids: Iterable[str], *, maximum_citations: int +) -> tuple[str, ...]: + """Return unique citation IDs without silently truncating evidence.""" + + citations = tuple(dict.fromkeys(str(value) for value in cited_post_ids if str(value))) + if len(citations) > maximum_citations: + raise ValueError("citation count exceeds the supported bound") + return citations + + +async def read_authorized_ask_evidence( + conn: AskEvidenceConnection, + *, + cited_post_ids: Iterable[str], + corporate_entity_ids: Iterable[str], + knowledge_cutoff: datetime | str, + maximum_citations: int = ASK_CITATION_LIMIT, + maximum_projects: int = ASK_PROJECT_LIMIT, +) -> AskEvidenceProjection: + """Reauthorize citations and derive bounded exact-project history links. + + A citation is visible only when its current source row passes tenant ABAC, + publication eligibility, and the answer cutoff. If any citation is absent, + project links are withheld and callers must not reuse the persisted answer. + """ + + cutoff = ask_knowledge_cutoff(knowledge_cutoff) + cutoff_text = _cutoff_text(cutoff) + citations = _bounded_citations( + cited_post_ids, + maximum_citations=maximum_citations, + ) + if not citations: + return AskEvidenceProjection(True, (), (), False, cutoff_text) + rows = list( + await conn.fetch( + _CITATION_PROJECT_SQL, + list(citations), + list(corporate_entity_ids), + cutoff, + ) + ) + citation_order = {post_id: index for index, post_id in enumerate(citations, start=1)} + visible_titles: dict[str, str] = {} + for row in rows: + post_id = str(row["post_id"]) + if post_id in citation_order: + visible_titles.setdefault(post_id, str(row["post_title"])) + all_visible = set(visible_titles) == set(citations) + cited_posts = tuple( + {"post_id": post_id, "post_title": visible_titles[post_id]} + for post_id in citations + if post_id in visible_titles + ) + if not all_visible: + return AskEvidenceProjection(False, cited_posts, (), False, cutoff_text) + + evidence_rows = sorted( + ( + row + for row in rows + if row.get("project_key") is not None and row.get("project_name") is not None + ), + key=lambda row: ( + citation_order[str(row["post_id"])], + int(row.get("truth_order") or 0), + str(row["project_name"]), + str(row["project_key"]), + ), + ) + grouped: dict[str, dict[str, Any]] = {} + for row in evidence_rows: + project_key = str(row["project_key"]).strip() + project_name = str(row["project_name"]).strip() + try: + normalized_key = normalize_project_key(project_key) + except ValueError: + continue + post_id = str(row["post_id"]) + truth_order = int(row.get("truth_order") or 0) + group = grouped.get(normalized_key) + if group is None: + grouped[normalized_key] = { + "project_key": project_key, + "project_name": project_name, + "focus_post_id": post_id, + "source_post_ids": [post_id], + "knowledge_cutoff": cutoff_text, + "truth_status_code": str(row["truth_status_code"]), + "truth_order": truth_order, + "first_citation_ordinal": citation_order[post_id], + } + continue + if post_id not in group["source_post_ids"]: + group["source_post_ids"].append(post_id) + if truth_order < group["truth_order"]: + group["project_key"] = project_key + group["project_name"] = project_name + group["truth_status_code"] = str(row["truth_status_code"]) + group["truth_order"] = truth_order + + ordered = sorted( + grouped.values(), + key=lambda group: ( + int(group["first_citation_ordinal"]), + str(group["project_name"]), + str(group["project_key"]), + ), + ) + truncated = len(ordered) > maximum_projects + public_links: list[dict[str, Any]] = [] + for group in ordered[:maximum_projects]: + public_links.append( + { + key: value + for key, value in group.items() + if key not in {"truth_order", "first_citation_ordinal"} + } + ) + return AskEvidenceProjection( + True, + cited_posts, + tuple(public_links), + truncated, + cutoff_text, + ) + + +async def global_ask_session_citations_authorized( + conn: AskEvidenceConnection, + *, + session_id: str, + corporate_entity_ids: Iterable[str], + knowledge_cutoff: datetime | str, +) -> bool: + """Return whether every citation ever reused by a session is still visible.""" + + rows = list( + await conn.fetch( + _SESSION_CITATION_SQL, + session_id, + GLOBAL_ASK_SESSION_CITATION_LIMIT + 1, + ) + ) + if len(rows) > GLOBAL_ASK_SESSION_CITATION_LIMIT: + return False + citations = [str(row["cited_post_id"]) for row in rows] + result = await read_authorized_ask_evidence( + conn, + cited_post_ids=citations, + corporate_entity_ids=corporate_entity_ids, + knowledge_cutoff=knowledge_cutoff, + maximum_citations=GLOBAL_ASK_SESSION_CITATION_LIMIT, + maximum_projects=0, + ) + return result.all_citations_visible +''', + ) + + +def patch_post_chat_ingestion() -> None: + """Apply cutoff and publication eligibility to both Ask retrieval paths.""" + + replace_once( + "backend/app/post_chat_ingestion.py", + "from dataclasses import dataclass\nfrom typing import Any, Callable, Iterable\n", + "from dataclasses import dataclass\nfrom datetime import datetime, timezone\n" + "from typing import Any, Callable, Iterable\n", + ) + replace_once( + "backend/app/post_chat_ingestion.py", + "from .knowledge_graph import hydrate_related_nodes, load_visible_subgraph\n", + "from .knowledge_graph import hydrate_related_nodes, load_visible_subgraph\n" + "from .post_eligibility import SOURCE_POST_ELIGIBILITY_SQL\n", + ) + replace_once( + "backend/app/post_chat_ingestion.py", + "_POST_CHAT_SOURCE_LIMIT = 8\n_POST_CHAT_CANDIDATE_LIMIT = 32\n", + "_POST_CHAT_SOURCE_LIMIT = 8\n_POST_CHAT_CANDIDATE_LIMIT = 32\n" + "_SOURCE_ELIGIBILITY = SOURCE_POST_ELIGIBILITY_SQL.format(alias=\"source_post\")\n\n\n" + "def _ask_cutoff(value: datetime | None) -> datetime:\n" + " \"\"\"Return an aware UTC cutoff for one Ask retrieval.\"\"\"\n\n" + " cutoff = value or datetime.now(timezone.utc)\n" + " if cutoff.tzinfo is None or cutoff.utcoffset() is None:\n" + " raise ValueError(\"knowledge_cutoff must include an offset\")\n" + " return cutoff.astimezone(timezone.utc)\n", + ) + replace_once( + "backend/app/post_chat_ingestion.py", + """async def gather_chat_sources( + conn: asyncpg.Connection, + post_id: str, + can_see_post: Callable[[asyncpg.Record], bool], + vision_client: ImageContentClient | None = None, +) -> list[ChatSourceDocument]: +""", + """async def gather_chat_sources( + conn: asyncpg.Connection, + post_id: str, + can_see_post: Callable[[asyncpg.Record], bool], + vision_client: ImageContentClient | None = None, + *, + knowledge_cutoff: datetime | None = None, +) -> list[ChatSourceDocument]: +""", + ) + replace_once( + "backend/app/post_chat_ingestion.py", + """ if vision_client is None: + vision_client = NullImageContentClient() + + this_post = await conn.fetchrow( + "select post_id, post_title, post_body, source_system_code, source_record_key, " +""", + """ if vision_client is None: + vision_client = NullImageContentClient() + cutoff = _ask_cutoff(knowledge_cutoff) + + this_post = await conn.fetchrow( + "select post_id, post_title, post_body, created_at, source_system_code, source_record_key, " +""", + ) + replace_once( + "backend/app/post_chat_ingestion.py", + ' "source_project_name from source_post where post_id = $1",\n post_id,\n', + ' f"source_project_name from source_post where post_id = $1 "\n' + ' f"and created_at <= $2 and {_SOURCE_ELIGIBILITY}",\n' + ' post_id,\n cutoff,\n', + ) + replace_once( + "backend/app/post_chat_ingestion.py", + ' "source_project_code, source_project_name "\n' + ' "from source_post where post_id = any($1::uuid[]) "\n' + ' "order by array_position($1::uuid[], post_id) limit $2",\n' + ' candidate_ids,\n _POST_CHAT_CANDIDATE_LIMIT,\n', + ' "source_project_code, source_project_name, created_at "\n' + ' f"from source_post where post_id = any($1::uuid[]) "\n' + ' f"and created_at <= $3 and {_SOURCE_ELIGIBILITY} "\n' + ' "order by array_position($1::uuid[], post_id) limit $2",\n' + ' candidate_ids,\n _POST_CHAT_CANDIDATE_LIMIT,\n cutoff,\n', + ) + replace_once( + "backend/app/post_chat_ingestion.py", + """ question: str | None = None, + limit: int = 4, +) -> list[ChatSourceDocument]: +""", + """ question: str | None = None, + limit: int = 4, + knowledge_cutoff: datetime | None = None, +) -> list[ChatSourceDocument]: +""", + ) + replace_once( + "backend/app/post_chat_ingestion.py", + """ if vision_client is None: + vision_client = NullImageContentClient() + search_terms = tuple( +""", + """ if vision_client is None: + vision_client = NullImageContentClient() + cutoff = _ask_cutoff(knowledge_cutoff) + authorized_entity_ids = list(authorized_corporate_entity_ids) + search_terms = tuple( +""", + ) + eligibility = "{_SOURCE_ELIGIBILITY}" + replace_once( + "backend/app/post_chat_ingestion.py", + """ (select post_id, created_at, 'title' as matched_in + from source_post + where post_title ilike '%' || $1 || '%' + limit 32) +""", + f""" (select post_id, created_at, 'title' as matched_in + from source_post + where (visibility_code = 'public' + or corporate_entity_id::text = any($2::text[])) + and created_at <= $3 + and {eligibility} + and post_title ilike '%' || $1 || '%' + limit 32) +""", + ) + replace_once( + "backend/app/post_chat_ingestion.py", + """ (select post_id, created_at, 'body' as matched_in + from source_post + where lower(left(source_post_search_text(post_body), 16384)) + like '%' || lower($1) || '%' + limit 32) +""", + f""" (select post_id, created_at, 'body' as matched_in + from source_post + where (visibility_code = 'public' + or corporate_entity_id::text = any($2::text[])) + and created_at <= $3 + and {eligibility} + and lower(left(source_post_search_text(post_body), 16384)) + like '%' || lower($1) || '%' + limit 32) +""", + ) + replace_once( + "backend/app/post_chat_ingestion.py", + """ (select post_id, created_at, 'body' as matched_in + from source_post + where to_tsvector('simple', source_post_search_text(post_body)) + @@ plainto_tsquery('simple', $1) + limit 32) +""", + f""" (select post_id, created_at, 'body' as matched_in + from source_post + where (visibility_code = 'public' + or corporate_entity_id::text = any($2::text[])) + and created_at <= $3 + and {eligibility} + and to_tsvector('simple', source_post_search_text(post_body)) + @@ plainto_tsquery('simple', $1) + limit 32) +""", + ) + replace_once( + "backend/app/post_chat_ingestion.py", + """ (select post_id, created_at, 'source_field' as matched_in + from source_post + where concat_ws(' ', source_system_code, source_record_key, +""", + f""" (select post_id, created_at, 'source_field' as matched_in + from source_post + where (visibility_code = 'public' + or corporate_entity_id::text = any($2::text[])) + and created_at <= $3 + and {eligibility} + and concat_ws(' ', source_system_code, source_record_key, +""", + ) + replace_once( + "backend/app/post_chat_ingestion.py", + """ term, + ) +""", + """ term, + authorized_entity_ids, + cutoff, + ) +""", + ) + replace_once( + "backend/app/post_chat_ingestion.py", + """ where visibility_code = 'public' + or corporate_entity_id::text = any($1::text[]) + order by array_position($2::uuid[], post_id) nulls last, + created_at desc, post_id desc + limit $3 + """, + list(authorized_corporate_entity_ids), + candidate_ids, + limit, +""", + f""" where (visibility_code = 'public' + or corporate_entity_id::text = any($1::text[])) + and created_at <= $4 + and {eligibility} + order by array_position($2::uuid[], post_id) nulls last, + created_at desc, post_id desc + limit $3 + """, + authorized_entity_ids, + candidate_ids, + limit, + cutoff, +""", + ) + replace_once( + "backend/app/post_chat_ingestion.py", + ' "select question_text, answer_text from post_chat_result "\n', + ' "select question_text, answer_text, computed_at from post_chat_result "\n', + ) + replace_once( + "backend/app/post_chat_ingestion.py", + ' "cited_posts": [\n', + ' "_knowledge_cutoff": header.get("computed_at"),\n "cited_posts": [\n', + ) + + +def patch_main_routes() -> None: + """Attach the reauthorized project links to stored and live Ask responses.""" + + replace_once( + "backend/app/main.py", + "from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL\n", + "from backend.app.ask_project_history import (\n" + " ask_knowledge_cutoff,\n" + " global_ask_session_citations_authorized,\n" + " read_authorized_ask_evidence,\n" + ")\n" + "from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL\n", + ) + old_read = ''' await _load_visible_post(post_id, account, pool) + async with pool.acquire() as conn: + exchanges = await fetch_persisted_chats(conn, post_id) + return {"post_id": post_id, "exchanges": exchanges} +''' + new_read = ''' await _load_visible_post(post_id, account, pool) + authorized_exchanges: list[dict[str, Any]] = [] + async with pool.acquire() as conn: + exchanges = await fetch_persisted_chats(conn, post_id) + for exchange in exchanges: + cutoff = ask_knowledge_cutoff(exchange.get("_knowledge_cutoff")) + evidence = await read_authorized_ask_evidence( + conn, + cited_post_ids=exchange["cited_post_ids"], + corporate_entity_ids=account.corporate_entity_ids, + knowledge_cutoff=cutoff, + ) + if not evidence.all_citations_visible: + continue + public_exchange = { + key: value for key, value in exchange.items() if not key.startswith("_") + } + public_exchange.update(evidence.response_fields()) + authorized_exchanges.append(public_exchange) + return {"post_id": post_id, "exchanges": authorized_exchanges} +''' + replace_once("backend/app/main.py", old_read, new_read) + replace_once( + "backend/app/main.py", + """ post = await _load_visible_post(post_id, account, pool) + post_metadata = build_post_llm_metadata(post_id, post) + async with pool.acquire() as conn: + stored = await fetch_persisted_chat(conn, post_id, question) + if stored is not None: + source_ids = [post_id] + source_ids.extend(cid for cid in stored["cited_post_ids"] if cid != post_id) + return { + "post_id": post_id, + "answer_text": stored["answer_text"], + "cited_post_ids": stored["cited_post_ids"], + "cited_posts": stored["cited_posts"], + "source_post_ids": source_ids, + } + with use_llm_metadata(post_metadata): +""", + """ post = await _load_visible_post(post_id, account, pool) + post_metadata = build_post_llm_metadata(post_id, post) + knowledge_cutoff = ask_knowledge_cutoff() + async with pool.acquire() as conn: + stored = await fetch_persisted_chat(conn, post_id, question) + if stored is not None: + stored_cutoff = ask_knowledge_cutoff(stored.get("_knowledge_cutoff")) + stored_evidence = await read_authorized_ask_evidence( + conn, + cited_post_ids=stored["cited_post_ids"], + corporate_entity_ids=account.corporate_entity_ids, + knowledge_cutoff=stored_cutoff, + ) + if stored_evidence.all_citations_visible: + source_ids = list( + dict.fromkeys([post_id, *stored["cited_post_ids"]]) + ) + return { + "post_id": post_id, + "answer_text": stored["answer_text"], + "cited_post_ids": stored["cited_post_ids"], + "source_post_ids": source_ids, + **stored_evidence.response_fields(), + } + with use_llm_metadata(post_metadata): +""", + ) + replace_once( + "backend/app/main.py", + """ sources = await gather_chat_sources( + conn, post_id, lambda row: _can_see_post(account, row), vision_client=_vision_client() + ) +""", + """ sources = await gather_chat_sources( + conn, + post_id, + lambda row: _can_see_post(account, row), + vision_client=_vision_client(), + knowledge_cutoff=knowledge_cutoff, + ) +""", + ) + replace_once( + "backend/app/main.py", + """ async with pool.acquire() as conn: + await persist_post_chat(conn, post_id, question, answer.answer_text, cited_ids) +""", + """ async with pool.acquire() as conn: + await persist_post_chat(conn, post_id, question, answer.answer_text, cited_ids) + answer_evidence = await read_authorized_ask_evidence( + conn, + cited_post_ids=cited_ids, + corporate_entity_ids=account.corporate_entity_ids, + knowledge_cutoff=knowledge_cutoff, + ) + if not answer_evidence.all_citations_visible: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Post chat evidence changed before the answer could be returned", + ) +""", + ) + replace_once( + "backend/app/main.py", + ' "cited_posts": cited_post_summaries(sources, cited_ids),\n' + ' "source_post_ids": [source.post_id for source in sources],\n', + ' "source_post_ids": [source.post_id for source in sources],\n' + ' **answer_evidence.response_fields(),\n', + ) + replace_once( + "backend/app/main.py", + """ client = _post_chat_client() + if not client.available: +""", + """ knowledge_cutoff = ask_knowledge_cutoff() + client = _post_chat_client() + if not client.available: +""", + ) + replace_once( + "backend/app/main.py", + """ conversation = await load_global_ask_context(conn, session_id) + sources = await gather_global_chat_sources( +""", + """ if not await global_ask_session_citations_authorized( + conn, + session_id=session_id, + corporate_entity_ids=account.corporate_entity_ids, + knowledge_cutoff=knowledge_cutoff, + ): + raise HTTPException( + status.HTTP_409_CONFLICT, + "Global Ask session evidence is no longer authorized; start a new session", + ) + conversation = await load_global_ask_context(conn, session_id) + sources = await gather_global_chat_sources( +""", + ) + replace_once( + "backend/app/main.py", + """ question=question, + ) +""", + """ question=question, + knowledge_cutoff=knowledge_cutoff, + ) +""", + ) + replace_once( + "backend/app/main.py", + ' "timeline": [],\n "next_action": "No authorized source posts are available for this question.",\n', + ' "timeline": [],\n "project_histories": [],\n' + ' "project_histories_truncated": False,\n' + ' "knowledge_cutoff": knowledge_cutoff.isoformat().replace("+00:00", "Z"),\n' + ' "next_action": "No authorized source posts are available for this question.",\n', + ) + replace_once( + "backend/app/main.py", + """ async with pool.acquire() as conn: + await persist_global_ask_turn( + conn, + conversation.session_id, + question, + answer.answer_text, + cited_ids, + ) +""", + """ async with pool.acquire() as conn: + await persist_global_ask_turn( + conn, + conversation.session_id, + question, + answer.answer_text, + cited_ids, + ) + answer_evidence = await read_authorized_ask_evidence( + conn, + cited_post_ids=cited_ids, + corporate_entity_ids=account.corporate_entity_ids, + knowledge_cutoff=knowledge_cutoff, + ) + if not answer_evidence.all_citations_visible: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Global Ask evidence changed before the answer could be returned", + ) +""", + ) + replace_once( + "backend/app/main.py", + ' "cited_posts": cited_post_summaries(sources, cited_ids),\n' + ' "cited_post_evidence": cited_post_evidence(sources, cited_ids),\n', + ' "cited_post_evidence": cited_post_evidence(sources, cited_ids),\n', + ) + replace_once( + "backend/app/main.py", + ' "timeline": global_ask_timeline(sources),\n }\n', + ' "timeline": global_ask_timeline(sources),\n' + ' **answer_evidence.response_fields(),\n }\n', + ) + + +def create_frontend_component() -> None: + """Create one lazy canonical-timeline disclosure reused by both Ask surfaces.""" + + write_new( + "frontend/src/components/AskProjectHistoryLinks.tsx", + r''' +import { useEffect, useId, useState } from "react"; + +import { fetchProjectHistory, type ProjectHistoryLink } from "../api"; +import type { Locale } from "../i18n"; +import { useLocale } from "../i18n"; +import { projectHistoryText, type ProjectHistoryProjection } from "../projectHistory"; +import { ProjectHistoryTimeline } from "./ProjectHistoryTimeline"; +import "./AskProjectHistoryLinks.css"; + +interface Copy { + heading: string; + boundary: string; + open: (name: string) => string; + close: (name: string) => string; + loading: string; + truncated: string; + observed: string; + inferred: string; +} + +const COPY: Record = { + en: { + heading: "Project histories cited by this answer", + boundary: "Each timeline is rebuilt from currently authorized evidence at the answer cutoff.", + open: (name) => `Open project history: ${name}`, + close: (name) => `Close project history: ${name}`, + loading: "Loading cited project history...", + truncated: "Additional cited projects are not shown. Open the cited source records to inspect their project evidence.", + observed: "Observed project identity", + inferred: "Inferred project identity", + }, + ko: { + heading: "이 답변이 인용한 프로젝트 이력", + boundary: "각 타임라인은 답변 기준 시각과 현재 권한을 통과한 근거로 다시 구성됩니다.", + open: (name) => `프로젝트 이력 열기: ${name}`, + close: (name) => `프로젝트 이력 닫기: ${name}`, + loading: "인용된 프로젝트 이력을 불러오는 중...", + truncated: "일부 추가 프로젝트는 표시하지 않습니다. 인용된 원천 기록에서 프로젝트 근거를 확인하세요.", + observed: "관찰된 프로젝트 식별자", + inferred: "추론된 프로젝트 식별자", + }, + zh: { + heading: "此回答引用的项目历史", + boundary: "每条时间线都根据回答截止时间和当前授权证据重新构建。", + open: (name) => `打开项目历史:${name}`, + close: (name) => `关闭项目历史:${name}`, + loading: "正在加载引用的项目历史...", + truncated: "还有引用项目未显示。请打开引用的源记录检查其项目依据。", + observed: "已观察的项目身份", + inferred: "已推断的项目身份", + }, + ja: { + heading: "この回答が引用したプロジェクト履歴", + boundary: "各タイムラインは回答時点と現在の権限を通過した根拠から再構成されます。", + open: (name) => `プロジェクト履歴を開く: ${name}`, + close: (name) => `プロジェクト履歴を閉じる: ${name}`, + loading: "引用されたプロジェクト履歴を読み込み中...", + truncated: "追加の引用プロジェクトは表示されていません。引用元レコードでプロジェクト根拠を確認してください。", + observed: "観察されたプロジェクト識別子", + inferred: "推論されたプロジェクト識別子", + }, + vi: { + heading: "Lịch sử dự án được câu trả lời này trích dẫn", + boundary: "Mỗi dòng thời gian được dựng lại từ bằng chứng hiện được cấp quyền tại thời điểm cắt của câu trả lời.", + open: (name) => `Mở lịch sử dự án: ${name}`, + close: (name) => `Đóng lịch sử dự án: ${name}`, + loading: "Đang tải lịch sử dự án được trích dẫn...", + truncated: "Một số dự án được trích dẫn chưa được hiển thị. Hãy mở bản ghi nguồn để kiểm tra bằng chứng dự án.", + observed: "Danh tính dự án được quan sát", + inferred: "Danh tính dự án được suy luận", + }, +}; + +function ProjectHistoryDisclosure({ + accessToken, + link, + onOpenPost, +}: { + accessToken: string; + link: ProjectHistoryLink; + onOpenPost: (postId: string) => void; +}) { + const locale = useLocale(); + const copy = COPY[locale]; + const regionId = useId(); + const [opened, setOpened] = useState(false); + const [loading, setLoading] = useState(false); + const [projection, setProjection] = useState(null); + const [error, setError] = useState(false); + + useEffect(() => { + setOpened(false); + setLoading(false); + setProjection(null); + setError(false); + }, [link.project_key, link.focus_post_id, link.knowledge_cutoff]); + + function toggle() { + if (opened) { + setOpened(false); + return; + } + setOpened(true); + if (projection || loading) return; + setLoading(true); + setError(false); + fetchProjectHistory( + accessToken, + link.project_key, + link.knowledge_cutoff, + link.focus_post_id, + ) + .then((result) => { + setProjection(result); + setLoading(false); + }) + .catch(() => { + setError(true); + setLoading(false); + }); + } + + return ( +
+
+ {link.project_name} + + {link.truth_status_code === "observed" ? copy.observed : copy.inferred} + +
+ + +
+ ); +} + +export function AskProjectHistoryLinks({ + accessToken, + links, + truncated, + onOpenPost, +}: { + accessToken: string; + links: ProjectHistoryLink[]; + truncated: boolean; + onOpenPost: (postId: string) => void; +}) { + const locale = useLocale(); + const copy = COPY[locale]; + const headingId = useId(); + if (links.length === 0 && !truncated) return null; + return ( +
+

{copy.heading}

+

{copy.boundary}

+ {links.map((link) => ( + + ))} + {truncated ?

{copy.truncated}

: null} +
+ ); +} +''', + ) + write_new( + "frontend/src/components/AskProjectHistoryLinks.css", + r''' +.ask-project-history-links { + display: grid; + gap: 0.75rem; + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--border-color, #d7dce5); +} + +.ask-project-history-links > h4, +.ask-project-history-link p { + margin: 0; +} + +.ask-project-history-link { + display: grid; + gap: 0.625rem; + padding: 0.75rem; + border: 1px solid var(--border-color, #d7dce5); + border-radius: 0.75rem; + background: var(--surface-color, #fff); +} + +.ask-project-history-link > div:first-child { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; +} + +.ask-project-history-link > button { + justify-self: start; +} + +.ask-project-history-link [hidden] { + display: none; +} +''', + ) + write_new( + "frontend/src/components/AskProjectHistoryLinks.stories.tsx", + r''' +import type { Meta, StoryObj } from "@storybook/react"; + +import { AskProjectHistoryLinks } from "./AskProjectHistoryLinks"; + +const meta = { + title: "Buyer/Ask Project History Links", + component: AskProjectHistoryLinks, + args: { + accessToken: "storybook-token", + links: [ + { + project_key: "P-100", + project_name: "Synthetic renewal", + focus_post_id: "post-voc", + source_post_ids: ["post-spec", "post-voc"], + knowledge_cutoff: "2026-08-20T12:00:00Z", + truth_status_code: "observed", + }, + ], + truncated: false, + onOpenPost: () => undefined, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const ObservedProject: Story = {}; + +export const InferredAndTruncated: Story = { + args: { + links: [ + { + project_key: "semantic-project", + project_name: "Semantic project candidate", + focus_post_id: "post-candidate", + source_post_ids: ["post-candidate"], + knowledge_cutoff: "2026-08-20T12:00:00Z", + truth_status_code: "inferred", + }, + ], + truncated: true, + }, +}; +''', + ) + + +def patch_frontend_api_and_app() -> None: + """Expose structured links and render them in both answer surfaces.""" + + replace_once( + "frontend/src/api.ts", + """export interface CitedPostEvidence { + post_id: string; + facts: CitedPostEvidenceFact[]; +} + +export interface ChatAnswer { +""", + """export interface CitedPostEvidence { + post_id: string; + facts: CitedPostEvidenceFact[]; +} + +export interface ProjectHistoryLink { + project_key: string; + project_name: string; + focus_post_id: string; + source_post_ids: string[]; + knowledge_cutoff: string; + truth_status_code: "observed" | "inferred"; +} + +export interface ChatAnswer { +""", + ) + replace_once( + "frontend/src/api.ts", + """ cited_posts?: CitedPostRef[]; + source_post_ids: string[]; +} + +export interface ChatExchange { +""", + """ cited_posts?: CitedPostRef[]; + source_post_ids: string[]; + knowledge_cutoff?: string; + project_histories?: ProjectHistoryLink[]; + project_histories_truncated?: boolean; +} + +export interface ChatExchange { +""", + ) + replace_once( + "frontend/src/api.ts", + """ cited_post_ids: string[]; + cited_posts?: CitedPostRef[]; +} + +export interface ChatHistory { +""", + """ cited_post_ids: string[]; + cited_posts?: CitedPostRef[]; + knowledge_cutoff?: string; + project_histories?: ProjectHistoryLink[]; + project_histories_truncated?: boolean; +} + +export interface ChatHistory { +""", + ) + replace_once( + "frontend/src/api.ts", + """ timeline?: AskTimelineEntry[]; + next_action?: string; +} +""", + """ timeline?: AskTimelineEntry[]; + knowledge_cutoff?: string; + project_histories?: ProjectHistoryLink[]; + project_histories_truncated?: boolean; + next_action?: string; +} +""", + ) + replace_once( + "frontend/src/App.tsx", + " type PostSortOrder,\n", + " type PostSortOrder,\n type ProjectHistoryLink,\n", + ) + replace_once( + "frontend/src/App.tsx", + 'import { ProjectHistoryTimeline } from "./components/ProjectHistoryTimeline";\n', + 'import { AskProjectHistoryLinks } from "./components/AskProjectHistoryLinks";\n' + 'import { ProjectHistoryTimeline } from "./components/ProjectHistoryTimeline";\n', + ) + replace_once( + "frontend/src/App.tsx", + """ cited_post_ids: result.cited_post_ids, + cited_posts: result.cited_posts, + }; +""", + """ cited_post_ids: result.cited_post_ids, + cited_posts: result.cited_posts, + knowledge_cutoff: result.knowledge_cutoff, + project_histories: result.project_histories, + project_histories_truncated: result.project_histories_truncated, + }; +""", + ) + first_citations = ''' +''' + replace_once( + "frontend/src/App.tsx", + first_citations, + first_citations + + ''' +''', + ) + map_citations = ''' +''' + replace_once( + "frontend/src/App.tsx", + map_citations, + map_citations + + ''' +''', + ) + answer_citations = ''' +''' + replace_once( + "frontend/src/App.tsx", + answer_citations, + answer_citations + + ''' +''', + ) + replace_once( + "frontend/src/App.tsx", + """ async function handleAsk() { + const normalized = question.trim(); + if (!normalized) return; + setAsking(true); + setError(null); + setAnswer(null); + try { + const nextAnswer = await askAgent(accessToken, normalized, sessionId); + setAnswer(nextAnswer); + setSessionId(nextAnswer.session_id); + window.sessionStorage.setItem("lineageweave.globalAskSessionId", nextAnswer.session_id); + } catch (err) { + setAnswer(null); + setError(orchestratorUnavailableMessage(err, t("Ask Agent"))); + } finally { + setAsking(false); + } + } +""", + """ function acceptAnswer(nextAnswer: AskAgentResponse) { + setAnswer(nextAnswer); + setSessionId(nextAnswer.session_id); + window.sessionStorage.setItem("lineageweave.globalAskSessionId", nextAnswer.session_id); + } + + async function handleAsk() { + const normalized = question.trim(); + if (!normalized) return; + setAsking(true); + setError(null); + setAnswer(null); + try { + acceptAnswer(await askAgent(accessToken, normalized, sessionId)); + } catch (err) { + if (err instanceof BackendError && err.status === 409 && sessionId) { + window.sessionStorage.removeItem("lineageweave.globalAskSessionId"); + setSessionId(undefined); + try { + acceptAnswer(await askAgent(accessToken, normalized)); + return; + } catch (retryError) { + setError(orchestratorUnavailableMessage(retryError, t("Ask Agent"))); + return; + } + } + setError(orchestratorUnavailableMessage(err, t("Ask Agent"))); + } finally { + setAsking(false); + } + } +""", + ) + timeline_block = ''' {answer.timeline && answer.timeline.length > 0 ? ( + <> +

Event Lineage timeline

+
    + {answer.timeline.map((event) => ( +
  1. + +
  2. + ))} +
+ + ) : null} +''' + replace_once( + "frontend/src/App.tsx", + timeline_block, + timeline_block + + ''' +''', + ) + + +def patch_docs_and_versions() -> None: + """Record the closed Ask integration and advance the stacked release version.""" + + replace_once("pyproject.toml", 'version = "2.19.0"\n', 'version = "2.20.0"\n') + replace_once( + "frontend/package.json", + ' "version": "2.19.0",\n', + ' "version": "2.20.0",\n', + ) + replace_once( + "uv.lock", + 'name = "lineageweave"\nversion = "2.19.0"\n', + 'name = "lineageweave"\nversion = "2.20.0"\n', + ) + replace_once( + "CHANGELOG.md", + "## [2.19.0] - 2026-08-21\n", + "## [2.20.0] - 2026-08-21\n\n" + "### Added\n\n" + "- Post-scoped Ask and Global Ask now attach exact project-history links derived\n" + " only from currently authorized cited posts. Opening a link reuses the canonical\n" + " Project history timeline and its optional TEPP validation at the answer cutoff.\n\n" + "### Security\n\n" + "- Persisted post answers are withheld when any citation is no longer visible, and\n" + " stale Global Ask sessions are restarted before hidden prior prose can re-enter\n" + " conversation context (ADR 0113).\n\n" + "## [2.19.0] - 2026-08-21\n", + ) + append_once( + "docs/product-technical-gap-baseline.md", + "## Ask-to-project-history integration (2026-08-21)", + """ +## Ask-to-project-history integration (2026-08-21) + +- Post-scoped Ask and Global Ask return structured project-history links only for exact + project identities on their currently authorized cited posts. +- Opening a link lazily calls the canonical Project history endpoint with the answer + knowledge cutoff and cited focus post; no second timeline, classifier, or TEPP query is + 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. +- 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 + and evidence navigation do not identify why a VOC occurred. +""", + ) + write_new( + "docs/adr/0113-project-history-links-in-ask-surfaces.md", + r''' +# ADR 0113: Reuse canonical project history in Ask surfaces + +- Status: Proposed +- Date: 2026-08-21 +- Depends on: ADR 0112 and the canonical Project history read model + +## Context + +Post-scoped Ask and Global Ask already cite authorized source posts, but they did not +connect those citations to the project lifecycle timeline shown in the product design. +The earlier orphaned stack attempted to solve this with another project-history flow. +That would create competing project identity, authorization, cutoff, classification, and +TEPP behavior. + +Persisted Ask prose introduces an additional security boundary: if a previously cited +post becomes hidden, deleted, draft, or otherwise ineligible, returning the old answer or +reusing it as conversation context can disclose facts no longer authorized. + +## Decision + +1. Ask responses expose structured project-history links derived only from cited post IDs. +2. Citation IDs are reauthorized with tenant ABAC, source publication eligibility, and the + answer knowledge cutoff before titles or project identities are returned. +3. Exact source project fields outrank semantic project candidates; inferred identities + remain labelled inferred. Links are bounded and deterministic. +4. Opening a link calls the canonical Project history endpoint with project key, answer + cutoff, and cited focus post. The established timeline and TEPP metadata are reused. +5. A persisted post answer is withheld in full when any citation is 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. +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. +8. Timeline or TEPP failure does not remove the answer; the Buyer receives an actionable + error and can still open the exact cited source post. + +## Consequences + +- Document reading, post Ask, Global Ask, and the dedicated Project history destination + share one authorization-first read model and one timeline component. +- Historical answers can disappear after permission or publication changes. This is an + intentional fail-closed property, not data loss from the evidence store. +- A session restart can lose conversational convenience, but prevents a compressed + summary from carrying hidden prose forward. +- Event order remains a temporal association and is not presented as causal inference. + +## Rejected alternatives + +- Parse project identities from answer prose. This is nondeterministic and ungrounded. +- Build a second project query or timeline inside Ask. This duplicates authority. +- Return a stored answer while merely hiding its citation chips. The prose may still leak + the hidden source. +- Keep a stale Global Ask summary and filter only new citations. The summary cannot be + safely decomposed after authorization changes. +''', + ) + + +def main() -> None: + """Apply every product, test-support, and documentation edit.""" + + create_backend_projection() + patch_post_chat_ingestion() + patch_main_routes() + create_frontend_component() + patch_frontend_api_and_app() + patch_docs_and_versions() + + +if __name__ == "__main__": + main() From 807bfac327a5ee4ae98563b311c973c2eb7aaa56 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 18:15:57 -0700 Subject: [PATCH 04/27] ci: verify project-history Ask integration --- .../one-shot-project-history-ask-surfaces.yml | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 .github/workflows/one-shot-project-history-ask-surfaces.yml diff --git a/.github/workflows/one-shot-project-history-ask-surfaces.yml b/.github/workflows/one-shot-project-history-ask-surfaces.yml new file mode 100644 index 000000000..781932f64 --- /dev/null +++ b/.github/workflows/one-shot-project-history-ask-surfaces.yml @@ -0,0 +1,138 @@ +name: One-shot Project history Ask integration + +on: + push: + branches: + - feat/project-history-ask-surfaces-v2200 + +permissions: + contents: write + +concurrency: + group: one-shot-project-history-ask-surfaces + cancel-in-progress: true + +jobs: + integrate: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + timeout-minutes: 90 + services: + postgres: + image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 + env: + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN: postgresql://postgres:postgres@localhost:5432/postgres + steps: + - name: Checkout stacked branch + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 + with: + ref: feat/project-history-ask-surfaces-v2200 + fetch-depth: 0 + persist-credentials: true + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Set up locked dependency manager + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + version: "0.11.28" + enable-cache: false + + - name: Select pinned Rust toolchain + run: | + rustup toolchain install 1.97.1 --profile minimal + rustup default 1.97.1 + + - name: Install committed Python lock + run: uv sync --frozen --extra dev --extra backend + + - name: Set up Node + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # actions/setup-node@v5 + with: + node-version: "24" + + - name: Install committed frontend lock + working-directory: frontend + run: | + corepack enable + pnpm install --frozen-lockfile + + - name: Confirm RED backend authorization contract + run: | + if uv run --frozen python -m pytest -q tests/test_ask_project_history.py; then + echo "Expected missing Ask project-history implementation to keep the backend contract red." >&2 + exit 1 + fi + + - name: Confirm RED frontend reuse contract + working-directory: frontend + run: | + if pnpm exec vitest run src/components/AskProjectHistoryLinks.test.tsx; then + echo "Expected the canonical Ask project-history component to be absent before integration." >&2 + exit 1 + fi + + - name: Apply exact-context integration + run: python tools/apply_project_history_ask_surfaces.py + + - name: Format and lint changed Python + run: | + uv run --frozen ruff format \ + backend/app/ask_project_history.py \ + backend/app/post_chat_ingestion.py \ + backend/app/main.py \ + tests/test_ask_project_history.py + uv run --frozen ruff check \ + backend/app/ask_project_history.py \ + backend/app/post_chat_ingestion.py \ + backend/app/main.py \ + tests/test_ask_project_history.py + + - name: Run focused Python contracts + run: >- + uv run --frozen python -m pytest -q + tests/test_ask_project_history.py + tests/test_post_chat_ingestion.py + tests/test_global_ask_sources.py + tests/test_project_history_api_contract.py + tests/test_documentation_hygiene.py + + - name: Compile production and tests + run: uv run --frozen python -m compileall -q lineageweave backend tests + + - name: Run complete frontend tests + working-directory: frontend + run: pnpm run test + + - name: Run frontend lint and production builds + working-directory: frontend + run: | + pnpm run lint + pnpm run build + pnpm run build-storybook + + - name: Remove one-shot machinery and verify patch hygiene + run: | + rm .github/workflows/one-shot-project-history-ask-surfaces.yml + rm tools/apply_project_history_ask_surfaces.py + git diff --check + + - name: Publish verified product head + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "feat: connect Ask answers to canonical project histories" + git push origin HEAD:feat/project-history-ask-surfaces-v2200 From 153c7c40ae86cbf3587cf2ea1fa60762250b54b8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 18:18:24 -0700 Subject: [PATCH 05/27] fix(ci): repair Ask transform f-string and import anchors --- tools/repair_project_history_ask_transform.py | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 tools/repair_project_history_ask_transform.py diff --git a/tools/repair_project_history_ask_transform.py b/tools/repair_project_history_ask_transform.py new file mode 100644 index 000000000..ddc14c193 --- /dev/null +++ b/tools/repair_project_history_ask_transform.py @@ -0,0 +1,57 @@ +"""Repair generated-code details after the bounded Ask transform.""" + +from __future__ import annotations + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def replace_once(path: str, old: str, new: str) -> None: + """Replace one exact generated fragment.""" + + file_path = ROOT / path + text = file_path.read_text(encoding="utf-8") + count = text.count(old) + if count != 1: + raise RuntimeError(f"expected one generated anchor in {path}, found {count}") + file_path.write_text(text.replace(old, new, 1), encoding="utf-8") + + +def main() -> None: + """Make interpolated eligibility SQL executable and remove a stale import.""" + + replace_once( + "backend/app/post_chat_ingestion.py", + """ candidate_rows = await conn.fetch( + """ + select post_id, matched_in +""", + """ candidate_rows = await conn.fetch( + f""" + select post_id, matched_in +""", + ) + replace_once( + "backend/app/post_chat_ingestion.py", + """ rows = await conn.fetch( + """ + select post_id, post_title, post_body, visibility_code, corporate_entity_id, + created_at, +""", + """ rows = await conn.fetch( + f""" + select post_id, post_title, post_body, visibility_code, corporate_entity_id, + created_at, +""", + ) + replace_once( + "backend/app/main.py", + " cited_post_evidence,\n cited_post_summaries,\n", + " cited_post_evidence,\n", + ) + + +if __name__ == "__main__": + main() From 6e10acb701db91a340e39f8f0598eed3ed88cafe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 18:18:56 -0700 Subject: [PATCH 06/27] fix(ci): repair generated Ask SQL before verification --- .github/workflows/one-shot-project-history-ask-surfaces.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/one-shot-project-history-ask-surfaces.yml b/.github/workflows/one-shot-project-history-ask-surfaces.yml index 781932f64..b1b754769 100644 --- a/.github/workflows/one-shot-project-history-ask-surfaces.yml +++ b/.github/workflows/one-shot-project-history-ask-surfaces.yml @@ -85,7 +85,9 @@ jobs: fi - name: Apply exact-context integration - run: python tools/apply_project_history_ask_surfaces.py + run: | + python tools/apply_project_history_ask_surfaces.py + python tools/repair_project_history_ask_transform.py - name: Format and lint changed Python run: | @@ -127,6 +129,7 @@ jobs: run: | rm .github/workflows/one-shot-project-history-ask-surfaces.yml rm tools/apply_project_history_ask_surfaces.py + rm tools/repair_project_history_ask_transform.py git diff --check - name: Publish verified product head From 9fbec381eb1a815d999b21b02018c47b1c09679a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 18:27:37 -0700 Subject: [PATCH 07/27] test(red): persist the exact post Ask knowledge cutoff --- tests/test_ask_project_history_cutoff.py | 75 ++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 tests/test_ask_project_history_cutoff.py diff --git a/tests/test_ask_project_history_cutoff.py b/tests/test_ask_project_history_cutoff.py new file mode 100644 index 000000000..620711a7c --- /dev/null +++ b/tests/test_ask_project_history_cutoff.py @@ -0,0 +1,75 @@ +"""Contracts for persisted post-Ask knowledge cutoffs.""" + +from __future__ import annotations + +import asyncio +from datetime import datetime, timezone +from pathlib import Path + +from backend.app.post_chat_ingestion import persist_post_chat + + +ROOT = Path(__file__).resolve().parents[1] +CUTOFF = datetime(2026, 8, 20, 12, 0, tzinfo=timezone.utc) + + +class _Connection: + """Minimal chat-persistence double that records SQL parameters.""" + + def __init__(self) -> None: + self.executions: list[tuple[str, tuple[object, ...]]] = [] + + async def execute(self, query: str, *args: object) -> None: + self.executions.append((query, args)) + + async def fetchrow(self, query: str, *args: object): + del args + if "from post_chat_result" not in query: + return None + return { + "question_text": "What happened?", + "answer_text": "Synthetic answer", + "knowledge_cutoff": CUTOFF, + } + + async def fetch(self, query: str, *args: object): + del query, args + return [] + + +def test_persist_post_chat_writes_the_retrieval_cutoff_not_a_later_read_clock() -> None: + conn = _Connection() + + result = asyncio.run( + persist_post_chat( + conn, + "00000000-0000-4000-8000-000000000001", + "What happened?", + "Synthetic answer", + [], + knowledge_cutoff=CUTOFF, + ) + ) + + insert = next( + (query, args) + for query, args in conn.executions + if "insert into post_chat_result" in query + ) + assert "knowledge_cutoff" in insert[0] + assert insert[1][-1] == CUTOFF + assert result["_knowledge_cutoff"] == CUTOFF + + +def test_cutoff_migration_is_applied_and_fails_closed_on_inverted_clocks() -> None: + migration = ROOT / "migrations/0053_post_chat_knowledge_cutoff.sql" + rollback = ROOT / "migrations/rollback/0053_post_chat_knowledge_cutoff.sql" + migrate_script = (ROOT / "docker/postgres-init/migrate.sh").read_text(encoding="utf-8") + + assert migration.is_file() + text = migration.read_text(encoding="utf-8") + assert "knowledge_cutoff timestamptz" in text + assert "knowledge_cutoff = computed_at" in text + assert "knowledge_cutoff <= computed_at" in text + assert rollback.is_file() + assert "0053_*" in migrate_script From 2cdb5265c6016747ab90f8a0e57ca317a3e9a5d5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 18:29:03 -0700 Subject: [PATCH 08/27] fix(ci): preserve exact Ask cutoffs and stale-request safety --- tools/repair_project_history_ask_transform.py | 251 +++++++++++++++++- 1 file changed, 249 insertions(+), 2 deletions(-) diff --git a/tools/repair_project_history_ask_transform.py b/tools/repair_project_history_ask_transform.py index ddc14c193..0fecb7e8e 100644 --- a/tools/repair_project_history_ask_transform.py +++ b/tools/repair_project_history_ask_transform.py @@ -19,8 +19,18 @@ def replace_once(path: str, old: str, new: str) -> None: file_path.write_text(text.replace(old, new, 1), encoding="utf-8") -def main() -> None: - """Make interpolated eligibility SQL executable and remove a stale import.""" +def write_new(path: str, content: str) -> None: + """Create one migration artifact and reject accidental overwrite.""" + + file_path = ROOT / path + if file_path.exists(): + raise RuntimeError(f"refusing to overwrite existing {path}") + file_path.parent.mkdir(parents=True, exist_ok=True) + file_path.write_text(content.strip() + "\n", encoding="utf-8") + + +def repair_sql_interpolation() -> None: + """Make schema-owned eligibility fragments interpolate before execution.""" replace_once( "backend/app/post_chat_ingestion.py", @@ -53,5 +63,242 @@ def main() -> None: ) +def add_exact_post_chat_cutoff() -> None: + """Persist the retrieval cutoff instead of reconstructing it from write time.""" + + write_new( + "migrations/0053_post_chat_knowledge_cutoff.sql", + """ +alter table post_chat_result + add column if not exists knowledge_cutoff timestamptz; + +update post_chat_result + set knowledge_cutoff = computed_at + where knowledge_cutoff is null; + +alter table post_chat_result + alter column knowledge_cutoff set default now(), + alter column knowledge_cutoff set not null; + +do $$ +begin + if not exists ( + select 1 + from pg_constraint + where conname = 'post_chat_result_knowledge_cutoff_check' + and conrelid = 'post_chat_result'::regclass + ) then + alter table post_chat_result + add constraint post_chat_result_knowledge_cutoff_check + check (knowledge_cutoff <= computed_at); + end if; +end +$$; + +comment on column post_chat_result.knowledge_cutoff is + 'Maximum source availability time used to compute this persisted answer.'; +""", + ) + write_new( + "migrations/rollback/0053_post_chat_knowledge_cutoff.sql", + """ +alter table post_chat_result + drop constraint if exists post_chat_result_knowledge_cutoff_check; + +alter table post_chat_result + drop column if exists knowledge_cutoff; +""", + ) + replace_once( + "docker/postgres-init/migrate.sh", + " 0051_*|0052_*) ;;\n", + " 0051_*|0052_*|0053_*) ;;\n", + ) + replace_once( + "backend/app/post_chat_ingestion.py", + ' "select question_text, answer_text, computed_at from post_chat_result "\n', + ' "select question_text, answer_text, knowledge_cutoff from post_chat_result "\n', + ) + replace_once( + "backend/app/post_chat_ingestion.py", + ' "_knowledge_cutoff": header.get("computed_at"),\n', + ' "_knowledge_cutoff": header.get("knowledge_cutoff"),\n', + ) + replace_once( + "backend/app/post_chat_ingestion.py", + """ cited_post_ids: list[str] | tuple[str, ...], +) -> dict[str, Any]: +""", + """ cited_post_ids: list[str] | tuple[str, ...], + *, + knowledge_cutoff: datetime | None = None, +) -> dict[str, Any]: +""", + ) + replace_once( + "backend/app/post_chat_ingestion.py", + """ if not norm: + raise ValueError("question is empty after normalize") + await conn.execute( +""", + """ if not norm: + raise ValueError("question is empty after normalize") + cutoff = _ask_cutoff(knowledge_cutoff) + await conn.execute( +""", + ) + replace_once( + "backend/app/post_chat_ingestion.py", + """ "insert into post_chat_result (post_id, question_norm, question_text, answer_text) " + "values ($1, $2, $3, $4)", + post_id, + norm, + question.strip(), + answer_text, +""", + """ "insert into post_chat_result " + "(post_id, question_norm, question_text, answer_text, knowledge_cutoff) " + "values ($1, $2, $3, $4, $5)", + post_id, + norm, + question.strip(), + answer_text, + cutoff, +""", + ) + replace_once( + "backend/app/main.py", + """ await persist_post_chat(conn, post_id, question, answer.answer_text, cited_ids) + answer_evidence = await read_authorized_ask_evidence( +""", + """ await persist_post_chat( + conn, + post_id, + question, + answer.answer_text, + cited_ids, + knowledge_cutoff=knowledge_cutoff, + ) + answer_evidence = await read_authorized_ask_evidence( +""", + ) + + +def harden_ask_projection() -> None: + """Canonicalize untrusted IDs and bound public project projection sizes.""" + + replace_once( + "backend/app/ask_project_history.py", + "from typing import Any, Protocol\n", + "from typing import Any, Protocol\nfrom uuid import UUID\n", + ) + replace_once( + "backend/app/ask_project_history.py", + """ citations = tuple(dict.fromkeys(str(value) for value in cited_post_ids if str(value))) + if len(citations) > maximum_citations: +""", + """ try: + citations = tuple( + dict.fromkeys( + str(UUID(str(value))) for value in cited_post_ids if str(value).strip() + ) + ) + except (TypeError, ValueError, AttributeError) as exc: + raise ValueError("citation identities must be UUIDs") from exc + if len(citations) > maximum_citations: +""", + ) + replace_once( + "backend/app/ask_project_history.py", + """ cutoff = ask_knowledge_cutoff(knowledge_cutoff) + cutoff_text = _cutoff_text(cutoff) +""", + """ if maximum_projects < 0 or maximum_projects > ASK_PROJECT_LIMIT: + raise ValueError("project count is outside the supported bound") + cutoff = ask_knowledge_cutoff(knowledge_cutoff) + cutoff_text = _cutoff_text(cutoff) +""", + ) + + +def harden_frontend_async_state() -> None: + """Ignore stale timeline fetches and retry only the intended session conflict.""" + + replace_once( + "frontend/src/components/AskProjectHistoryLinks.tsx", + 'import { useEffect, useId, useState } from "react";\n', + 'import { useEffect, useId, useRef, useState } from "react";\n', + ) + replace_once( + "frontend/src/components/AskProjectHistoryLinks.tsx", + """ const [projection, setProjection] = useState(null); + const [error, setError] = useState(false); + + useEffect(() => { +""", + """ const [projection, setProjection] = useState(null); + const [error, setError] = useState(false); + const requestGeneration = useRef(0); + + useEffect(() => { + requestGeneration.current += 1; +""", + ) + replace_once( + "frontend/src/components/AskProjectHistoryLinks.tsx", + """ setLoading(true); + setError(false); + fetchProjectHistory( +""", + """ setLoading(true); + setError(false); + const generation = ++requestGeneration.current; + fetchProjectHistory( +""", + ) + replace_once( + "frontend/src/components/AskProjectHistoryLinks.tsx", + """ .then((result) => { + setProjection(result); + setLoading(false); + }) + .catch(() => { + setError(true); + setLoading(false); + }); +""", + """ .then((result) => { + if (generation !== requestGeneration.current) return; + setProjection(result); + setLoading(false); + }) + .catch(() => { + if (generation !== requestGeneration.current) return; + setError(true); + setLoading(false); + }); +""", + ) + replace_once( + "frontend/src/App.tsx", + "if (err instanceof BackendError && err.status === 409 && sessionId) {", + """if ( + err instanceof BackendError && + err.status === 409 && + sessionId && + err.message.toLowerCase().includes("start a new session") + ) {""", + ) + + +def main() -> None: + """Repair SQL, exact cutoffs, hostile IDs, and asynchronous UI state.""" + + repair_sql_interpolation() + add_exact_post_chat_cutoff() + harden_ask_projection() + harden_frontend_async_state() + + if __name__ == "__main__": main() From bb3d306f25b4a3f0e4563c2ad73126442dd1b308 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 18:29:59 -0700 Subject: [PATCH 09/27] ci: verify exact persisted Ask cutoffs --- .../one-shot-project-history-ask-surfaces.yml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/one-shot-project-history-ask-surfaces.yml b/.github/workflows/one-shot-project-history-ask-surfaces.yml index b1b754769..6cc6c9caa 100644 --- a/.github/workflows/one-shot-project-history-ask-surfaces.yml +++ b/.github/workflows/one-shot-project-history-ask-surfaces.yml @@ -71,7 +71,9 @@ jobs: - name: Confirm RED backend authorization contract run: | - if uv run --frozen python -m pytest -q tests/test_ask_project_history.py; then + if uv run --frozen python -m pytest -q \ + tests/test_ask_project_history.py \ + tests/test_ask_project_history_cutoff.py; then echo "Expected missing Ask project-history implementation to keep the backend contract red." >&2 exit 1 fi @@ -95,17 +97,20 @@ jobs: backend/app/ask_project_history.py \ backend/app/post_chat_ingestion.py \ backend/app/main.py \ - tests/test_ask_project_history.py + tests/test_ask_project_history.py \ + tests/test_ask_project_history_cutoff.py uv run --frozen ruff check \ backend/app/ask_project_history.py \ backend/app/post_chat_ingestion.py \ backend/app/main.py \ - tests/test_ask_project_history.py + tests/test_ask_project_history.py \ + tests/test_ask_project_history_cutoff.py - name: Run focused Python contracts run: >- uv run --frozen python -m pytest -q tests/test_ask_project_history.py + tests/test_ask_project_history_cutoff.py tests/test_post_chat_ingestion.py tests/test_global_ask_sources.py tests/test_project_history_api_contract.py From f350e91a2a15a76f1a2cf9b59912af48e2d5a00c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 18:34:16 -0700 Subject: [PATCH 10/27] ci: trigger Ask integration verification on stacked PR --- .github/workflows/one-shot-project-history-ask-surfaces.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/one-shot-project-history-ask-surfaces.yml b/.github/workflows/one-shot-project-history-ask-surfaces.yml index 6cc6c9caa..0dfaf7cb4 100644 --- a/.github/workflows/one-shot-project-history-ask-surfaces.yml +++ b/.github/workflows/one-shot-project-history-ask-surfaces.yml @@ -4,6 +4,9 @@ on: push: branches: - feat/project-history-ask-surfaces-v2200 + pull_request: + branches: + - feat/tepp-project-history-recovery-v2210 permissions: contents: write From 34d5f98d291c172e34599364787da08671d351f8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 18:52:20 -0700 Subject: [PATCH 11/27] ci: restack Ask integration on latest TEPP recovery --- .../workflows/one-shot-project-history-ask-surfaces.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/one-shot-project-history-ask-surfaces.yml b/.github/workflows/one-shot-project-history-ask-surfaces.yml index 0dfaf7cb4..739a19955 100644 --- a/.github/workflows/one-shot-project-history-ask-surfaces.yml +++ b/.github/workflows/one-shot-project-history-ask-surfaces.yml @@ -42,6 +42,13 @@ jobs: fetch-depth: 0 persist-credentials: true + - name: Merge the latest parent head without rewriting history + run: | + git fetch origin feat/tepp-project-history-recovery-v2210 + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git merge --no-edit origin/feat/tepp-project-history-recovery-v2210 + - name: Set up Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # actions/setup-python@v6 with: From 5512f33a3f902988367e8e8cc66dc7706fb0aebd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 18:56:03 -0700 Subject: [PATCH 12/27] fix(ci): bind persisted Ask clocks to one application timeline --- tools/harden_project_history_ask_cutoff.py | 63 ++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 tools/harden_project_history_ask_cutoff.py diff --git a/tools/harden_project_history_ask_cutoff.py b/tools/harden_project_history_ask_cutoff.py new file mode 100644 index 000000000..8a2fad53a --- /dev/null +++ b/tools/harden_project_history_ask_cutoff.py @@ -0,0 +1,63 @@ +"""Harden generated post-Ask persistence against database clock skew.""" + +from __future__ import annotations + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def replace_once(path: str, old: str, new: str) -> None: + """Replace one exact generated persistence fragment.""" + + file_path = ROOT / path + text = file_path.read_text(encoding="utf-8") + count = text.count(old) + if count != 1: + raise RuntimeError(f"expected one cutoff anchor in {path}, found {count}") + file_path.write_text(text.replace(old, new, 1), encoding="utf-8") + + +def main() -> None: + """Write computed and cutoff clocks from one monotonic application decision.""" + + replace_once( + "backend/app/post_chat_ingestion.py", + """ cutoff = _ask_cutoff(knowledge_cutoff) + await conn.execute( + "delete from post_chat_result where post_id = $1 and question_norm = $2", +""", + """ cutoff = _ask_cutoff(knowledge_cutoff) + computed_at = max(datetime.now(timezone.utc), cutoff) + await conn.execute( + "delete from post_chat_result where post_id = $1 and question_norm = $2", +""", + ) + replace_once( + "backend/app/post_chat_ingestion.py", + """ "insert into post_chat_result " + "(post_id, question_norm, question_text, answer_text, knowledge_cutoff) " + "values ($1, $2, $3, $4, $5)", + post_id, + norm, + question.strip(), + answer_text, + cutoff, +""", + """ "insert into post_chat_result " + "(post_id, question_norm, question_text, answer_text, " + "computed_at, knowledge_cutoff) " + "values ($1, $2, $3, $4, $5, $6)", + post_id, + norm, + question.strip(), + answer_text, + computed_at, + cutoff, +""", + ) + + +if __name__ == "__main__": + main() From 3834de8ee532d00cac0def7856b193a8b8e3ced6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 18:57:07 -0700 Subject: [PATCH 13/27] test(ask): keep persisted answer time after its cutoff --- tests/test_ask_project_history_cutoff.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_ask_project_history_cutoff.py b/tests/test_ask_project_history_cutoff.py index 620711a7c..be3c5edf7 100644 --- a/tests/test_ask_project_history_cutoff.py +++ b/tests/test_ask_project_history_cutoff.py @@ -56,7 +56,9 @@ def test_persist_post_chat_writes_the_retrieval_cutoff_not_a_later_read_clock() for query, args in conn.executions if "insert into post_chat_result" in query ) + assert "computed_at" in insert[0] assert "knowledge_cutoff" in insert[0] + assert insert[1][-2] >= CUTOFF assert insert[1][-1] == CUTOFF assert result["_knowledge_cutoff"] == CUTOFF From 70f749826ec35e7ed500386a7c9f519f501e6348 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 18:58:06 -0700 Subject: [PATCH 14/27] ci: verify hardened Ask cutoff persistence --- .github/workflows/one-shot-project-history-ask-surfaces.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/one-shot-project-history-ask-surfaces.yml b/.github/workflows/one-shot-project-history-ask-surfaces.yml index 739a19955..feef83b28 100644 --- a/.github/workflows/one-shot-project-history-ask-surfaces.yml +++ b/.github/workflows/one-shot-project-history-ask-surfaces.yml @@ -100,6 +100,7 @@ jobs: run: | python tools/apply_project_history_ask_surfaces.py python tools/repair_project_history_ask_transform.py + python tools/harden_project_history_ask_cutoff.py - name: Format and lint changed Python run: | @@ -145,6 +146,7 @@ jobs: rm .github/workflows/one-shot-project-history-ask-surfaces.yml rm tools/apply_project_history_ask_surfaces.py rm tools/repair_project_history_ask_transform.py + rm tools/harden_project_history_ask_cutoff.py git diff --check - name: Publish verified product head From b5bf17e688a7b9168b188197a59c179206e7aa96 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 18:58:43 -0700 Subject: [PATCH 15/27] test(db): reserve the next migration after project history --- tests/test_ask_project_history_cutoff.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_ask_project_history_cutoff.py b/tests/test_ask_project_history_cutoff.py index be3c5edf7..22c7cd059 100644 --- a/tests/test_ask_project_history_cutoff.py +++ b/tests/test_ask_project_history_cutoff.py @@ -64,8 +64,8 @@ def test_persist_post_chat_writes_the_retrieval_cutoff_not_a_later_read_clock() def test_cutoff_migration_is_applied_and_fails_closed_on_inverted_clocks() -> None: - migration = ROOT / "migrations/0053_post_chat_knowledge_cutoff.sql" - rollback = ROOT / "migrations/rollback/0053_post_chat_knowledge_cutoff.sql" + migration = ROOT / "migrations/0054_post_chat_knowledge_cutoff.sql" + rollback = ROOT / "migrations/rollback/0054_post_chat_knowledge_cutoff.sql" migrate_script = (ROOT / "docker/postgres-init/migrate.sh").read_text(encoding="utf-8") assert migration.is_file() @@ -74,4 +74,4 @@ def test_cutoff_migration_is_applied_and_fails_closed_on_inverted_clocks() -> No assert "knowledge_cutoff = computed_at" in text assert "knowledge_cutoff <= computed_at" in text assert rollback.is_file() - assert "0053_*" in migrate_script + assert "0054_*" in migrate_script From 65468a80b3a234b6fa4411e4de9dc49d6efc5122 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 19:00:42 -0700 Subject: [PATCH 16/27] fix(ci): allocate Ask cutoff migration after project history --- tools/repair_project_history_ask_transform.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/repair_project_history_ask_transform.py b/tools/repair_project_history_ask_transform.py index 0fecb7e8e..c0a5e8200 100644 --- a/tools/repair_project_history_ask_transform.py +++ b/tools/repair_project_history_ask_transform.py @@ -67,7 +67,7 @@ def add_exact_post_chat_cutoff() -> None: """Persist the retrieval cutoff instead of reconstructing it from write time.""" write_new( - "migrations/0053_post_chat_knowledge_cutoff.sql", + "migrations/0054_post_chat_knowledge_cutoff.sql", """ alter table post_chat_result add column if not exists knowledge_cutoff timestamptz; @@ -100,7 +100,7 @@ def add_exact_post_chat_cutoff() -> None: """, ) write_new( - "migrations/rollback/0053_post_chat_knowledge_cutoff.sql", + "migrations/rollback/0054_post_chat_knowledge_cutoff.sql", """ alter table post_chat_result drop constraint if exists post_chat_result_knowledge_cutoff_check; @@ -111,8 +111,8 @@ def add_exact_post_chat_cutoff() -> None: ) replace_once( "docker/postgres-init/migrate.sh", - " 0051_*|0052_*) ;;\n", " 0051_*|0052_*|0053_*) ;;\n", + " 0051_*|0052_*|0053_*|0054_*) ;;\n", ) replace_once( "backend/app/post_chat_ingestion.py", From 5edff9e09442305135f81b7249ac10d2f8d5e35b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 19:02:49 -0700 Subject: [PATCH 17/27] test(db): use the next available migration sequence --- tests/test_ask_project_history_cutoff.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_ask_project_history_cutoff.py b/tests/test_ask_project_history_cutoff.py index 22c7cd059..be3c5edf7 100644 --- a/tests/test_ask_project_history_cutoff.py +++ b/tests/test_ask_project_history_cutoff.py @@ -64,8 +64,8 @@ def test_persist_post_chat_writes_the_retrieval_cutoff_not_a_later_read_clock() def test_cutoff_migration_is_applied_and_fails_closed_on_inverted_clocks() -> None: - migration = ROOT / "migrations/0054_post_chat_knowledge_cutoff.sql" - rollback = ROOT / "migrations/rollback/0054_post_chat_knowledge_cutoff.sql" + migration = ROOT / "migrations/0053_post_chat_knowledge_cutoff.sql" + rollback = ROOT / "migrations/rollback/0053_post_chat_knowledge_cutoff.sql" migrate_script = (ROOT / "docker/postgres-init/migrate.sh").read_text(encoding="utf-8") assert migration.is_file() @@ -74,4 +74,4 @@ def test_cutoff_migration_is_applied_and_fails_closed_on_inverted_clocks() -> No assert "knowledge_cutoff = computed_at" in text assert "knowledge_cutoff <= computed_at" in text assert rollback.is_file() - assert "0054_*" in migrate_script + assert "0053_*" in migrate_script From f881c768a7752bb448fb4564c992acc0dd417811 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 19:03:49 -0700 Subject: [PATCH 18/27] fix(ci): allocate the first free Ask cutoff migration --- tools/repair_project_history_ask_transform.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/repair_project_history_ask_transform.py b/tools/repair_project_history_ask_transform.py index c0a5e8200..0fecb7e8e 100644 --- a/tools/repair_project_history_ask_transform.py +++ b/tools/repair_project_history_ask_transform.py @@ -67,7 +67,7 @@ def add_exact_post_chat_cutoff() -> None: """Persist the retrieval cutoff instead of reconstructing it from write time.""" write_new( - "migrations/0054_post_chat_knowledge_cutoff.sql", + "migrations/0053_post_chat_knowledge_cutoff.sql", """ alter table post_chat_result add column if not exists knowledge_cutoff timestamptz; @@ -100,7 +100,7 @@ def add_exact_post_chat_cutoff() -> None: """, ) write_new( - "migrations/rollback/0054_post_chat_knowledge_cutoff.sql", + "migrations/rollback/0053_post_chat_knowledge_cutoff.sql", """ alter table post_chat_result drop constraint if exists post_chat_result_knowledge_cutoff_check; @@ -111,8 +111,8 @@ def add_exact_post_chat_cutoff() -> None: ) replace_once( "docker/postgres-init/migrate.sh", + " 0051_*|0052_*) ;;\n", " 0051_*|0052_*|0053_*) ;;\n", - " 0051_*|0052_*|0053_*|0054_*) ;;\n", ) replace_once( "backend/app/post_chat_ingestion.py", From ab489d81af9b48cf6adc7ae7ff22f84001cf20e2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 11:45:16 +0900 Subject: [PATCH 19/27] feat: connect Ask answers to project histories --- .../one-shot-project-history-ask-surfaces.yml | 158 -- CHANGELOG.md | 14 + backend/app/ask_project_history.py | 307 ++++ backend/app/main.py | 119 +- backend/app/post_chat_ingestion.py | 80 +- backend/tests/test_api.py | 6 + docker/postgres-init/migrate.sh | 2 +- ...3-project-history-links-in-ask-surfaces.md | 56 + docs/product-technical-gap-baseline.md | 15 + frontend/package.json | 2 +- frontend/src/App.tsx | 52 +- frontend/src/api.ts | 18 + .../src/components/AskProjectHistoryLinks.css | 36 + .../AskProjectHistoryLinks.stories.tsx | 44 + .../src/components/AskProjectHistoryLinks.tsx | 177 ++ lineageweave/__init__.py | 2 +- .../0053_post_chat_knowledge_cutoff.sql | 28 + .../0053_post_chat_knowledge_cutoff.sql | 5 + pyproject.toml | 2 +- tests/test_ask_project_history.py | 57 + tools/apply_project_history_ask_surfaces.py | 1447 ----------------- tools/harden_project_history_ask_cutoff.py | 63 - tools/repair_project_history_ask_transform.py | 304 ---- uv.lock | 2 +- 24 files changed, 984 insertions(+), 2012 deletions(-) delete mode 100644 .github/workflows/one-shot-project-history-ask-surfaces.yml create mode 100644 backend/app/ask_project_history.py create mode 100644 docs/adr/0113-project-history-links-in-ask-surfaces.md create mode 100644 frontend/src/components/AskProjectHistoryLinks.css create mode 100644 frontend/src/components/AskProjectHistoryLinks.stories.tsx create mode 100644 frontend/src/components/AskProjectHistoryLinks.tsx create mode 100644 migrations/0053_post_chat_knowledge_cutoff.sql create mode 100644 migrations/rollback/0053_post_chat_knowledge_cutoff.sql delete mode 100644 tools/apply_project_history_ask_surfaces.py delete mode 100644 tools/harden_project_history_ask_cutoff.py delete mode 100644 tools/repair_project_history_ask_transform.py diff --git a/.github/workflows/one-shot-project-history-ask-surfaces.yml b/.github/workflows/one-shot-project-history-ask-surfaces.yml deleted file mode 100644 index feef83b28..000000000 --- a/.github/workflows/one-shot-project-history-ask-surfaces.yml +++ /dev/null @@ -1,158 +0,0 @@ -name: One-shot Project history Ask integration - -on: - push: - branches: - - feat/project-history-ask-surfaces-v2200 - pull_request: - branches: - - feat/tepp-project-history-recovery-v2210 - -permissions: - contents: write - -concurrency: - group: one-shot-project-history-ask-surfaces - cancel-in-progress: true - -jobs: - integrate: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - timeout-minutes: 90 - services: - postgres: - image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 - env: - POSTGRES_PASSWORD: postgres - ports: - - 5432:5432 - options: >- - --health-cmd "pg_isready -U postgres" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - env: - LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN: postgresql://postgres:postgres@localhost:5432/postgres - steps: - - name: Checkout stacked branch - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 - with: - ref: feat/project-history-ask-surfaces-v2200 - fetch-depth: 0 - persist-credentials: true - - - name: Merge the latest parent head without rewriting history - run: | - git fetch origin feat/tepp-project-history-recovery-v2210 - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git merge --no-edit origin/feat/tepp-project-history-recovery-v2210 - - - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # actions/setup-python@v6 - with: - python-version: "3.12" - - - name: Set up locked dependency manager - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 - with: - version: "0.11.28" - enable-cache: false - - - name: Select pinned Rust toolchain - run: | - rustup toolchain install 1.97.1 --profile minimal - rustup default 1.97.1 - - - name: Install committed Python lock - run: uv sync --frozen --extra dev --extra backend - - - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # actions/setup-node@v5 - with: - node-version: "24" - - - name: Install committed frontend lock - working-directory: frontend - run: | - corepack enable - pnpm install --frozen-lockfile - - - name: Confirm RED backend authorization contract - run: | - if uv run --frozen python -m pytest -q \ - tests/test_ask_project_history.py \ - tests/test_ask_project_history_cutoff.py; then - echo "Expected missing Ask project-history implementation to keep the backend contract red." >&2 - exit 1 - fi - - - name: Confirm RED frontend reuse contract - working-directory: frontend - run: | - if pnpm exec vitest run src/components/AskProjectHistoryLinks.test.tsx; then - echo "Expected the canonical Ask project-history component to be absent before integration." >&2 - exit 1 - fi - - - name: Apply exact-context integration - run: | - python tools/apply_project_history_ask_surfaces.py - python tools/repair_project_history_ask_transform.py - python tools/harden_project_history_ask_cutoff.py - - - name: Format and lint changed Python - run: | - uv run --frozen ruff format \ - backend/app/ask_project_history.py \ - backend/app/post_chat_ingestion.py \ - backend/app/main.py \ - tests/test_ask_project_history.py \ - tests/test_ask_project_history_cutoff.py - uv run --frozen ruff check \ - backend/app/ask_project_history.py \ - backend/app/post_chat_ingestion.py \ - backend/app/main.py \ - tests/test_ask_project_history.py \ - tests/test_ask_project_history_cutoff.py - - - name: Run focused Python contracts - run: >- - uv run --frozen python -m pytest -q - tests/test_ask_project_history.py - tests/test_ask_project_history_cutoff.py - tests/test_post_chat_ingestion.py - tests/test_global_ask_sources.py - tests/test_project_history_api_contract.py - tests/test_documentation_hygiene.py - - - name: Compile production and tests - run: uv run --frozen python -m compileall -q lineageweave backend tests - - - name: Run complete frontend tests - working-directory: frontend - run: pnpm run test - - - name: Run frontend lint and production builds - working-directory: frontend - run: | - pnpm run lint - pnpm run build - pnpm run build-storybook - - - name: Remove one-shot machinery and verify patch hygiene - run: | - rm .github/workflows/one-shot-project-history-ask-surfaces.yml - rm tools/apply_project_history_ask_surfaces.py - rm tools/repair_project_history_ask_transform.py - rm tools/harden_project_history_ask_cutoff.py - git diff --check - - - name: Publish verified product head - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "feat: connect Ask answers to canonical project histories" - git push origin HEAD:feat/project-history-ask-surfaces-v2200 diff --git a/CHANGELOG.md b/CHANGELOG.md index 34fe8aafb..02e286f95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ 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.0] - 2026-08-21 + +### Added + +- Post-scoped Ask and Global Ask now attach exact project-history links derived + only from currently authorized cited posts. Opening a link reuses the canonical + Project history timeline and its optional TEPP validation at the answer cutoff. + +### Security + +- Persisted post answers are withheld when any citation is no longer visible, and + stale Global Ask sessions are restarted before hidden prior prose can re-enter + conversation context (ADR 0113). + ## [2.19.0] - 2026-08-21 ### Added diff --git a/backend/app/ask_project_history.py b/backend/app/ask_project_history.py new file mode 100644 index 000000000..cf22e8a32 --- /dev/null +++ b/backend/app/ask_project_history.py @@ -0,0 +1,307 @@ +"""Authorization-safe project-history links for Ask responses. + +The module accepts only citation identities already produced by post-scoped or +Global Ask. It re-applies current tenant visibility, source publication +eligibility, and the answer knowledge cutoff before returning citation labels or +project identities. A missing citation fails the whole persisted answer closed; +answer prose cannot be safely decomposed after one of its sources becomes +unauthorized. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping, Sequence +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Protocol +from uuid import UUID + +from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL +from lineageweave.project_history import normalize_project_key + +ASK_CITATION_LIMIT = 64 +ASK_PROJECT_LIMIT = 8 +GLOBAL_ASK_SESSION_CITATION_LIMIT = 256 + +_ELIGIBILITY = SOURCE_POST_ELIGIBILITY_SQL.format(alias="post") +_CITATION_PROJECT_SQL = f""" +with visible_citation as materialized ( + select post.post_id::text as post_id, + post.post_title, + array_position($1::uuid[], post.post_id) as citation_ordinal, + nullif(btrim(post.source_project_code), '') as source_project_code, + nullif(btrim(post.source_project_name), '') as source_project_name + from source_post post + where post.post_id = any($1::uuid[]) + and (post.visibility_code = 'public' + or post.corporate_entity_id::text = any($2::text[])) + and post.created_at <= $3 + and {_ELIGIBILITY} +), project_evidence as ( + select visible_citation.post_id, + coalesce(visible_citation.source_project_code, + visible_citation.source_project_name) as project_key, + coalesce(visible_citation.source_project_name, + visible_citation.source_project_code) as project_name, + 'observed'::text as truth_status_code, + 0::integer as truth_order + from visible_citation + where coalesce(visible_citation.source_project_code, + visible_citation.source_project_name) is not null + union all + select visible_citation.post_id, + coalesce(nullif(btrim(mention.project_key), ''), + nullif(btrim(mention.project_name), '')) as project_key, + coalesce(nullif(btrim(mention.project_name), ''), + nullif(btrim(mention.project_key), '')) as project_name, + 'inferred'::text as truth_status_code, + 1::integer as truth_order + from visible_citation + join post_project_mention mention + on mention.post_id::text = visible_citation.post_id + where coalesce(nullif(btrim(mention.project_key), ''), + nullif(btrim(mention.project_name), '')) is not null +) +select visible_citation.post_id, + visible_citation.post_title, + visible_citation.citation_ordinal, + project_evidence.project_key, + project_evidence.project_name, + project_evidence.truth_status_code, + project_evidence.truth_order + from visible_citation + left join project_evidence + on project_evidence.post_id = visible_citation.post_id + order by visible_citation.citation_ordinal, + project_evidence.truth_order nulls last, + project_evidence.project_name nulls last, + project_evidence.project_key nulls last +""" +_SESSION_CITATION_SQL = """ +select distinct cited_post_id::text as cited_post_id + from global_ask_turn_citation + where global_ask_session_id = $1 + order by cited_post_id::text + limit $2 +""" + + +class AskEvidenceConnection(Protocol): + """Minimal async query port used by this read projection.""" + + async def fetch(self, query: str, *args: object) -> Sequence[Mapping[str, Any]]: + """Execute a bounded read query.""" + + raise NotImplementedError + + +@dataclass(frozen=True) +class AskEvidenceProjection: + """Currently authorized citation labels and exact project links.""" + + all_citations_visible: bool + cited_posts: tuple[dict[str, str], ...] + project_histories: tuple[dict[str, Any], ...] + project_histories_truncated: bool + knowledge_cutoff: str + + def response_fields(self) -> dict[str, Any]: + """Return the public response fields shared by both Ask surfaces.""" + + return { + "cited_posts": list(self.cited_posts), + "project_histories": list(self.project_histories), + "project_histories_truncated": self.project_histories_truncated, + "knowledge_cutoff": self.knowledge_cutoff, + } + + +def ask_knowledge_cutoff(value: object | None = None) -> datetime: + """Return an offset-aware UTC cutoff from a datetime or ISO text.""" + + if value is None: + return datetime.now(timezone.utc) + if isinstance(value, datetime): + parsed = value + elif isinstance(value, str) and value.strip(): + try: + parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00")) + except ValueError as exc: + raise ValueError("knowledge cutoff must be ISO-8601") from exc + else: + raise ValueError("knowledge cutoff must be a datetime or ISO-8601 text") + if parsed.tzinfo is None or parsed.utcoffset() is None: + raise ValueError("knowledge cutoff must include an offset") + return parsed.astimezone(timezone.utc) + + +def _cutoff_text(value: datetime) -> str: + """Serialize one validated cutoff as canonical UTC RFC 3339 text.""" + + return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _bounded_citations( + cited_post_ids: Iterable[str], *, maximum_citations: int +) -> tuple[str, ...]: + """Return unique citation IDs without silently truncating evidence.""" + + try: + citations = tuple( + dict.fromkeys( + str(UUID(str(value))) for value in cited_post_ids if str(value).strip() + ) + ) + except (AttributeError, TypeError, ValueError) as exc: + raise ValueError("citation identities must be UUIDs") from exc + if len(citations) > maximum_citations: + raise ValueError("citation count exceeds the supported bound") + return citations + + +async def read_authorized_ask_evidence( + conn: AskEvidenceConnection, + *, + cited_post_ids: Iterable[str], + corporate_entity_ids: Iterable[str], + knowledge_cutoff: datetime | str, + maximum_citations: int = ASK_CITATION_LIMIT, + maximum_projects: int = ASK_PROJECT_LIMIT, +) -> AskEvidenceProjection: + """Reauthorize citations and derive bounded exact-project history links. + + A citation is visible only when its current source row passes tenant ABAC, + publication eligibility, and the answer cutoff. If any citation is absent, + project links are withheld and callers must not reuse the persisted answer. + """ + + cutoff = ask_knowledge_cutoff(knowledge_cutoff) + cutoff_text = _cutoff_text(cutoff) + citations = _bounded_citations( + cited_post_ids, + maximum_citations=maximum_citations, + ) + if not citations: + return AskEvidenceProjection(True, (), (), False, cutoff_text) + rows = list( + await conn.fetch( + _CITATION_PROJECT_SQL, + list(citations), + list(corporate_entity_ids), + cutoff, + ) + ) + citation_order = {post_id: index for index, post_id in enumerate(citations, start=1)} + visible_titles: dict[str, str] = {} + for row in rows: + post_id = str(row["post_id"]) + if post_id in citation_order: + visible_titles.setdefault(post_id, str(row["post_title"])) + all_visible = set(visible_titles) == set(citations) + cited_posts = tuple( + {"post_id": post_id, "post_title": visible_titles[post_id]} + for post_id in citations + if post_id in visible_titles + ) + if not all_visible: + return AskEvidenceProjection(False, cited_posts, (), False, cutoff_text) + + evidence_rows = sorted( + ( + row + for row in rows + if row.get("project_key") is not None and row.get("project_name") is not None + ), + key=lambda row: ( + citation_order[str(row["post_id"])], + int(row.get("truth_order") or 0), + str(row["project_name"]), + str(row["project_key"]), + ), + ) + grouped: dict[str, dict[str, Any]] = {} + for row in evidence_rows: + project_key = str(row["project_key"]).strip() + project_name = str(row["project_name"]).strip() + try: + normalized_key = normalize_project_key(project_key) + except ValueError: + continue + post_id = str(row["post_id"]) + truth_order = int(row.get("truth_order") or 0) + group = grouped.get(normalized_key) + if group is None: + grouped[normalized_key] = { + "project_key": project_key, + "project_name": project_name, + "focus_post_id": post_id, + "source_post_ids": [post_id], + "knowledge_cutoff": cutoff_text, + "truth_status_code": str(row["truth_status_code"]), + "truth_order": truth_order, + "first_citation_ordinal": citation_order[post_id], + } + continue + if post_id not in group["source_post_ids"]: + group["source_post_ids"].append(post_id) + if truth_order < group["truth_order"]: + group["project_key"] = project_key + group["project_name"] = project_name + group["truth_status_code"] = str(row["truth_status_code"]) + group["truth_order"] = truth_order + + ordered = sorted( + grouped.values(), + key=lambda group: ( + int(group["first_citation_ordinal"]), + str(group["project_name"]), + str(group["project_key"]), + ), + ) + truncated = len(ordered) > maximum_projects + public_links: list[dict[str, Any]] = [] + for group in ordered[:maximum_projects]: + public_links.append( + { + key: value + for key, value in group.items() + if key not in {"truth_order", "first_citation_ordinal"} + } + ) + return AskEvidenceProjection( + True, + cited_posts, + tuple(public_links), + truncated, + cutoff_text, + ) + + +async def global_ask_session_citations_authorized( + conn: AskEvidenceConnection, + *, + session_id: str, + corporate_entity_ids: Iterable[str], + knowledge_cutoff: datetime | str, +) -> bool: + """Return whether every citation ever reused by a session is still visible.""" + + rows = list( + await conn.fetch( + _SESSION_CITATION_SQL, + session_id, + GLOBAL_ASK_SESSION_CITATION_LIMIT + 1, + ) + ) + if len(rows) > GLOBAL_ASK_SESSION_CITATION_LIMIT: + return False + citations = [str(row["cited_post_id"]) for row in rows] + result = await read_authorized_ask_evidence( + conn, + cited_post_ids=citations, + corporate_entity_ids=corporate_entity_ids, + knowledge_cutoff=knowledge_cutoff, + maximum_citations=GLOBAL_ASK_SESSION_CITATION_LIMIT, + maximum_projects=0, + ) + return result.all_citations_visible diff --git a/backend/app/main.py b/backend/app/main.py index 89af71757..3e770f1cd 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -186,6 +186,11 @@ persist_post_summary, require_summary_source_body, ) +from backend.app.ask_project_history import ( + ask_knowledge_cutoff, + global_ask_session_citations_authorized, + read_authorized_ask_evidence, +) from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL from backend.app.project_history import ( PROJECT_HISTORY_DEFAULT_LIMIT, @@ -2562,9 +2567,25 @@ async def read_post_chat( an empty list, not a fabricated transcript. """ await _load_visible_post(post_id, account, pool) + authorized_exchanges: list[dict[str, Any]] = [] async with pool.acquire() as conn: exchanges = await fetch_persisted_chats(conn, post_id) - return {"post_id": post_id, "exchanges": exchanges} + for exchange in exchanges: + cutoff = ask_knowledge_cutoff(exchange.get("_knowledge_cutoff")) + evidence = await read_authorized_ask_evidence( + conn, + cited_post_ids=exchange["cited_post_ids"], + corporate_entity_ids=account.corporate_entity_ids, + knowledge_cutoff=cutoff, + ) + if not evidence.all_citations_visible: + continue + public_exchange = { + key: value for key, value in exchange.items() if not key.startswith("_") + } + public_exchange.update(evidence.response_fields()) + authorized_exchanges.append(public_exchange) + return {"post_id": post_id, "exchanges": authorized_exchanges} @app.post("/api/posts/{post_id}/chat") @@ -2590,18 +2611,28 @@ async def chat_about_post( raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "question is required") post = await _load_visible_post(post_id, account, pool) post_metadata = build_post_llm_metadata(post_id, post) + knowledge_cutoff = ask_knowledge_cutoff() async with pool.acquire() as conn: stored = await fetch_persisted_chat(conn, post_id, question) if stored is not None: - source_ids = [post_id] - source_ids.extend(cid for cid in stored["cited_post_ids"] if cid != post_id) - return { - "post_id": post_id, - "answer_text": stored["answer_text"], - "cited_post_ids": stored["cited_post_ids"], - "cited_posts": stored["cited_posts"], - "source_post_ids": source_ids, - } + stored_cutoff = ask_knowledge_cutoff(stored.get("_knowledge_cutoff")) + stored_evidence = await read_authorized_ask_evidence( + conn, + cited_post_ids=stored["cited_post_ids"], + corporate_entity_ids=account.corporate_entity_ids, + knowledge_cutoff=stored_cutoff, + ) + if stored_evidence.all_citations_visible: + source_ids = list( + dict.fromkeys([post_id, *stored["cited_post_ids"]]) + ) + return { + "post_id": post_id, + "answer_text": stored["answer_text"], + "cited_post_ids": stored["cited_post_ids"], + "source_post_ids": source_ids, + **stored_evidence.response_fields(), + } with use_llm_metadata(post_metadata): client = _post_chat_client() if not client.available: @@ -2610,7 +2641,11 @@ async def chat_about_post( "Post chat is unavailable: set ORCHESTRATOR_BASE_URL / ORCHESTRATOR_API_KEY", ) sources = await gather_chat_sources( - conn, post_id, lambda row: _can_see_post(account, row), vision_client=_vision_client() + conn, + post_id, + lambda row: _can_see_post(account, row), + vision_client=_vision_client(), + knowledge_cutoff=knowledge_cutoff, ) try: with use_llm_metadata(post_metadata): @@ -2622,7 +2657,25 @@ async def chat_about_post( ) from exc cited_ids = list(answer.cited_post_ids) async with pool.acquire() as conn: - await persist_post_chat(conn, post_id, question, answer.answer_text, cited_ids) + await persist_post_chat( + conn, + post_id, + question, + answer.answer_text, + cited_ids, + knowledge_cutoff=knowledge_cutoff, + ) + answer_evidence = await read_authorized_ask_evidence( + conn, + cited_post_ids=cited_ids, + corporate_entity_ids=account.corporate_entity_ids, + knowledge_cutoff=knowledge_cutoff, + ) + if not answer_evidence.all_citations_visible: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Post chat evidence changed before the answer could be returned", + ) await publish_activity_event( valkey, post_id, @@ -2634,8 +2687,8 @@ async def chat_about_post( "post_id": post_id, "answer_text": answer.answer_text, "cited_post_ids": cited_ids, - "cited_posts": cited_post_summaries(sources, cited_ids), "source_post_ids": [source.post_id for source in sources], + **answer_evidence.response_fields(), } @@ -2656,6 +2709,7 @@ async def ask_agent( UUID(request.session_id) except ValueError: raise HTTPException(status.HTTP_404_NOT_FOUND, "Global Ask session not found") from None + knowledge_cutoff = ask_knowledge_cutoff() client = _post_chat_client() if not client.available: raise HTTPException( @@ -2668,12 +2722,23 @@ async def ask_agent( ) if session_id is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Global Ask session not found") + if not await global_ask_session_citations_authorized( + conn, + session_id=session_id, + corporate_entity_ids=account.corporate_entity_ids, + knowledge_cutoff=knowledge_cutoff, + ): + raise HTTPException( + status.HTTP_409_CONFLICT, + "Global Ask session evidence is no longer authorized; start a new session", + ) conversation = await load_global_ask_context(conn, session_id) sources = await gather_global_chat_sources( conn, lambda row: _can_see_post(account, row), account.corporate_entity_ids, question=question, + knowledge_cutoff=knowledge_cutoff, ) if conversation.compress_turns: compressor = getattr(client, "compress_context", None) @@ -2701,6 +2766,11 @@ async def ask_agent( status.HTTP_503_SERVICE_UNAVAILABLE, "Ask Agent conversation context compression is unavailable", ) from exc + except Exception as exc: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Ask Agent conversation context compression is unavailable", + ) from exc conversation_context = render_global_ask_context( conversation.summary, conversation.recent_turns, @@ -2722,6 +2792,9 @@ async def ask_agent( "source_post_ids": [], "cited_post_evidence": [], "timeline": [], + "project_histories": [], + "project_histories_truncated": False, + "knowledge_cutoff": knowledge_cutoff.isoformat().replace("+00:00", "Z"), "next_action": "No authorized source posts are available for this question.", } try: @@ -2734,7 +2807,12 @@ async def ask_agent( except (HttpClientError, KeyError, OSError, ValueError) as exc: raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, - f"Ask Agent is unavailable: {exc}", + "Ask Agent is unavailable: contextual-orchestrator returned no complete evidence object", + ) from exc + except Exception as exc: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Ask Agent is unavailable: contextual-orchestrator returned no complete evidence object", ) from exc cited_ids = list(answer.cited_post_ids) async with pool.acquire() as conn: @@ -2745,6 +2823,17 @@ async def ask_agent( answer.answer_text, cited_ids, ) + answer_evidence = await read_authorized_ask_evidence( + conn, + cited_post_ids=cited_ids, + corporate_entity_ids=account.corporate_entity_ids, + knowledge_cutoff=knowledge_cutoff, + ) + if not answer_evidence.all_citations_visible: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Global Ask evidence changed before the answer could be returned", + ) await publish_operation_event( valkey, account.user_account_id, @@ -2755,10 +2844,10 @@ async def ask_agent( "session_id": conversation.session_id, "answer_text": answer.answer_text, "cited_post_ids": cited_ids, - "cited_posts": cited_post_summaries(sources, cited_ids), "cited_post_evidence": cited_post_evidence(sources, cited_ids), "source_post_ids": [source.post_id for source in sources], "timeline": global_ask_timeline(sources), + **answer_evidence.response_fields(), } diff --git a/backend/app/post_chat_ingestion.py b/backend/app/post_chat_ingestion.py index e60b00aa5..24f04d703 100644 --- a/backend/app/post_chat_ingestion.py +++ b/backend/app/post_chat_ingestion.py @@ -20,6 +20,7 @@ import asyncio import re from dataclasses import dataclass +from datetime import datetime, timezone from typing import Any, Callable, Iterable from uuid import uuid4 @@ -44,6 +45,7 @@ from lineageweave.post_content_normalization import normalize_post_body from .knowledge_graph import hydrate_related_nodes, load_visible_subgraph +from .post_eligibility import SOURCE_POST_ELIGIBILITY_SQL from lineageweave.ontology import ontology_annotations @@ -340,6 +342,16 @@ async def _graph_facts_for_posts( _GLOBAL_ASK_TERM_PATTERN = re.compile(r"[^\W_]+(?:-[^\W_]+)*", re.UNICODE) _POST_CHAT_SOURCE_LIMIT = 8 _POST_CHAT_CANDIDATE_LIMIT = 32 +_SOURCE_ELIGIBILITY = SOURCE_POST_ELIGIBILITY_SQL.format(alias="source_post") + + +def _ask_cutoff(value: datetime | None) -> datetime: + """Return an aware UTC cutoff for one Ask retrieval.""" + + cutoff = value or datetime.now(timezone.utc) + if cutoff.tzinfo is None or cutoff.utcoffset() is None: + raise ValueError("knowledge_cutoff must include an offset") + return cutoff.astimezone(timezone.utc) def _source_hint_facts(row: Any) -> tuple[str, ...]: @@ -453,6 +465,8 @@ async def gather_chat_sources( post_id: str, can_see_post: Callable[[asyncpg.Record], bool], vision_client: ImageContentClient | None = None, + *, + knowledge_cutoff: datetime | None = None, ) -> list[ChatSourceDocument]: """Post `post_id` plus a bounded, deterministic linked-source window. @@ -469,15 +483,18 @@ async def gather_chat_sources( """ if vision_client is None: vision_client = NullImageContentClient() + cutoff = _ask_cutoff(knowledge_cutoff) this_post = await conn.fetchrow( - "select post_id, post_title, post_body, source_system_code, source_record_key, " + "select post_id, post_title, post_body, created_at, source_system_code, source_record_key, " "source_author_code, source_author_name, source_company_code, source_company_name, " "source_process_unit_code, source_process_unit_name, " "source_sales_pool_code, source_sales_pool_name, " "source_customer_code, source_customer_name, source_project_code, " - "source_project_name from source_post where post_id = $1", + f"source_project_name from source_post where post_id = $1 " + f"and created_at <= $2 and {_SOURCE_ELIGIBILITY}", post_id, + cutoff, ) if this_post is None: return [] @@ -510,11 +527,13 @@ async def gather_chat_sources( "source_company_code, source_company_name, source_process_unit_code, " "source_process_unit_name, source_sales_pool_code, source_sales_pool_name, " "source_customer_code, source_customer_name, " - "source_project_code, source_project_name " - "from source_post where post_id = any($1::uuid[]) " + "source_project_code, source_project_name, created_at " + f"from source_post where post_id = any($1::uuid[]) " + f"and created_at <= $3 and {_SOURCE_ELIGIBILITY} " "order by array_position($1::uuid[], post_id) limit $2", candidate_ids, _POST_CHAT_CANDIDATE_LIMIT, + cutoff, ) visible_source_ids = [post_id] visible_rows: list[asyncpg.Record] = [] @@ -558,6 +577,7 @@ async def gather_global_chat_sources( *, question: str | None = None, limit: int = 4, + knowledge_cutoff: datetime | None = None, ) -> list[ChatSourceDocument]: """Assemble a bounded, ABAC-filtered source set for Global Ask. @@ -569,6 +589,8 @@ async def gather_global_chat_sources( return [] if vision_client is None: vision_client = NullImageContentClient() + cutoff = _ask_cutoff(knowledge_cutoff) + authorized_entity_ids = list(authorized_corporate_entity_ids) search_terms = tuple( dict.fromkeys( token.casefold() @@ -612,29 +634,45 @@ async def gather_global_chat_sources( candidate_scores: dict[str, float] = {} for term in search_terms: candidate_rows = await conn.fetch( - """ + f""" select post_id, matched_in from ( (select post_id, created_at, 'title' as matched_in from source_post - where post_title ilike '%' || $1 || '%' + where (visibility_code = 'public' + or corporate_entity_id::text = any($2::text[])) + and created_at <= $3 + and {_SOURCE_ELIGIBILITY} + and post_title ilike '%' || $1 || '%' limit 32) union all (select post_id, created_at, 'body' as matched_in from source_post - where lower(left(source_post_search_text(post_body), 16384)) + where (visibility_code = 'public' + or corporate_entity_id::text = any($2::text[])) + and created_at <= $3 + and {_SOURCE_ELIGIBILITY} + and lower(left(source_post_search_text(post_body), 16384)) like '%' || lower($1) || '%' limit 32) union all (select post_id, created_at, 'body' as matched_in from source_post - where to_tsvector('simple', source_post_search_text(post_body)) + where (visibility_code = 'public' + or corporate_entity_id::text = any($2::text[])) + and created_at <= $3 + and {_SOURCE_ELIGIBILITY} + and to_tsvector('simple', source_post_search_text(post_body)) @@ plainto_tsquery('simple', $1) limit 32) union all (select post_id, created_at, 'source_field' as matched_in from source_post - where concat_ws(' ', source_system_code, source_record_key, + where (visibility_code = 'public' + or corporate_entity_id::text = any($2::text[])) + and created_at <= $3 + and {_SOURCE_ELIGIBILITY} + and concat_ws(' ', source_system_code, source_record_key, source_author_code, source_author_name, source_company_code, source_company_name, source_process_unit_code, source_process_unit_name, @@ -648,6 +686,8 @@ async def gather_global_chat_sources( limit 32 """, term, + authorized_entity_ids, + cutoff, ) for row in candidate_rows: post_id = str(row["post_id"]) @@ -686,7 +726,7 @@ async def gather_global_chat_sources( lineage_neighbor_id_set = frozenset(lineage_neighbor_ids) rows = await conn.fetch( - """ + f""" select post_id, post_title, post_body, visibility_code, corporate_entity_id, created_at, source_system_code, source_record_key, source_author_code, source_author_name, @@ -695,8 +735,10 @@ async def gather_global_chat_sources( source_customer_code, source_customer_name, source_project_code, source_project_name from source_post - where visibility_code = 'public' - or corporate_entity_id::text = any($1::text[]) + where (visibility_code = 'public' + or corporate_entity_id::text = any($1::text[])) + and created_at <= $4 + and {_SOURCE_ELIGIBILITY} order by array_position($2::uuid[], post_id) nulls last, created_at desc, post_id desc limit $3 @@ -759,7 +801,7 @@ async def _serialize_chat( ) -> dict[str, Any] | None: """One stored exchange plus citation chips, or None when missing.""" header = await conn.fetchrow( - "select question_text, answer_text from post_chat_result " + "select question_text, answer_text, knowledge_cutoff from post_chat_result " "where post_id = $1 and question_norm = $2", post_id, question_norm, @@ -779,6 +821,7 @@ async def _serialize_chat( "question_text": header["question_text"], "answer_text": header["answer_text"], "cited_post_ids": cited_ids, + "_knowledge_cutoff": header.get("knowledge_cutoff"), "cited_posts": [ {"post_id": str(row["cited_post_id"]), "post_title": row["post_title"]} for row in cites @@ -816,23 +859,30 @@ async def persist_post_chat( question: str, answer_text: str, cited_post_ids: list[str] | tuple[str, ...], + *, + knowledge_cutoff: datetime | None = None, ) -> dict[str, Any]: """Replace the stored exchange for ``(post_id, question)`` and return it.""" norm = normalize_chat_question(question) if not norm: raise ValueError("question is empty after normalize") + cutoff = _ask_cutoff(knowledge_cutoff) + computed_at = max(datetime.now(timezone.utc), cutoff) await conn.execute( "delete from post_chat_result where post_id = $1 and question_norm = $2", post_id, norm, ) await conn.execute( - "insert into post_chat_result (post_id, question_norm, question_text, answer_text) " - "values ($1, $2, $3, $4)", + "insert into post_chat_result " + "(post_id, question_norm, question_text, answer_text, computed_at, knowledge_cutoff) " + "values ($1, $2, $3, $4, $5, $6)", post_id, norm, question.strip(), answer_text, + computed_at, + cutoff, ) seen: set[str] = set() ordinal = 0 diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 440732b63..023703fcc 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -110,6 +110,11 @@ / "migrations" / "0052_global_ask_context.sql" ) +_POST_CHAT_CUTOFF_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0053_post_chat_knowledge_cutoff.sql" +) _MAJOR_EVENT_ACTION_MIGRATION = ( Path(__file__).resolve().parents[2] / "migrations" / "0100_major_event_action.sql" ) @@ -225,6 +230,7 @@ def seeded_db(demo_analyst_token): cur.execute(_POST_CONTENT_QUEUE_MIGRATION.read_text()) cur.execute(_ORGANIZATION_CONTEXT_MIGRATION.read_text()) cur.execute(_GLOBAL_ASK_CONTEXT_MIGRATION.read_text()) + cur.execute(_POST_CHAT_CUTOFF_MIGRATION.read_text()) cur.execute(_MAJOR_EVENT_ACTION_MIGRATION.read_text()) cur.execute( "insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values " diff --git a/docker/postgres-init/migrate.sh b/docker/postgres-init/migrate.sh index 122523d18..af0fc9bba 100644 --- a/docker/postgres-init/migrate.sh +++ b/docker/postgres-init/migrate.sh @@ -18,7 +18,7 @@ for migration in /opt/lineageweave/migrations/*.sql; do migration_name=${migration##*/} case "$migration_name" in 0012_*|0013_*|0014_*|0015_*|0016_*|0017_*|0018_*|0019_*|0020_*|0021_*|0022_*|0023_*|0024_*|0025_*|0026_*|0027_*|0028_*|0029_*|0030_*|0031_*|0032_*|0033_*|0034_*|0035_*|0036_*|0037_*|0038_*|0039_*|0040_*|0041_*|0042_*|0043_*|0044_*|0045_*|0046_*|0047_*|0048_*|0049_*|0050_*) ;; - 0051_*|0052_*) ;; + 0051_*|0052_*|0053_*) ;; 0060_*|0100_*) ;; *) continue ;; esac diff --git a/docs/adr/0113-project-history-links-in-ask-surfaces.md b/docs/adr/0113-project-history-links-in-ask-surfaces.md new file mode 100644 index 000000000..b009fe847 --- /dev/null +++ b/docs/adr/0113-project-history-links-in-ask-surfaces.md @@ -0,0 +1,56 @@ +# ADR 0113: Reuse canonical project history in Ask surfaces + +- Status: Proposed +- Date: 2026-08-21 +- Depends on: ADR 0112 and the canonical Project history read model + +## Context + +Post-scoped Ask and Global Ask already cite authorized source posts, but they did not +connect those citations to the project lifecycle timeline shown in the product design. +The earlier orphaned stack attempted to solve this with another project-history flow. +That would create competing project identity, authorization, cutoff, classification, and +TEPP behavior. + +Persisted Ask prose introduces an additional security boundary: if a previously cited +post becomes hidden, deleted, draft, or otherwise ineligible, returning the old answer or +reusing it as conversation context can disclose facts no longer authorized. + +## Decision + +1. Ask responses expose structured project-history links derived only from cited post IDs. +2. Citation IDs are reauthorized with tenant ABAC, source publication eligibility, and the + answer knowledge cutoff before titles or project identities are returned. +3. Exact source project fields outrank semantic project candidates; inferred identities + remain labelled inferred. Links are bounded and deterministic. +4. Opening a link calls the canonical Project history endpoint with project key, answer + cutoff, and cited focus post. The established timeline and TEPP metadata are reused. +5. A persisted post answer is withheld in full when any citation is 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. +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. +8. Timeline or TEPP failure does not remove the answer; the Buyer receives an actionable + error and can still open the exact cited source post. + +## Consequences + +- Document reading, post Ask, Global Ask, and the dedicated Project history destination + share one authorization-first read model and one timeline component. +- Historical answers can disappear after permission or publication changes. This is an + intentional fail-closed property, not data loss from the evidence store. +- A session restart can lose conversational convenience, but prevents a compressed + summary from carrying hidden prose forward. +- Event order remains a temporal association and is not presented as causal inference. + +## Rejected alternatives + +- Parse project identities from answer prose. This is nondeterministic and ungrounded. +- Build a second project query or timeline inside Ask. This duplicates authority. +- Return a stored answer while merely hiding its citation chips. The prose may still leak + the hidden source. +- Keep a stale Global Ask summary and filter only new citations. The summary cannot be + safely decomposed after authorization changes. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 44fd113cd..8ee666385 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -308,3 +308,18 @@ runtime note into a shipped/live claim. that a preceding event is related in time, not that it caused the VOC. - The next stacked slice attaches this same canonical timeline and TEPP metadata to Global Ask and post-scoped Ask without re-retrieving hidden evidence. + +## Ask-to-project-history integration (2026-08-21) + +- Post-scoped Ask and Global Ask return structured project-history links only for exact + project identities on their currently authorized cited posts. +- Opening a link lazily calls the canonical Project history endpoint with the answer + knowledge cutoff and cited focus post; no second timeline, classifier, or TEPP query is + 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. +- 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 + and evidence navigation do not identify why a VOC occurred. diff --git a/frontend/package.json b/frontend/package.json index 4a61cd78c..bd8c9ff59 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.19.0", + "version": "2.20.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 51a442ecd..022709369 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -75,6 +75,7 @@ import { type PostLineage, type PostSummary, type PostSortOrder, + type ProjectHistoryLink, type RankingList, type PersonRoleHistoryEntry, type RelatedNode, @@ -90,6 +91,7 @@ import { LineageDag } from "./LineageDag"; import { PostBody } from "./PostBody"; import { decodeHtmlEntities } from "./postBodyDisplay"; import { FiveW1H } from "./components/FiveW1H"; +import { AskProjectHistoryLinks } from "./components/AskProjectHistoryLinks"; import { ProjectHistoryTimeline } from "./components/ProjectHistoryTimeline"; import { projectHistoryText, @@ -289,6 +291,9 @@ function ChatPanel({ answer_text: result.answer_text, cited_post_ids: result.cited_post_ids, cited_posts: result.cited_posts, + knowledge_cutoff: result.knowledge_cutoff, + project_histories: result.project_histories, + project_histories_truncated: result.project_histories_truncated, }; return [...prev.filter((row) => row.question_text !== next.question_text), next]; }); @@ -331,6 +336,12 @@ function ChatPanel({ exchanges[0].cited_posts?.[0]?.post_id ?? exchanges[0].cited_post_ids[0] } /> + ) : null} {nameFirstAsk && firstCitedTitle ? ( @@ -411,6 +422,12 @@ function ChatPanel({ citedPostIds={exchange.cited_post_ids} onOpenEvidence={setEvidencePostId} /> + ))} {answer && !exchanges.some((row) => row.answer_text === answer.answer_text) && ( @@ -421,6 +438,12 @@ function ChatPanel({ citedPostIds={answer.cited_post_ids} onOpenEvidence={setEvidencePostId} /> + )} {!nameFirstAsk && evidencePostId ? ( @@ -4707,6 +4730,12 @@ function AskAgentPanel({ window.sessionStorage.getItem("lineageweave.globalAskSessionId") ?? undefined, ); + function acceptAnswer(nextAnswer: AskAgentResponse) { + setAnswer(nextAnswer); + setSessionId(nextAnswer.session_id); + window.sessionStorage.setItem("lineageweave.globalAskSessionId", nextAnswer.session_id); + } + async function handleAsk() { const normalized = question.trim(); if (!normalized) return; @@ -4714,12 +4743,19 @@ function AskAgentPanel({ setError(null); setAnswer(null); try { - const nextAnswer = await askAgent(accessToken, normalized, sessionId); - setAnswer(nextAnswer); - setSessionId(nextAnswer.session_id); - window.sessionStorage.setItem("lineageweave.globalAskSessionId", nextAnswer.session_id); + acceptAnswer(await askAgent(accessToken, normalized, sessionId)); } catch (err) { - setAnswer(null); + if (err instanceof BackendError && err.status === 409 && sessionId) { + window.sessionStorage.removeItem("lineageweave.globalAskSessionId"); + setSessionId(undefined); + try { + acceptAnswer(await askAgent(accessToken, normalized)); + return; + } catch (retryError) { + setError(orchestratorUnavailableMessage(retryError, t("Ask Agent"))); + return; + } + } setError(orchestratorUnavailableMessage(err, t("Ask Agent"))); } finally { setAsking(false); @@ -4769,6 +4805,12 @@ function AskAgentPanel({ ) : null} + {answer.cited_posts && answer.cited_posts.length > 0 && ( <>

diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 5bbcdcd9b..963b81284 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -272,12 +272,24 @@ export interface CitedPostEvidence { facts: CitedPostEvidenceFact[]; } +export interface ProjectHistoryLink { + project_key: string; + project_name: string; + focus_post_id: string; + source_post_ids: string[]; + knowledge_cutoff: string; + truth_status_code: "observed" | "inferred"; +} + export interface ChatAnswer { post_id: string; answer_text: string; cited_post_ids: string[]; cited_posts?: CitedPostRef[]; source_post_ids: string[]; + knowledge_cutoff?: string; + project_histories?: ProjectHistoryLink[]; + project_histories_truncated?: boolean; } export interface ChatExchange { @@ -285,6 +297,9 @@ export interface ChatExchange { answer_text: string; cited_post_ids: string[]; cited_posts?: CitedPostRef[]; + knowledge_cutoff?: string; + project_histories?: ProjectHistoryLink[]; + project_histories_truncated?: boolean; } export interface ChatHistory { @@ -300,6 +315,9 @@ export interface AskAgentResponse { cited_post_evidence?: CitedPostEvidence[]; source_post_ids: string[]; timeline?: AskTimelineEntry[]; + knowledge_cutoff?: string; + project_histories?: ProjectHistoryLink[]; + project_histories_truncated?: boolean; next_action?: string; } diff --git a/frontend/src/components/AskProjectHistoryLinks.css b/frontend/src/components/AskProjectHistoryLinks.css new file mode 100644 index 000000000..8a491419d --- /dev/null +++ b/frontend/src/components/AskProjectHistoryLinks.css @@ -0,0 +1,36 @@ +.ask-project-history-links { + display: grid; + gap: 0.75rem; + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--border-color, #d7dce5); +} + +.ask-project-history-links > h4, +.ask-project-history-link p { + margin: 0; +} + +.ask-project-history-link { + display: grid; + gap: 0.625rem; + padding: 0.75rem; + border: 1px solid var(--border-color, #d7dce5); + border-radius: 0.75rem; + background: var(--surface-color, #fff); +} + +.ask-project-history-link > div:first-child { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; +} + +.ask-project-history-link > button { + justify-self: start; +} + +.ask-project-history-link [hidden] { + display: none; +} diff --git a/frontend/src/components/AskProjectHistoryLinks.stories.tsx b/frontend/src/components/AskProjectHistoryLinks.stories.tsx new file mode 100644 index 000000000..b3b25fd16 --- /dev/null +++ b/frontend/src/components/AskProjectHistoryLinks.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { AskProjectHistoryLinks } from "./AskProjectHistoryLinks"; + +const meta = { + title: "Buyer/Ask Project History Links", + component: AskProjectHistoryLinks, + args: { + accessToken: "storybook-token", + links: [ + { + project_key: "P-100", + project_name: "Synthetic renewal", + focus_post_id: "post-voc", + source_post_ids: ["post-spec", "post-voc"], + knowledge_cutoff: "2026-08-20T12:00:00Z", + truth_status_code: "observed", + }, + ], + truncated: false, + onOpenPost: () => undefined, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const ObservedProject: Story = {}; + +export const InferredAndTruncated: Story = { + args: { + links: [ + { + project_key: "semantic-project", + project_name: "Semantic project candidate", + focus_post_id: "post-candidate", + source_post_ids: ["post-candidate"], + knowledge_cutoff: "2026-08-20T12:00:00Z", + truth_status_code: "inferred", + }, + ], + truncated: true, + }, +}; diff --git a/frontend/src/components/AskProjectHistoryLinks.tsx b/frontend/src/components/AskProjectHistoryLinks.tsx new file mode 100644 index 000000000..99d144b43 --- /dev/null +++ b/frontend/src/components/AskProjectHistoryLinks.tsx @@ -0,0 +1,177 @@ +import { useEffect, useId, useState } from "react"; + +import { fetchProjectHistory, type ProjectHistoryLink } from "../api"; +import type { Locale } from "../i18n"; +import { useLocale } from "../i18n"; +import { projectHistoryText, type ProjectHistoryProjection } from "../projectHistory"; +import { ProjectHistoryTimeline } from "./ProjectHistoryTimeline"; +import "./AskProjectHistoryLinks.css"; + +interface Copy { + heading: string; + boundary: string; + open: (name: string) => string; + close: (name: string) => string; + loading: string; + truncated: string; + observed: string; + inferred: string; +} + +const COPY: Record = { + en: { + heading: "Project histories cited by this answer", + boundary: "Each timeline is rebuilt from currently authorized evidence at the answer cutoff.", + open: (name) => `Open project history: ${name}`, + close: (name) => `Close project history: ${name}`, + loading: "Loading cited project history...", + truncated: "Additional cited projects are not shown. Open the cited source records to inspect their project evidence.", + observed: "Observed project identity", + inferred: "Inferred project identity", + }, + ko: { + heading: "이 답변이 인용한 프로젝트 이력", + boundary: "각 타임라인은 답변 기준 시각과 현재 권한을 통과한 근거로 다시 구성됩니다.", + open: (name) => `프로젝트 이력 열기: ${name}`, + close: (name) => `프로젝트 이력 닫기: ${name}`, + loading: "인용된 프로젝트 이력을 불러오는 중...", + truncated: "일부 추가 프로젝트는 표시하지 않습니다. 인용된 원천 기록에서 프로젝트 근거를 확인하세요.", + observed: "관찰된 프로젝트 식별자", + inferred: "추론된 프로젝트 식별자", + }, + zh: { + heading: "此回答引用的项目历史", + boundary: "每条时间线都根据回答截止时间和当前授权证据重新构建。", + open: (name) => `打开项目历史:${name}`, + close: (name) => `关闭项目历史:${name}`, + loading: "正在加载引用的项目历史...", + truncated: "还有引用项目未显示。请打开引用的源记录检查其项目依据。", + observed: "已观察的项目身份", + inferred: "已推断的项目身份", + }, + ja: { + heading: "この回答が引用したプロジェクト履歴", + boundary: "各タイムラインは回答時点と現在の権限を通過した根拠から再構成されます。", + open: (name) => `プロジェクト履歴を開く: ${name}`, + close: (name) => `プロジェクト履歴を閉じる: ${name}`, + loading: "引用されたプロジェクト履歴を読み込み中...", + truncated: "追加の引用プロジェクトは表示されていません。引用元レコードでプロジェクト根拠を確認してください。", + observed: "観察されたプロジェクト識別子", + inferred: "推論されたプロジェクト識別子", + }, + vi: { + heading: "Lịch sử dự án được câu trả lời này trích dẫn", + boundary: "Mỗi dòng thời gian được dựng lại từ bằng chứng hiện được cấp quyền tại thời điểm cắt của câu trả lời.", + open: (name) => `Mở lịch sử dự án: ${name}`, + close: (name) => `Đóng lịch sử dự án: ${name}`, + loading: "Đang tải lịch sử dự án được trích dẫn...", + truncated: "Một số dự án được trích dẫn chưa được hiển thị. Hãy mở bản ghi nguồn để kiểm tra bằng chứng dự án.", + observed: "Danh tính dự án được quan sát", + inferred: "Danh tính dự án được suy luận", + }, +}; + +function ProjectHistoryDisclosure({ + accessToken, + link, + onOpenPost, +}: { + accessToken: string; + link: ProjectHistoryLink; + onOpenPost: (postId: string) => void; +}) { + const locale = useLocale(); + const copy = COPY[locale]; + const regionId = useId(); + const [opened, setOpened] = useState(false); + const [loading, setLoading] = useState(false); + const [projection, setProjection] = useState(null); + const [error, setError] = useState(false); + + useEffect(() => { + setOpened(false); + setLoading(false); + setProjection(null); + setError(false); + }, [link.project_key, link.focus_post_id, link.knowledge_cutoff]); + + function toggle() { + if (opened) { + setOpened(false); + return; + } + setOpened(true); + if (projection || loading) return; + setLoading(true); + setError(false); + fetchProjectHistory( + accessToken, + link.project_key, + link.knowledge_cutoff, + link.focus_post_id, + ) + .then((result) => { + setProjection(result); + setLoading(false); + }) + .catch(() => { + setError(true); + setLoading(false); + }); + } + + return ( +

+
+ {link.project_name} + + {link.truth_status_code === "observed" ? copy.observed : copy.inferred} + +
+ + +
+ ); +} + +export function AskProjectHistoryLinks({ + accessToken, + links, + truncated, + onOpenPost, +}: { + accessToken: string; + links: ProjectHistoryLink[]; + truncated: boolean; + onOpenPost: (postId: string) => void; +}) { + const locale = useLocale(); + const copy = COPY[locale]; + const headingId = useId(); + if (links.length === 0 && !truncated) return null; + return ( +
+

{copy.heading}

+

{copy.boundary}

+ {links.map((link) => ( + + ))} + {truncated ?

{copy.truncated}

: null} +
+ ); +} diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index bdfb418d0..2cb406a3f 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "2.19.0" +__version__ = "2.20.0" diff --git a/migrations/0053_post_chat_knowledge_cutoff.sql b/migrations/0053_post_chat_knowledge_cutoff.sql new file mode 100644 index 000000000..00d05706d --- /dev/null +++ b/migrations/0053_post_chat_knowledge_cutoff.sql @@ -0,0 +1,28 @@ +alter table post_chat_result + add column if not exists knowledge_cutoff timestamptz; + +update post_chat_result + set knowledge_cutoff = computed_at + where knowledge_cutoff is null; + +alter table post_chat_result + alter column knowledge_cutoff set default now(), + alter column knowledge_cutoff set not null; + +do $$ +begin + if not exists ( + select 1 + from pg_constraint + where conname = 'post_chat_result_knowledge_cutoff_check' + and conrelid = 'post_chat_result'::regclass + ) then + alter table post_chat_result + add constraint post_chat_result_knowledge_cutoff_check + check (knowledge_cutoff <= computed_at); + end if; +end +$$; + +comment on column post_chat_result.knowledge_cutoff is + 'Maximum source availability time used to compute this persisted answer.'; diff --git a/migrations/rollback/0053_post_chat_knowledge_cutoff.sql b/migrations/rollback/0053_post_chat_knowledge_cutoff.sql new file mode 100644 index 000000000..8980fe69f --- /dev/null +++ b/migrations/rollback/0053_post_chat_knowledge_cutoff.sql @@ -0,0 +1,5 @@ +alter table post_chat_result + drop constraint if exists post_chat_result_knowledge_cutoff_check; + +alter table post_chat_result + drop column if exists knowledge_cutoff; diff --git a/pyproject.toml b/pyproject.toml index ec0d44278..8d34399ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.19.0" +version = "2.20.0" 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 782e3dd62..9a98229e8 100644 --- a/tests/test_ask_project_history.py +++ b/tests/test_ask_project_history.py @@ -126,6 +126,18 @@ def test_authorized_ask_evidence_fails_closed_when_any_citation_is_hidden() -> N assert result.project_histories == () +def test_authorized_ask_evidence_rejects_non_uuid_citations_before_sql() -> None: + with pytest.raises(ValueError, match="UUIDs"): + asyncio.run( + read_authorized_ask_evidence( + _EvidenceConnection([]), + cited_post_ids=["not-a-uuid"], + corporate_entity_ids=["tenant-a"], + knowledge_cutoff=CUTOFF, + ) + ) + + def test_global_ask_session_reauthorizes_every_persisted_citation() -> None: class SessionConnection: def __init__(self) -> None: @@ -284,3 +296,48 @@ async def unauthorized(*_args, **_kwargs): assert exc_info.value.status_code == 409 assert "start a new session" in str(exc_info.value.detail).lower() + + +def test_global_ask_hides_unexpected_provider_errors(monkeypatch) -> None: + class ProviderFailure: + available = True + + def answer(self, *args, **kwargs): + del args, kwargs + raise RuntimeError("raw provider trace must not reach the buyer") + + async def ensure_session(*_args, **_kwargs): + return "00000000-0000-4000-8000-000000000010" + + async def authorized(*_args, **_kwargs): + return True + + async def load_context(*_args, **_kwargs): + return SimpleNamespace( + session_id="00000000-0000-4000-8000-000000000010", + summary="", + recent_turns=(), + compress_turns=(), + ) + + async def sources(*_args, **_kwargs): + return [object()] + + monkeypatch.setattr(main, "_post_chat_client", lambda: ProviderFailure()) + monkeypatch.setattr(main, "ensure_global_ask_session", ensure_session) + monkeypatch.setattr(main, "global_ask_session_citations_authorized", authorized) + monkeypatch.setattr(main, "load_global_ask_context", load_context) + monkeypatch.setattr(main, "gather_global_chat_sources", sources) + + with pytest.raises(HTTPException) as exc_info: + asyncio.run( + main.ask_agent( + request=main.GlobalAskRequest(question="What happened?"), + account=_account(), + pool=_Pool(object()), + valkey=SimpleNamespace(), + ) + ) + + assert exc_info.value.status_code == 503 + assert "raw provider trace" not in str(exc_info.value.detail) diff --git a/tools/apply_project_history_ask_surfaces.py b/tools/apply_project_history_ask_surfaces.py deleted file mode 100644 index 714d6567e..000000000 --- a/tools/apply_project_history_ask_surfaces.py +++ /dev/null @@ -1,1447 +0,0 @@ -"""Apply authorization-safe project-history links to both Ask surfaces.""" - -from __future__ import annotations - -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] - - -def replace_once(path: str, old: str, new: str) -> None: - """Replace one exact source fragment and fail when branch context drifted.""" - - file_path = ROOT / path - text = file_path.read_text(encoding="utf-8") - count = text.count(old) - if count != 1: - raise RuntimeError(f"expected one anchor in {path}, found {count}") - file_path.write_text(text.replace(old, new, 1), encoding="utf-8") - - -def write_new(path: str, content: str) -> None: - """Create one product file and reject accidental overwrite.""" - - file_path = ROOT / path - if file_path.exists(): - raise RuntimeError(f"refusing to overwrite existing {path}") - file_path.parent.mkdir(parents=True, exist_ok=True) - file_path.write_text(content.strip() + "\n", encoding="utf-8") - - -def append_once(path: str, marker: str, content: str) -> None: - """Append a documented section only when it is not already present.""" - - file_path = ROOT / path - text = file_path.read_text(encoding="utf-8") - if marker in text: - return - file_path.write_text(text.rstrip() + "\n\n" + content.strip() + "\n", encoding="utf-8") - - -def create_backend_projection() -> None: - """Create the authorization-first citation-to-project projection.""" - - write_new( - "backend/app/ask_project_history.py", - r''' -"""Authorization-safe project-history links for Ask responses. - -The module accepts only citation identities already produced by post-scoped or -Global Ask. It re-applies current tenant visibility, source publication -eligibility, and the answer knowledge cutoff before returning citation labels or -project identities. A missing citation fails the whole persisted answer closed; -answer prose cannot be safely decomposed after one of its sources becomes -unauthorized. -""" - -from __future__ import annotations - -from collections.abc import Iterable, Mapping, Sequence -from dataclasses import dataclass -from datetime import datetime, timezone -from typing import Any, Protocol - -from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL -from lineageweave.project_history import normalize_project_key - -ASK_CITATION_LIMIT = 64 -ASK_PROJECT_LIMIT = 8 -GLOBAL_ASK_SESSION_CITATION_LIMIT = 256 - -_ELIGIBILITY = SOURCE_POST_ELIGIBILITY_SQL.format(alias="post") -_CITATION_PROJECT_SQL = f""" -with visible_citation as materialized ( - select post.post_id::text as post_id, - post.post_title, - array_position($1::uuid[], post.post_id) as citation_ordinal, - nullif(btrim(post.source_project_code), '') as source_project_code, - nullif(btrim(post.source_project_name), '') as source_project_name - from source_post post - where post.post_id = any($1::uuid[]) - and (post.visibility_code = 'public' - or post.corporate_entity_id::text = any($2::text[])) - and post.created_at <= $3 - and {_ELIGIBILITY} -), project_evidence as ( - select visible_citation.post_id, - coalesce(visible_citation.source_project_code, - visible_citation.source_project_name) as project_key, - coalesce(visible_citation.source_project_name, - visible_citation.source_project_code) as project_name, - 'observed'::text as truth_status_code, - 0::integer as truth_order - from visible_citation - where coalesce(visible_citation.source_project_code, - visible_citation.source_project_name) is not null - union all - select visible_citation.post_id, - coalesce(nullif(btrim(mention.project_key), ''), - nullif(btrim(mention.project_name), '')) as project_key, - coalesce(nullif(btrim(mention.project_name), ''), - nullif(btrim(mention.project_key), '')) as project_name, - 'inferred'::text as truth_status_code, - 1::integer as truth_order - from visible_citation - join post_project_mention mention - on mention.post_id::text = visible_citation.post_id - where coalesce(nullif(btrim(mention.project_key), ''), - nullif(btrim(mention.project_name), '')) is not null -) -select visible_citation.post_id, - visible_citation.post_title, - visible_citation.citation_ordinal, - project_evidence.project_key, - project_evidence.project_name, - project_evidence.truth_status_code, - project_evidence.truth_order - from visible_citation - left join project_evidence - on project_evidence.post_id = visible_citation.post_id - order by visible_citation.citation_ordinal, - project_evidence.truth_order nulls last, - project_evidence.project_name nulls last, - project_evidence.project_key nulls last -""" -_SESSION_CITATION_SQL = """ -select distinct cited_post_id::text as cited_post_id - from global_ask_turn_citation - where global_ask_session_id = $1 - order by cited_post_id::text - limit $2 -""" - - -class AskEvidenceConnection(Protocol): - """Minimal async query port used by this read projection.""" - - async def fetch(self, query: str, *args: object) -> Sequence[Mapping[str, Any]]: - """Execute a bounded read query.""" - - raise NotImplementedError - - -@dataclass(frozen=True) -class AskEvidenceProjection: - """Currently authorized citation labels and exact project links.""" - - all_citations_visible: bool - cited_posts: tuple[dict[str, str], ...] - project_histories: tuple[dict[str, Any], ...] - project_histories_truncated: bool - knowledge_cutoff: str - - def response_fields(self) -> dict[str, Any]: - """Return the public response fields shared by both Ask surfaces.""" - - return { - "cited_posts": list(self.cited_posts), - "project_histories": list(self.project_histories), - "project_histories_truncated": self.project_histories_truncated, - "knowledge_cutoff": self.knowledge_cutoff, - } - - -def ask_knowledge_cutoff(value: object | None = None) -> datetime: - """Return an offset-aware UTC cutoff from a datetime or ISO text.""" - - if value is None: - return datetime.now(timezone.utc) - if isinstance(value, datetime): - parsed = value - elif isinstance(value, str) and value.strip(): - try: - parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00")) - except ValueError as exc: - raise ValueError("knowledge cutoff must be ISO-8601") from exc - else: - raise ValueError("knowledge cutoff must be a datetime or ISO-8601 text") - if parsed.tzinfo is None or parsed.utcoffset() is None: - raise ValueError("knowledge cutoff must include an offset") - return parsed.astimezone(timezone.utc) - - -def _cutoff_text(value: datetime) -> str: - """Serialize one validated cutoff as canonical UTC RFC 3339 text.""" - - return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") - - -def _bounded_citations( - cited_post_ids: Iterable[str], *, maximum_citations: int -) -> tuple[str, ...]: - """Return unique citation IDs without silently truncating evidence.""" - - citations = tuple(dict.fromkeys(str(value) for value in cited_post_ids if str(value))) - if len(citations) > maximum_citations: - raise ValueError("citation count exceeds the supported bound") - return citations - - -async def read_authorized_ask_evidence( - conn: AskEvidenceConnection, - *, - cited_post_ids: Iterable[str], - corporate_entity_ids: Iterable[str], - knowledge_cutoff: datetime | str, - maximum_citations: int = ASK_CITATION_LIMIT, - maximum_projects: int = ASK_PROJECT_LIMIT, -) -> AskEvidenceProjection: - """Reauthorize citations and derive bounded exact-project history links. - - A citation is visible only when its current source row passes tenant ABAC, - publication eligibility, and the answer cutoff. If any citation is absent, - project links are withheld and callers must not reuse the persisted answer. - """ - - cutoff = ask_knowledge_cutoff(knowledge_cutoff) - cutoff_text = _cutoff_text(cutoff) - citations = _bounded_citations( - cited_post_ids, - maximum_citations=maximum_citations, - ) - if not citations: - return AskEvidenceProjection(True, (), (), False, cutoff_text) - rows = list( - await conn.fetch( - _CITATION_PROJECT_SQL, - list(citations), - list(corporate_entity_ids), - cutoff, - ) - ) - citation_order = {post_id: index for index, post_id in enumerate(citations, start=1)} - visible_titles: dict[str, str] = {} - for row in rows: - post_id = str(row["post_id"]) - if post_id in citation_order: - visible_titles.setdefault(post_id, str(row["post_title"])) - all_visible = set(visible_titles) == set(citations) - cited_posts = tuple( - {"post_id": post_id, "post_title": visible_titles[post_id]} - for post_id in citations - if post_id in visible_titles - ) - if not all_visible: - return AskEvidenceProjection(False, cited_posts, (), False, cutoff_text) - - evidence_rows = sorted( - ( - row - for row in rows - if row.get("project_key") is not None and row.get("project_name") is not None - ), - key=lambda row: ( - citation_order[str(row["post_id"])], - int(row.get("truth_order") or 0), - str(row["project_name"]), - str(row["project_key"]), - ), - ) - grouped: dict[str, dict[str, Any]] = {} - for row in evidence_rows: - project_key = str(row["project_key"]).strip() - project_name = str(row["project_name"]).strip() - try: - normalized_key = normalize_project_key(project_key) - except ValueError: - continue - post_id = str(row["post_id"]) - truth_order = int(row.get("truth_order") or 0) - group = grouped.get(normalized_key) - if group is None: - grouped[normalized_key] = { - "project_key": project_key, - "project_name": project_name, - "focus_post_id": post_id, - "source_post_ids": [post_id], - "knowledge_cutoff": cutoff_text, - "truth_status_code": str(row["truth_status_code"]), - "truth_order": truth_order, - "first_citation_ordinal": citation_order[post_id], - } - continue - if post_id not in group["source_post_ids"]: - group["source_post_ids"].append(post_id) - if truth_order < group["truth_order"]: - group["project_key"] = project_key - group["project_name"] = project_name - group["truth_status_code"] = str(row["truth_status_code"]) - group["truth_order"] = truth_order - - ordered = sorted( - grouped.values(), - key=lambda group: ( - int(group["first_citation_ordinal"]), - str(group["project_name"]), - str(group["project_key"]), - ), - ) - truncated = len(ordered) > maximum_projects - public_links: list[dict[str, Any]] = [] - for group in ordered[:maximum_projects]: - public_links.append( - { - key: value - for key, value in group.items() - if key not in {"truth_order", "first_citation_ordinal"} - } - ) - return AskEvidenceProjection( - True, - cited_posts, - tuple(public_links), - truncated, - cutoff_text, - ) - - -async def global_ask_session_citations_authorized( - conn: AskEvidenceConnection, - *, - session_id: str, - corporate_entity_ids: Iterable[str], - knowledge_cutoff: datetime | str, -) -> bool: - """Return whether every citation ever reused by a session is still visible.""" - - rows = list( - await conn.fetch( - _SESSION_CITATION_SQL, - session_id, - GLOBAL_ASK_SESSION_CITATION_LIMIT + 1, - ) - ) - if len(rows) > GLOBAL_ASK_SESSION_CITATION_LIMIT: - return False - citations = [str(row["cited_post_id"]) for row in rows] - result = await read_authorized_ask_evidence( - conn, - cited_post_ids=citations, - corporate_entity_ids=corporate_entity_ids, - knowledge_cutoff=knowledge_cutoff, - maximum_citations=GLOBAL_ASK_SESSION_CITATION_LIMIT, - maximum_projects=0, - ) - return result.all_citations_visible -''', - ) - - -def patch_post_chat_ingestion() -> None: - """Apply cutoff and publication eligibility to both Ask retrieval paths.""" - - replace_once( - "backend/app/post_chat_ingestion.py", - "from dataclasses import dataclass\nfrom typing import Any, Callable, Iterable\n", - "from dataclasses import dataclass\nfrom datetime import datetime, timezone\n" - "from typing import Any, Callable, Iterable\n", - ) - replace_once( - "backend/app/post_chat_ingestion.py", - "from .knowledge_graph import hydrate_related_nodes, load_visible_subgraph\n", - "from .knowledge_graph import hydrate_related_nodes, load_visible_subgraph\n" - "from .post_eligibility import SOURCE_POST_ELIGIBILITY_SQL\n", - ) - replace_once( - "backend/app/post_chat_ingestion.py", - "_POST_CHAT_SOURCE_LIMIT = 8\n_POST_CHAT_CANDIDATE_LIMIT = 32\n", - "_POST_CHAT_SOURCE_LIMIT = 8\n_POST_CHAT_CANDIDATE_LIMIT = 32\n" - "_SOURCE_ELIGIBILITY = SOURCE_POST_ELIGIBILITY_SQL.format(alias=\"source_post\")\n\n\n" - "def _ask_cutoff(value: datetime | None) -> datetime:\n" - " \"\"\"Return an aware UTC cutoff for one Ask retrieval.\"\"\"\n\n" - " cutoff = value or datetime.now(timezone.utc)\n" - " if cutoff.tzinfo is None or cutoff.utcoffset() is None:\n" - " raise ValueError(\"knowledge_cutoff must include an offset\")\n" - " return cutoff.astimezone(timezone.utc)\n", - ) - replace_once( - "backend/app/post_chat_ingestion.py", - """async def gather_chat_sources( - conn: asyncpg.Connection, - post_id: str, - can_see_post: Callable[[asyncpg.Record], bool], - vision_client: ImageContentClient | None = None, -) -> list[ChatSourceDocument]: -""", - """async def gather_chat_sources( - conn: asyncpg.Connection, - post_id: str, - can_see_post: Callable[[asyncpg.Record], bool], - vision_client: ImageContentClient | None = None, - *, - knowledge_cutoff: datetime | None = None, -) -> list[ChatSourceDocument]: -""", - ) - replace_once( - "backend/app/post_chat_ingestion.py", - """ if vision_client is None: - vision_client = NullImageContentClient() - - this_post = await conn.fetchrow( - "select post_id, post_title, post_body, source_system_code, source_record_key, " -""", - """ if vision_client is None: - vision_client = NullImageContentClient() - cutoff = _ask_cutoff(knowledge_cutoff) - - this_post = await conn.fetchrow( - "select post_id, post_title, post_body, created_at, source_system_code, source_record_key, " -""", - ) - replace_once( - "backend/app/post_chat_ingestion.py", - ' "source_project_name from source_post where post_id = $1",\n post_id,\n', - ' f"source_project_name from source_post where post_id = $1 "\n' - ' f"and created_at <= $2 and {_SOURCE_ELIGIBILITY}",\n' - ' post_id,\n cutoff,\n', - ) - replace_once( - "backend/app/post_chat_ingestion.py", - ' "source_project_code, source_project_name "\n' - ' "from source_post where post_id = any($1::uuid[]) "\n' - ' "order by array_position($1::uuid[], post_id) limit $2",\n' - ' candidate_ids,\n _POST_CHAT_CANDIDATE_LIMIT,\n', - ' "source_project_code, source_project_name, created_at "\n' - ' f"from source_post where post_id = any($1::uuid[]) "\n' - ' f"and created_at <= $3 and {_SOURCE_ELIGIBILITY} "\n' - ' "order by array_position($1::uuid[], post_id) limit $2",\n' - ' candidate_ids,\n _POST_CHAT_CANDIDATE_LIMIT,\n cutoff,\n', - ) - replace_once( - "backend/app/post_chat_ingestion.py", - """ question: str | None = None, - limit: int = 4, -) -> list[ChatSourceDocument]: -""", - """ question: str | None = None, - limit: int = 4, - knowledge_cutoff: datetime | None = None, -) -> list[ChatSourceDocument]: -""", - ) - replace_once( - "backend/app/post_chat_ingestion.py", - """ if vision_client is None: - vision_client = NullImageContentClient() - search_terms = tuple( -""", - """ if vision_client is None: - vision_client = NullImageContentClient() - cutoff = _ask_cutoff(knowledge_cutoff) - authorized_entity_ids = list(authorized_corporate_entity_ids) - search_terms = tuple( -""", - ) - eligibility = "{_SOURCE_ELIGIBILITY}" - replace_once( - "backend/app/post_chat_ingestion.py", - """ (select post_id, created_at, 'title' as matched_in - from source_post - where post_title ilike '%' || $1 || '%' - limit 32) -""", - f""" (select post_id, created_at, 'title' as matched_in - from source_post - where (visibility_code = 'public' - or corporate_entity_id::text = any($2::text[])) - and created_at <= $3 - and {eligibility} - and post_title ilike '%' || $1 || '%' - limit 32) -""", - ) - replace_once( - "backend/app/post_chat_ingestion.py", - """ (select post_id, created_at, 'body' as matched_in - from source_post - where lower(left(source_post_search_text(post_body), 16384)) - like '%' || lower($1) || '%' - limit 32) -""", - f""" (select post_id, created_at, 'body' as matched_in - from source_post - where (visibility_code = 'public' - or corporate_entity_id::text = any($2::text[])) - and created_at <= $3 - and {eligibility} - and lower(left(source_post_search_text(post_body), 16384)) - like '%' || lower($1) || '%' - limit 32) -""", - ) - replace_once( - "backend/app/post_chat_ingestion.py", - """ (select post_id, created_at, 'body' as matched_in - from source_post - where to_tsvector('simple', source_post_search_text(post_body)) - @@ plainto_tsquery('simple', $1) - limit 32) -""", - f""" (select post_id, created_at, 'body' as matched_in - from source_post - where (visibility_code = 'public' - or corporate_entity_id::text = any($2::text[])) - and created_at <= $3 - and {eligibility} - and to_tsvector('simple', source_post_search_text(post_body)) - @@ plainto_tsquery('simple', $1) - limit 32) -""", - ) - replace_once( - "backend/app/post_chat_ingestion.py", - """ (select post_id, created_at, 'source_field' as matched_in - from source_post - where concat_ws(' ', source_system_code, source_record_key, -""", - f""" (select post_id, created_at, 'source_field' as matched_in - from source_post - where (visibility_code = 'public' - or corporate_entity_id::text = any($2::text[])) - and created_at <= $3 - and {eligibility} - and concat_ws(' ', source_system_code, source_record_key, -""", - ) - replace_once( - "backend/app/post_chat_ingestion.py", - """ term, - ) -""", - """ term, - authorized_entity_ids, - cutoff, - ) -""", - ) - replace_once( - "backend/app/post_chat_ingestion.py", - """ where visibility_code = 'public' - or corporate_entity_id::text = any($1::text[]) - order by array_position($2::uuid[], post_id) nulls last, - created_at desc, post_id desc - limit $3 - """, - list(authorized_corporate_entity_ids), - candidate_ids, - limit, -""", - f""" where (visibility_code = 'public' - or corporate_entity_id::text = any($1::text[])) - and created_at <= $4 - and {eligibility} - order by array_position($2::uuid[], post_id) nulls last, - created_at desc, post_id desc - limit $3 - """, - authorized_entity_ids, - candidate_ids, - limit, - cutoff, -""", - ) - replace_once( - "backend/app/post_chat_ingestion.py", - ' "select question_text, answer_text from post_chat_result "\n', - ' "select question_text, answer_text, computed_at from post_chat_result "\n', - ) - replace_once( - "backend/app/post_chat_ingestion.py", - ' "cited_posts": [\n', - ' "_knowledge_cutoff": header.get("computed_at"),\n "cited_posts": [\n', - ) - - -def patch_main_routes() -> None: - """Attach the reauthorized project links to stored and live Ask responses.""" - - replace_once( - "backend/app/main.py", - "from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL\n", - "from backend.app.ask_project_history import (\n" - " ask_knowledge_cutoff,\n" - " global_ask_session_citations_authorized,\n" - " read_authorized_ask_evidence,\n" - ")\n" - "from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL\n", - ) - old_read = ''' await _load_visible_post(post_id, account, pool) - async with pool.acquire() as conn: - exchanges = await fetch_persisted_chats(conn, post_id) - return {"post_id": post_id, "exchanges": exchanges} -''' - new_read = ''' await _load_visible_post(post_id, account, pool) - authorized_exchanges: list[dict[str, Any]] = [] - async with pool.acquire() as conn: - exchanges = await fetch_persisted_chats(conn, post_id) - for exchange in exchanges: - cutoff = ask_knowledge_cutoff(exchange.get("_knowledge_cutoff")) - evidence = await read_authorized_ask_evidence( - conn, - cited_post_ids=exchange["cited_post_ids"], - corporate_entity_ids=account.corporate_entity_ids, - knowledge_cutoff=cutoff, - ) - if not evidence.all_citations_visible: - continue - public_exchange = { - key: value for key, value in exchange.items() if not key.startswith("_") - } - public_exchange.update(evidence.response_fields()) - authorized_exchanges.append(public_exchange) - return {"post_id": post_id, "exchanges": authorized_exchanges} -''' - replace_once("backend/app/main.py", old_read, new_read) - replace_once( - "backend/app/main.py", - """ post = await _load_visible_post(post_id, account, pool) - post_metadata = build_post_llm_metadata(post_id, post) - async with pool.acquire() as conn: - stored = await fetch_persisted_chat(conn, post_id, question) - if stored is not None: - source_ids = [post_id] - source_ids.extend(cid for cid in stored["cited_post_ids"] if cid != post_id) - return { - "post_id": post_id, - "answer_text": stored["answer_text"], - "cited_post_ids": stored["cited_post_ids"], - "cited_posts": stored["cited_posts"], - "source_post_ids": source_ids, - } - with use_llm_metadata(post_metadata): -""", - """ post = await _load_visible_post(post_id, account, pool) - post_metadata = build_post_llm_metadata(post_id, post) - knowledge_cutoff = ask_knowledge_cutoff() - async with pool.acquire() as conn: - stored = await fetch_persisted_chat(conn, post_id, question) - if stored is not None: - stored_cutoff = ask_knowledge_cutoff(stored.get("_knowledge_cutoff")) - stored_evidence = await read_authorized_ask_evidence( - conn, - cited_post_ids=stored["cited_post_ids"], - corporate_entity_ids=account.corporate_entity_ids, - knowledge_cutoff=stored_cutoff, - ) - if stored_evidence.all_citations_visible: - source_ids = list( - dict.fromkeys([post_id, *stored["cited_post_ids"]]) - ) - return { - "post_id": post_id, - "answer_text": stored["answer_text"], - "cited_post_ids": stored["cited_post_ids"], - "source_post_ids": source_ids, - **stored_evidence.response_fields(), - } - with use_llm_metadata(post_metadata): -""", - ) - replace_once( - "backend/app/main.py", - """ sources = await gather_chat_sources( - conn, post_id, lambda row: _can_see_post(account, row), vision_client=_vision_client() - ) -""", - """ sources = await gather_chat_sources( - conn, - post_id, - lambda row: _can_see_post(account, row), - vision_client=_vision_client(), - knowledge_cutoff=knowledge_cutoff, - ) -""", - ) - replace_once( - "backend/app/main.py", - """ async with pool.acquire() as conn: - await persist_post_chat(conn, post_id, question, answer.answer_text, cited_ids) -""", - """ async with pool.acquire() as conn: - await persist_post_chat(conn, post_id, question, answer.answer_text, cited_ids) - answer_evidence = await read_authorized_ask_evidence( - conn, - cited_post_ids=cited_ids, - corporate_entity_ids=account.corporate_entity_ids, - knowledge_cutoff=knowledge_cutoff, - ) - if not answer_evidence.all_citations_visible: - raise HTTPException( - status.HTTP_503_SERVICE_UNAVAILABLE, - "Post chat evidence changed before the answer could be returned", - ) -""", - ) - replace_once( - "backend/app/main.py", - ' "cited_posts": cited_post_summaries(sources, cited_ids),\n' - ' "source_post_ids": [source.post_id for source in sources],\n', - ' "source_post_ids": [source.post_id for source in sources],\n' - ' **answer_evidence.response_fields(),\n', - ) - replace_once( - "backend/app/main.py", - """ client = _post_chat_client() - if not client.available: -""", - """ knowledge_cutoff = ask_knowledge_cutoff() - client = _post_chat_client() - if not client.available: -""", - ) - replace_once( - "backend/app/main.py", - """ conversation = await load_global_ask_context(conn, session_id) - sources = await gather_global_chat_sources( -""", - """ if not await global_ask_session_citations_authorized( - conn, - session_id=session_id, - corporate_entity_ids=account.corporate_entity_ids, - knowledge_cutoff=knowledge_cutoff, - ): - raise HTTPException( - status.HTTP_409_CONFLICT, - "Global Ask session evidence is no longer authorized; start a new session", - ) - conversation = await load_global_ask_context(conn, session_id) - sources = await gather_global_chat_sources( -""", - ) - replace_once( - "backend/app/main.py", - """ question=question, - ) -""", - """ question=question, - knowledge_cutoff=knowledge_cutoff, - ) -""", - ) - replace_once( - "backend/app/main.py", - ' "timeline": [],\n "next_action": "No authorized source posts are available for this question.",\n', - ' "timeline": [],\n "project_histories": [],\n' - ' "project_histories_truncated": False,\n' - ' "knowledge_cutoff": knowledge_cutoff.isoformat().replace("+00:00", "Z"),\n' - ' "next_action": "No authorized source posts are available for this question.",\n', - ) - replace_once( - "backend/app/main.py", - """ async with pool.acquire() as conn: - await persist_global_ask_turn( - conn, - conversation.session_id, - question, - answer.answer_text, - cited_ids, - ) -""", - """ async with pool.acquire() as conn: - await persist_global_ask_turn( - conn, - conversation.session_id, - question, - answer.answer_text, - cited_ids, - ) - answer_evidence = await read_authorized_ask_evidence( - conn, - cited_post_ids=cited_ids, - corporate_entity_ids=account.corporate_entity_ids, - knowledge_cutoff=knowledge_cutoff, - ) - if not answer_evidence.all_citations_visible: - raise HTTPException( - status.HTTP_503_SERVICE_UNAVAILABLE, - "Global Ask evidence changed before the answer could be returned", - ) -""", - ) - replace_once( - "backend/app/main.py", - ' "cited_posts": cited_post_summaries(sources, cited_ids),\n' - ' "cited_post_evidence": cited_post_evidence(sources, cited_ids),\n', - ' "cited_post_evidence": cited_post_evidence(sources, cited_ids),\n', - ) - replace_once( - "backend/app/main.py", - ' "timeline": global_ask_timeline(sources),\n }\n', - ' "timeline": global_ask_timeline(sources),\n' - ' **answer_evidence.response_fields(),\n }\n', - ) - - -def create_frontend_component() -> None: - """Create one lazy canonical-timeline disclosure reused by both Ask surfaces.""" - - write_new( - "frontend/src/components/AskProjectHistoryLinks.tsx", - r''' -import { useEffect, useId, useState } from "react"; - -import { fetchProjectHistory, type ProjectHistoryLink } from "../api"; -import type { Locale } from "../i18n"; -import { useLocale } from "../i18n"; -import { projectHistoryText, type ProjectHistoryProjection } from "../projectHistory"; -import { ProjectHistoryTimeline } from "./ProjectHistoryTimeline"; -import "./AskProjectHistoryLinks.css"; - -interface Copy { - heading: string; - boundary: string; - open: (name: string) => string; - close: (name: string) => string; - loading: string; - truncated: string; - observed: string; - inferred: string; -} - -const COPY: Record = { - en: { - heading: "Project histories cited by this answer", - boundary: "Each timeline is rebuilt from currently authorized evidence at the answer cutoff.", - open: (name) => `Open project history: ${name}`, - close: (name) => `Close project history: ${name}`, - loading: "Loading cited project history...", - truncated: "Additional cited projects are not shown. Open the cited source records to inspect their project evidence.", - observed: "Observed project identity", - inferred: "Inferred project identity", - }, - ko: { - heading: "이 답변이 인용한 프로젝트 이력", - boundary: "각 타임라인은 답변 기준 시각과 현재 권한을 통과한 근거로 다시 구성됩니다.", - open: (name) => `프로젝트 이력 열기: ${name}`, - close: (name) => `프로젝트 이력 닫기: ${name}`, - loading: "인용된 프로젝트 이력을 불러오는 중...", - truncated: "일부 추가 프로젝트는 표시하지 않습니다. 인용된 원천 기록에서 프로젝트 근거를 확인하세요.", - observed: "관찰된 프로젝트 식별자", - inferred: "추론된 프로젝트 식별자", - }, - zh: { - heading: "此回答引用的项目历史", - boundary: "每条时间线都根据回答截止时间和当前授权证据重新构建。", - open: (name) => `打开项目历史:${name}`, - close: (name) => `关闭项目历史:${name}`, - loading: "正在加载引用的项目历史...", - truncated: "还有引用项目未显示。请打开引用的源记录检查其项目依据。", - observed: "已观察的项目身份", - inferred: "已推断的项目身份", - }, - ja: { - heading: "この回答が引用したプロジェクト履歴", - boundary: "各タイムラインは回答時点と現在の権限を通過した根拠から再構成されます。", - open: (name) => `プロジェクト履歴を開く: ${name}`, - close: (name) => `プロジェクト履歴を閉じる: ${name}`, - loading: "引用されたプロジェクト履歴を読み込み中...", - truncated: "追加の引用プロジェクトは表示されていません。引用元レコードでプロジェクト根拠を確認してください。", - observed: "観察されたプロジェクト識別子", - inferred: "推論されたプロジェクト識別子", - }, - vi: { - heading: "Lịch sử dự án được câu trả lời này trích dẫn", - boundary: "Mỗi dòng thời gian được dựng lại từ bằng chứng hiện được cấp quyền tại thời điểm cắt của câu trả lời.", - open: (name) => `Mở lịch sử dự án: ${name}`, - close: (name) => `Đóng lịch sử dự án: ${name}`, - loading: "Đang tải lịch sử dự án được trích dẫn...", - truncated: "Một số dự án được trích dẫn chưa được hiển thị. Hãy mở bản ghi nguồn để kiểm tra bằng chứng dự án.", - observed: "Danh tính dự án được quan sát", - inferred: "Danh tính dự án được suy luận", - }, -}; - -function ProjectHistoryDisclosure({ - accessToken, - link, - onOpenPost, -}: { - accessToken: string; - link: ProjectHistoryLink; - onOpenPost: (postId: string) => void; -}) { - const locale = useLocale(); - const copy = COPY[locale]; - const regionId = useId(); - const [opened, setOpened] = useState(false); - const [loading, setLoading] = useState(false); - const [projection, setProjection] = useState(null); - const [error, setError] = useState(false); - - useEffect(() => { - setOpened(false); - setLoading(false); - setProjection(null); - setError(false); - }, [link.project_key, link.focus_post_id, link.knowledge_cutoff]); - - function toggle() { - if (opened) { - setOpened(false); - return; - } - setOpened(true); - if (projection || loading) return; - setLoading(true); - setError(false); - fetchProjectHistory( - accessToken, - link.project_key, - link.knowledge_cutoff, - link.focus_post_id, - ) - .then((result) => { - setProjection(result); - setLoading(false); - }) - .catch(() => { - setError(true); - setLoading(false); - }); - } - - return ( -
-
- {link.project_name} - - {link.truth_status_code === "observed" ? copy.observed : copy.inferred} - -
- - -
- ); -} - -export function AskProjectHistoryLinks({ - accessToken, - links, - truncated, - onOpenPost, -}: { - accessToken: string; - links: ProjectHistoryLink[]; - truncated: boolean; - onOpenPost: (postId: string) => void; -}) { - const locale = useLocale(); - const copy = COPY[locale]; - const headingId = useId(); - if (links.length === 0 && !truncated) return null; - return ( -
-

{copy.heading}

-

{copy.boundary}

- {links.map((link) => ( - - ))} - {truncated ?

{copy.truncated}

: null} -
- ); -} -''', - ) - write_new( - "frontend/src/components/AskProjectHistoryLinks.css", - r''' -.ask-project-history-links { - display: grid; - gap: 0.75rem; - margin-top: 1rem; - padding-top: 1rem; - border-top: 1px solid var(--border-color, #d7dce5); -} - -.ask-project-history-links > h4, -.ask-project-history-link p { - margin: 0; -} - -.ask-project-history-link { - display: grid; - gap: 0.625rem; - padding: 0.75rem; - border: 1px solid var(--border-color, #d7dce5); - border-radius: 0.75rem; - background: var(--surface-color, #fff); -} - -.ask-project-history-link > div:first-child { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.5rem; -} - -.ask-project-history-link > button { - justify-self: start; -} - -.ask-project-history-link [hidden] { - display: none; -} -''', - ) - write_new( - "frontend/src/components/AskProjectHistoryLinks.stories.tsx", - r''' -import type { Meta, StoryObj } from "@storybook/react"; - -import { AskProjectHistoryLinks } from "./AskProjectHistoryLinks"; - -const meta = { - title: "Buyer/Ask Project History Links", - component: AskProjectHistoryLinks, - args: { - accessToken: "storybook-token", - links: [ - { - project_key: "P-100", - project_name: "Synthetic renewal", - focus_post_id: "post-voc", - source_post_ids: ["post-spec", "post-voc"], - knowledge_cutoff: "2026-08-20T12:00:00Z", - truth_status_code: "observed", - }, - ], - truncated: false, - onOpenPost: () => undefined, - }, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const ObservedProject: Story = {}; - -export const InferredAndTruncated: Story = { - args: { - links: [ - { - project_key: "semantic-project", - project_name: "Semantic project candidate", - focus_post_id: "post-candidate", - source_post_ids: ["post-candidate"], - knowledge_cutoff: "2026-08-20T12:00:00Z", - truth_status_code: "inferred", - }, - ], - truncated: true, - }, -}; -''', - ) - - -def patch_frontend_api_and_app() -> None: - """Expose structured links and render them in both answer surfaces.""" - - replace_once( - "frontend/src/api.ts", - """export interface CitedPostEvidence { - post_id: string; - facts: CitedPostEvidenceFact[]; -} - -export interface ChatAnswer { -""", - """export interface CitedPostEvidence { - post_id: string; - facts: CitedPostEvidenceFact[]; -} - -export interface ProjectHistoryLink { - project_key: string; - project_name: string; - focus_post_id: string; - source_post_ids: string[]; - knowledge_cutoff: string; - truth_status_code: "observed" | "inferred"; -} - -export interface ChatAnswer { -""", - ) - replace_once( - "frontend/src/api.ts", - """ cited_posts?: CitedPostRef[]; - source_post_ids: string[]; -} - -export interface ChatExchange { -""", - """ cited_posts?: CitedPostRef[]; - source_post_ids: string[]; - knowledge_cutoff?: string; - project_histories?: ProjectHistoryLink[]; - project_histories_truncated?: boolean; -} - -export interface ChatExchange { -""", - ) - replace_once( - "frontend/src/api.ts", - """ cited_post_ids: string[]; - cited_posts?: CitedPostRef[]; -} - -export interface ChatHistory { -""", - """ cited_post_ids: string[]; - cited_posts?: CitedPostRef[]; - knowledge_cutoff?: string; - project_histories?: ProjectHistoryLink[]; - project_histories_truncated?: boolean; -} - -export interface ChatHistory { -""", - ) - replace_once( - "frontend/src/api.ts", - """ timeline?: AskTimelineEntry[]; - next_action?: string; -} -""", - """ timeline?: AskTimelineEntry[]; - knowledge_cutoff?: string; - project_histories?: ProjectHistoryLink[]; - project_histories_truncated?: boolean; - next_action?: string; -} -""", - ) - replace_once( - "frontend/src/App.tsx", - " type PostSortOrder,\n", - " type PostSortOrder,\n type ProjectHistoryLink,\n", - ) - replace_once( - "frontend/src/App.tsx", - 'import { ProjectHistoryTimeline } from "./components/ProjectHistoryTimeline";\n', - 'import { AskProjectHistoryLinks } from "./components/AskProjectHistoryLinks";\n' - 'import { ProjectHistoryTimeline } from "./components/ProjectHistoryTimeline";\n', - ) - replace_once( - "frontend/src/App.tsx", - """ cited_post_ids: result.cited_post_ids, - cited_posts: result.cited_posts, - }; -""", - """ cited_post_ids: result.cited_post_ids, - cited_posts: result.cited_posts, - knowledge_cutoff: result.knowledge_cutoff, - project_histories: result.project_histories, - project_histories_truncated: result.project_histories_truncated, - }; -""", - ) - first_citations = ''' -''' - replace_once( - "frontend/src/App.tsx", - first_citations, - first_citations - + ''' -''', - ) - map_citations = ''' -''' - replace_once( - "frontend/src/App.tsx", - map_citations, - map_citations - + ''' -''', - ) - answer_citations = ''' -''' - replace_once( - "frontend/src/App.tsx", - answer_citations, - answer_citations - + ''' -''', - ) - replace_once( - "frontend/src/App.tsx", - """ async function handleAsk() { - const normalized = question.trim(); - if (!normalized) return; - setAsking(true); - setError(null); - setAnswer(null); - try { - const nextAnswer = await askAgent(accessToken, normalized, sessionId); - setAnswer(nextAnswer); - setSessionId(nextAnswer.session_id); - window.sessionStorage.setItem("lineageweave.globalAskSessionId", nextAnswer.session_id); - } catch (err) { - setAnswer(null); - setError(orchestratorUnavailableMessage(err, t("Ask Agent"))); - } finally { - setAsking(false); - } - } -""", - """ function acceptAnswer(nextAnswer: AskAgentResponse) { - setAnswer(nextAnswer); - setSessionId(nextAnswer.session_id); - window.sessionStorage.setItem("lineageweave.globalAskSessionId", nextAnswer.session_id); - } - - async function handleAsk() { - const normalized = question.trim(); - if (!normalized) return; - setAsking(true); - setError(null); - setAnswer(null); - try { - acceptAnswer(await askAgent(accessToken, normalized, sessionId)); - } catch (err) { - if (err instanceof BackendError && err.status === 409 && sessionId) { - window.sessionStorage.removeItem("lineageweave.globalAskSessionId"); - setSessionId(undefined); - try { - acceptAnswer(await askAgent(accessToken, normalized)); - return; - } catch (retryError) { - setError(orchestratorUnavailableMessage(retryError, t("Ask Agent"))); - return; - } - } - setError(orchestratorUnavailableMessage(err, t("Ask Agent"))); - } finally { - setAsking(false); - } - } -""", - ) - timeline_block = ''' {answer.timeline && answer.timeline.length > 0 ? ( - <> -

Event Lineage timeline

-
    - {answer.timeline.map((event) => ( -
  1. - -
  2. - ))} -
- - ) : null} -''' - replace_once( - "frontend/src/App.tsx", - timeline_block, - timeline_block - + ''' -''', - ) - - -def patch_docs_and_versions() -> None: - """Record the closed Ask integration and advance the stacked release version.""" - - replace_once("pyproject.toml", 'version = "2.19.0"\n', 'version = "2.20.0"\n') - replace_once( - "frontend/package.json", - ' "version": "2.19.0",\n', - ' "version": "2.20.0",\n', - ) - replace_once( - "uv.lock", - 'name = "lineageweave"\nversion = "2.19.0"\n', - 'name = "lineageweave"\nversion = "2.20.0"\n', - ) - replace_once( - "CHANGELOG.md", - "## [2.19.0] - 2026-08-21\n", - "## [2.20.0] - 2026-08-21\n\n" - "### Added\n\n" - "- Post-scoped Ask and Global Ask now attach exact project-history links derived\n" - " only from currently authorized cited posts. Opening a link reuses the canonical\n" - " Project history timeline and its optional TEPP validation at the answer cutoff.\n\n" - "### Security\n\n" - "- Persisted post answers are withheld when any citation is no longer visible, and\n" - " stale Global Ask sessions are restarted before hidden prior prose can re-enter\n" - " conversation context (ADR 0113).\n\n" - "## [2.19.0] - 2026-08-21\n", - ) - append_once( - "docs/product-technical-gap-baseline.md", - "## Ask-to-project-history integration (2026-08-21)", - """ -## Ask-to-project-history integration (2026-08-21) - -- Post-scoped Ask and Global Ask return structured project-history links only for exact - project identities on their currently authorized cited posts. -- Opening a link lazily calls the canonical Project history endpoint with the answer - knowledge cutoff and cited focus post; no second timeline, classifier, or TEPP query is - 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. -- 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 - and evidence navigation do not identify why a VOC occurred. -""", - ) - write_new( - "docs/adr/0113-project-history-links-in-ask-surfaces.md", - r''' -# ADR 0113: Reuse canonical project history in Ask surfaces - -- Status: Proposed -- Date: 2026-08-21 -- Depends on: ADR 0112 and the canonical Project history read model - -## Context - -Post-scoped Ask and Global Ask already cite authorized source posts, but they did not -connect those citations to the project lifecycle timeline shown in the product design. -The earlier orphaned stack attempted to solve this with another project-history flow. -That would create competing project identity, authorization, cutoff, classification, and -TEPP behavior. - -Persisted Ask prose introduces an additional security boundary: if a previously cited -post becomes hidden, deleted, draft, or otherwise ineligible, returning the old answer or -reusing it as conversation context can disclose facts no longer authorized. - -## Decision - -1. Ask responses expose structured project-history links derived only from cited post IDs. -2. Citation IDs are reauthorized with tenant ABAC, source publication eligibility, and the - answer knowledge cutoff before titles or project identities are returned. -3. Exact source project fields outrank semantic project candidates; inferred identities - remain labelled inferred. Links are bounded and deterministic. -4. Opening a link calls the canonical Project history endpoint with project key, answer - cutoff, and cited focus post. The established timeline and TEPP metadata are reused. -5. A persisted post answer is withheld in full when any citation is 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. -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. -8. Timeline or TEPP failure does not remove the answer; the Buyer receives an actionable - error and can still open the exact cited source post. - -## Consequences - -- Document reading, post Ask, Global Ask, and the dedicated Project history destination - share one authorization-first read model and one timeline component. -- Historical answers can disappear after permission or publication changes. This is an - intentional fail-closed property, not data loss from the evidence store. -- A session restart can lose conversational convenience, but prevents a compressed - summary from carrying hidden prose forward. -- Event order remains a temporal association and is not presented as causal inference. - -## Rejected alternatives - -- Parse project identities from answer prose. This is nondeterministic and ungrounded. -- Build a second project query or timeline inside Ask. This duplicates authority. -- Return a stored answer while merely hiding its citation chips. The prose may still leak - the hidden source. -- Keep a stale Global Ask summary and filter only new citations. The summary cannot be - safely decomposed after authorization changes. -''', - ) - - -def main() -> None: - """Apply every product, test-support, and documentation edit.""" - - create_backend_projection() - patch_post_chat_ingestion() - patch_main_routes() - create_frontend_component() - patch_frontend_api_and_app() - patch_docs_and_versions() - - -if __name__ == "__main__": - main() diff --git a/tools/harden_project_history_ask_cutoff.py b/tools/harden_project_history_ask_cutoff.py deleted file mode 100644 index 8a2fad53a..000000000 --- a/tools/harden_project_history_ask_cutoff.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Harden generated post-Ask persistence against database clock skew.""" - -from __future__ import annotations - -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] - - -def replace_once(path: str, old: str, new: str) -> None: - """Replace one exact generated persistence fragment.""" - - file_path = ROOT / path - text = file_path.read_text(encoding="utf-8") - count = text.count(old) - if count != 1: - raise RuntimeError(f"expected one cutoff anchor in {path}, found {count}") - file_path.write_text(text.replace(old, new, 1), encoding="utf-8") - - -def main() -> None: - """Write computed and cutoff clocks from one monotonic application decision.""" - - replace_once( - "backend/app/post_chat_ingestion.py", - """ cutoff = _ask_cutoff(knowledge_cutoff) - await conn.execute( - "delete from post_chat_result where post_id = $1 and question_norm = $2", -""", - """ cutoff = _ask_cutoff(knowledge_cutoff) - computed_at = max(datetime.now(timezone.utc), cutoff) - await conn.execute( - "delete from post_chat_result where post_id = $1 and question_norm = $2", -""", - ) - replace_once( - "backend/app/post_chat_ingestion.py", - """ "insert into post_chat_result " - "(post_id, question_norm, question_text, answer_text, knowledge_cutoff) " - "values ($1, $2, $3, $4, $5)", - post_id, - norm, - question.strip(), - answer_text, - cutoff, -""", - """ "insert into post_chat_result " - "(post_id, question_norm, question_text, answer_text, " - "computed_at, knowledge_cutoff) " - "values ($1, $2, $3, $4, $5, $6)", - post_id, - norm, - question.strip(), - answer_text, - computed_at, - cutoff, -""", - ) - - -if __name__ == "__main__": - main() diff --git a/tools/repair_project_history_ask_transform.py b/tools/repair_project_history_ask_transform.py deleted file mode 100644 index 0fecb7e8e..000000000 --- a/tools/repair_project_history_ask_transform.py +++ /dev/null @@ -1,304 +0,0 @@ -"""Repair generated-code details after the bounded Ask transform.""" - -from __future__ import annotations - -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] - - -def replace_once(path: str, old: str, new: str) -> None: - """Replace one exact generated fragment.""" - - file_path = ROOT / path - text = file_path.read_text(encoding="utf-8") - count = text.count(old) - if count != 1: - raise RuntimeError(f"expected one generated anchor in {path}, found {count}") - file_path.write_text(text.replace(old, new, 1), encoding="utf-8") - - -def write_new(path: str, content: str) -> None: - """Create one migration artifact and reject accidental overwrite.""" - - file_path = ROOT / path - if file_path.exists(): - raise RuntimeError(f"refusing to overwrite existing {path}") - file_path.parent.mkdir(parents=True, exist_ok=True) - file_path.write_text(content.strip() + "\n", encoding="utf-8") - - -def repair_sql_interpolation() -> None: - """Make schema-owned eligibility fragments interpolate before execution.""" - - replace_once( - "backend/app/post_chat_ingestion.py", - """ candidate_rows = await conn.fetch( - """ - select post_id, matched_in -""", - """ candidate_rows = await conn.fetch( - f""" - select post_id, matched_in -""", - ) - replace_once( - "backend/app/post_chat_ingestion.py", - """ rows = await conn.fetch( - """ - select post_id, post_title, post_body, visibility_code, corporate_entity_id, - created_at, -""", - """ rows = await conn.fetch( - f""" - select post_id, post_title, post_body, visibility_code, corporate_entity_id, - created_at, -""", - ) - replace_once( - "backend/app/main.py", - " cited_post_evidence,\n cited_post_summaries,\n", - " cited_post_evidence,\n", - ) - - -def add_exact_post_chat_cutoff() -> None: - """Persist the retrieval cutoff instead of reconstructing it from write time.""" - - write_new( - "migrations/0053_post_chat_knowledge_cutoff.sql", - """ -alter table post_chat_result - add column if not exists knowledge_cutoff timestamptz; - -update post_chat_result - set knowledge_cutoff = computed_at - where knowledge_cutoff is null; - -alter table post_chat_result - alter column knowledge_cutoff set default now(), - alter column knowledge_cutoff set not null; - -do $$ -begin - if not exists ( - select 1 - from pg_constraint - where conname = 'post_chat_result_knowledge_cutoff_check' - and conrelid = 'post_chat_result'::regclass - ) then - alter table post_chat_result - add constraint post_chat_result_knowledge_cutoff_check - check (knowledge_cutoff <= computed_at); - end if; -end -$$; - -comment on column post_chat_result.knowledge_cutoff is - 'Maximum source availability time used to compute this persisted answer.'; -""", - ) - write_new( - "migrations/rollback/0053_post_chat_knowledge_cutoff.sql", - """ -alter table post_chat_result - drop constraint if exists post_chat_result_knowledge_cutoff_check; - -alter table post_chat_result - drop column if exists knowledge_cutoff; -""", - ) - replace_once( - "docker/postgres-init/migrate.sh", - " 0051_*|0052_*) ;;\n", - " 0051_*|0052_*|0053_*) ;;\n", - ) - replace_once( - "backend/app/post_chat_ingestion.py", - ' "select question_text, answer_text, computed_at from post_chat_result "\n', - ' "select question_text, answer_text, knowledge_cutoff from post_chat_result "\n', - ) - replace_once( - "backend/app/post_chat_ingestion.py", - ' "_knowledge_cutoff": header.get("computed_at"),\n', - ' "_knowledge_cutoff": header.get("knowledge_cutoff"),\n', - ) - replace_once( - "backend/app/post_chat_ingestion.py", - """ cited_post_ids: list[str] | tuple[str, ...], -) -> dict[str, Any]: -""", - """ cited_post_ids: list[str] | tuple[str, ...], - *, - knowledge_cutoff: datetime | None = None, -) -> dict[str, Any]: -""", - ) - replace_once( - "backend/app/post_chat_ingestion.py", - """ if not norm: - raise ValueError("question is empty after normalize") - await conn.execute( -""", - """ if not norm: - raise ValueError("question is empty after normalize") - cutoff = _ask_cutoff(knowledge_cutoff) - await conn.execute( -""", - ) - replace_once( - "backend/app/post_chat_ingestion.py", - """ "insert into post_chat_result (post_id, question_norm, question_text, answer_text) " - "values ($1, $2, $3, $4)", - post_id, - norm, - question.strip(), - answer_text, -""", - """ "insert into post_chat_result " - "(post_id, question_norm, question_text, answer_text, knowledge_cutoff) " - "values ($1, $2, $3, $4, $5)", - post_id, - norm, - question.strip(), - answer_text, - cutoff, -""", - ) - replace_once( - "backend/app/main.py", - """ await persist_post_chat(conn, post_id, question, answer.answer_text, cited_ids) - answer_evidence = await read_authorized_ask_evidence( -""", - """ await persist_post_chat( - conn, - post_id, - question, - answer.answer_text, - cited_ids, - knowledge_cutoff=knowledge_cutoff, - ) - answer_evidence = await read_authorized_ask_evidence( -""", - ) - - -def harden_ask_projection() -> None: - """Canonicalize untrusted IDs and bound public project projection sizes.""" - - replace_once( - "backend/app/ask_project_history.py", - "from typing import Any, Protocol\n", - "from typing import Any, Protocol\nfrom uuid import UUID\n", - ) - replace_once( - "backend/app/ask_project_history.py", - """ citations = tuple(dict.fromkeys(str(value) for value in cited_post_ids if str(value))) - if len(citations) > maximum_citations: -""", - """ try: - citations = tuple( - dict.fromkeys( - str(UUID(str(value))) for value in cited_post_ids if str(value).strip() - ) - ) - except (TypeError, ValueError, AttributeError) as exc: - raise ValueError("citation identities must be UUIDs") from exc - if len(citations) > maximum_citations: -""", - ) - replace_once( - "backend/app/ask_project_history.py", - """ cutoff = ask_knowledge_cutoff(knowledge_cutoff) - cutoff_text = _cutoff_text(cutoff) -""", - """ if maximum_projects < 0 or maximum_projects > ASK_PROJECT_LIMIT: - raise ValueError("project count is outside the supported bound") - cutoff = ask_knowledge_cutoff(knowledge_cutoff) - cutoff_text = _cutoff_text(cutoff) -""", - ) - - -def harden_frontend_async_state() -> None: - """Ignore stale timeline fetches and retry only the intended session conflict.""" - - replace_once( - "frontend/src/components/AskProjectHistoryLinks.tsx", - 'import { useEffect, useId, useState } from "react";\n', - 'import { useEffect, useId, useRef, useState } from "react";\n', - ) - replace_once( - "frontend/src/components/AskProjectHistoryLinks.tsx", - """ const [projection, setProjection] = useState(null); - const [error, setError] = useState(false); - - useEffect(() => { -""", - """ const [projection, setProjection] = useState(null); - const [error, setError] = useState(false); - const requestGeneration = useRef(0); - - useEffect(() => { - requestGeneration.current += 1; -""", - ) - replace_once( - "frontend/src/components/AskProjectHistoryLinks.tsx", - """ setLoading(true); - setError(false); - fetchProjectHistory( -""", - """ setLoading(true); - setError(false); - const generation = ++requestGeneration.current; - fetchProjectHistory( -""", - ) - replace_once( - "frontend/src/components/AskProjectHistoryLinks.tsx", - """ .then((result) => { - setProjection(result); - setLoading(false); - }) - .catch(() => { - setError(true); - setLoading(false); - }); -""", - """ .then((result) => { - if (generation !== requestGeneration.current) return; - setProjection(result); - setLoading(false); - }) - .catch(() => { - if (generation !== requestGeneration.current) return; - setError(true); - setLoading(false); - }); -""", - ) - replace_once( - "frontend/src/App.tsx", - "if (err instanceof BackendError && err.status === 409 && sessionId) {", - """if ( - err instanceof BackendError && - err.status === 409 && - sessionId && - err.message.toLowerCase().includes("start a new session") - ) {""", - ) - - -def main() -> None: - """Repair SQL, exact cutoffs, hostile IDs, and asynchronous UI state.""" - - repair_sql_interpolation() - add_exact_post_chat_cutoff() - harden_ask_projection() - harden_frontend_async_state() - - -if __name__ == "__main__": - main() diff --git a/uv.lock b/uv.lock index 52e5dd21a..fe7edf1ef 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "2.19.0" +version = "2.20.0" source = { editable = "." } dependencies = [ { name = "certifi" }, From 202b7603404bd83c51e75dfb88661d82c601b992 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 11:49:37 +0900 Subject: [PATCH 20/27] docs: record Ask integration gate --- docs/product-technical-gap-baseline.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 8ee666385..4125612b4 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -311,6 +311,9 @@ runtime note into a shipped/live claim. ## Ask-to-project-history integration (2026-08-21) +- Protected-stack checkpoint: PR #342 current head `ab489d81af9b48cf6adc7ae7ff22f84001cf20e2` + is based on PR #339 head `43262dc76622928fdf90b922653949b4ac7c6631`; both remain + review/check gated and are not represented as merged production behavior. - Post-scoped Ask and Global Ask return structured project-history links only for exact project identities on their currently authorized cited posts. - Opening a link lazily calls the canonical Project history endpoint with the answer From bd9e965e3943ea19a115d53c0a8f39a0f70968d6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 11:51:18 +0900 Subject: [PATCH 21/27] docs: avoid stale self head checkpoint --- docs/product-technical-gap-baseline.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 4125612b4..5efbb9ea9 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -311,9 +311,10 @@ runtime note into a shipped/live claim. ## Ask-to-project-history integration (2026-08-21) -- Protected-stack checkpoint: PR #342 current head `ab489d81af9b48cf6adc7ae7ff22f84001cf20e2` - is based on PR #339 head `43262dc76622928fdf90b922653949b4ac7c6631`; both remain - review/check gated and are not represented as merged production behavior. +- Protected-stack checkpoint: PR #342 is based on PR #339 head + `43262dc76622928fdf90b922653949b4ac7c6631`; the PR description and hosted Checks + record its exact current head. Both remain review/check gated and are not represented + as merged production behavior. - Post-scoped Ask and Global Ask return structured project-history links only for exact project identities on their currently authorized cited posts. - Opening a link lazily calls the canonical Project history endpoint with the answer From 9fb70028205db39f4f31fe3031720faecedc4ffb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 14:02:15 +0900 Subject: [PATCH 22/27] fix: contain provider failures across Ask and TEPP --- backend/app/ask_project_history.py | 13 ++++++++----- tests/test_ask_project_history.py | 5 ++--- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/backend/app/ask_project_history.py b/backend/app/ask_project_history.py index cf22e8a32..1bf97a57e 100644 --- a/backend/app/ask_project_history.py +++ b/backend/app/ask_project_history.py @@ -12,7 +12,7 @@ from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Any, Protocol from uuid import UUID @@ -120,25 +120,28 @@ def ask_knowledge_cutoff(value: object | None = None) -> datetime: """Return an offset-aware UTC cutoff from a datetime or ISO text.""" if value is None: - return datetime.now(timezone.utc) + return datetime.now(UTC) if isinstance(value, datetime): parsed = value elif isinstance(value, str) and value.strip(): try: - parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00")) + normalized = value.strip() + if normalized.endswith("Z"): + normalized = f"{normalized[:-1]}+00:00" + parsed = datetime.fromisoformat(normalized) except ValueError as exc: raise ValueError("knowledge cutoff must be ISO-8601") from exc else: raise ValueError("knowledge cutoff must be a datetime or ISO-8601 text") if parsed.tzinfo is None or parsed.utcoffset() is None: raise ValueError("knowledge cutoff must include an offset") - return parsed.astimezone(timezone.utc) + return parsed.astimezone(UTC) def _cutoff_text(value: datetime) -> str: """Serialize one validated cutoff as canonical UTC RFC 3339 text.""" - return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + return value.astimezone(UTC).isoformat().replace("+00:00", "Z") def _bounded_citations( diff --git a/tests/test_ask_project_history.py b/tests/test_ask_project_history.py index 9a98229e8..ad2bdfa6e 100644 --- a/tests/test_ask_project_history.py +++ b/tests/test_ask_project_history.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -from datetime import datetime, timezone +from datetime import UTC, datetime from types import SimpleNamespace import pytest @@ -18,8 +18,7 @@ from backend.app.auth import CurrentAccount from backend.app.post_chat_ingestion import gather_global_chat_sources - -CUTOFF = datetime(2026, 8, 20, 12, 0, tzinfo=timezone.utc) +CUTOFF = datetime(2026, 8, 20, 12, 0, tzinfo=UTC) class _EvidenceConnection: From efde6ccad39314422a77bbb1d5eb4fb31f54c4cb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:30:39 -0700 Subject: [PATCH 23/27] ci: verify PR 342 Global Ask stabilization --- .../workflows/stabilize-pr342-global-ask.yml | 397 ++++++++++++++++++ 1 file changed, 397 insertions(+) create mode 100644 .github/workflows/stabilize-pr342-global-ask.yml diff --git a/.github/workflows/stabilize-pr342-global-ask.yml b/.github/workflows/stabilize-pr342-global-ask.yml new file mode 100644 index 000000000..79ec0c510 --- /dev/null +++ b/.github/workflows/stabilize-pr342-global-ask.yml @@ -0,0 +1,397 @@ +name: Stabilize PR 342 Global Ask + +on: + push: + branches: + - feat/project-history-ask-surfaces-v2200 + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: stabilize-pr342-global-ask-${{ github.ref }} + cancel-in-progress: false + +jobs: + red_green: + name: Reproduce, repair, and verify + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + timeout-minutes: 60 + services: + postgres: + image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 + env: + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN: postgresql://postgres:postgres@localhost:5432/postgres + steps: + - name: Checkout exact PR head + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 + with: + persist-credentials: false + fetch-depth: 1 + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Set up locked dependency manager + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + version: "0.11.28" + enable-cache: false + + - name: Install committed lock + run: uv sync --frozen --extra dev --extra backend + + - name: Add regression tests before production changes + run: | + python - <<'PY' + from pathlib import Path + + ask_test = Path("tests/test_ask_project_history.py") + source = ask_test.read_text(encoding="utf-8") + old = ''' source_queries = [query for query, _args in calls if "array_position($2::uuid[], post_id)" in query] + assert candidate_queries + assert all("source_draft_code" in query and "created_at <= $3" in query for query in candidate_queries) + assert source_queries + assert "source_deleted_flag" in source_queries[0] + assert "created_at <= $4" in source_queries[0] + ''' + new = ''' source_calls = [ + (query, args) + for query, args in calls + if "array_position($2::uuid[], post_id)" in query + ] + assert candidate_queries + assert all("source_draft_code" in query and "created_at <= $3" in query for query in candidate_queries) + assert source_calls + source_query, source_args = source_calls[0] + assert "source_deleted_flag" in source_query + assert "created_at <= $4" in source_query + assert source_args[3] == CUTOFF + ''' + if source.count(old) != 1: + raise SystemExit("Global Ask regression-test anchor changed") + ask_test.write_text(source.replace(old, new, 1), encoding="utf-8") + + Path("tests/test_global_ask_cutoff_postgres.py").write_text( + '''"""PostgreSQL regression for the final Global Ask cutoff boundary.""" + + from __future__ import annotations + + import asyncio + import os + from datetime import UTC, datetime + + import asyncpg + import pytest + + from backend.app.post_chat_ingestion import gather_global_chat_sources + + + CUTOFF = datetime(2026, 8, 20, 12, 0, tzinfo=UTC) + POSTGRES_DSN = os.environ.get("LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN") + + + @pytest.mark.skipif(POSTGRES_DSN is None, reason="requires PostgreSQL integration DSN") + def test_final_global_source_query_binds_the_cutoff_in_real_postgresql() -> None: + """The final authorized-source SQL binds every positional parameter.""" + + async def scenario() -> None: + connection = await asyncpg.connect(POSTGRES_DSN) + try: + await connection.execute( + """ + create temporary table source_post ( + post_id uuid primary key, + post_title text, + post_body text, + visibility_code text, + corporate_entity_id uuid, + created_at timestamptz, + source_system_code text, + source_record_key text, + source_author_code text, + source_author_name text, + source_company_code text, + source_company_name text, + source_process_unit_code text, + source_process_unit_name text, + source_sales_pool_code text, + source_sales_pool_name text, + source_customer_code text, + source_customer_name text, + source_project_code text, + source_project_name text, + source_draft_code text, + source_deleted_flag text + ) + """ + ) + + class PostgresBoundary: + """Execute only the final source query against PostgreSQL.""" + + def __init__(self) -> None: + self.final_args: tuple[object, ...] | None = None + + async def fetch(self, query: str, *args: object): + if "array_position($2::uuid[], post_id)" in query: + self.final_args = args + return await connection.fetch(query, *args) + return [] + + boundary = PostgresBoundary() + result = await gather_global_chat_sources( + boundary, + lambda _row: True, + ["00000000-0000-4000-8000-000000000001"], + question="synthetic project", + limit=2, + knowledge_cutoff=CUTOFF, + ) + assert result == [] + assert boundary.final_args is not None + assert boundary.final_args[3] == CUTOFF + finally: + await connection.close() + + asyncio.run(scenario()) + ''', + encoding="utf-8", + ) + + Path("tests/test_migration_identity.py").write_text( + '''"""Migration identity and replay-window contracts.""" + + from __future__ import annotations + + from collections import Counter + from pathlib import Path + + + ROOT = Path(__file__).resolve().parents[1] + + + def test_forward_migration_numeric_prefixes_are_unique() -> None: + """Every forward migration has one unambiguous numeric identity.""" + + migrations = sorted((ROOT / "migrations").glob("[0-9][0-9][0-9][0-9]_*.sql")) + counts = Counter(path.name.split("_", 1)[0] for path in migrations) + duplicates = sorted(prefix for prefix, count in counts.items() if count > 1) + assert duplicates == [] + + + def test_post_chat_cutoff_uses_the_next_unique_replayable_migration() -> None: + """The Ask cutoff migration remains independently addressable and replayed.""" + + forward = ROOT / "migrations/0054_post_chat_knowledge_cutoff.sql" + rollback = ROOT / "migrations/rollback/0054_post_chat_knowledge_cutoff.sql" + script = (ROOT / "docker/postgres-init/migrate.sh").read_text(encoding="utf-8") + assert forward.is_file() + assert rollback.is_file() + assert not (ROOT / "migrations/0053_post_chat_knowledge_cutoff.sql").exists() + assert "0054_*" in script + ''', + encoding="utf-8", + ) + PY + + - name: Verify RED for the missing cutoff bind + run: | + set +e + uv run --frozen python -m pytest \ + tests/test_ask_project_history.py::test_global_source_retrieval_applies_cutoff_and_publication_eligibility \ + -q > /tmp/red-global.log 2>&1 + status=$? + set -e + cat /tmp/red-global.log + if [ "$status" -eq 0 ]; then + echo "::error::Global Ask regression did not reproduce before the fix." + exit 1 + fi + grep -E "IndexError|tuple index out of range" /tmp/red-global.log >/dev/null + + - name: Verify RED against real PostgreSQL + run: | + set +e + uv run --frozen python -m pytest tests/test_global_ask_cutoff_postgres.py -q \ + > /tmp/red-postgres.log 2>&1 + status=$? + set -e + cat /tmp/red-postgres.log + if [ "$status" -eq 0 ]; then + echo "::error::PostgreSQL accepted the unbound cutoff query before the fix." + exit 1 + fi + grep -Ei "expects 4 arguments|4 arguments.*3.*passed|server expects" \ + /tmp/red-postgres.log >/dev/null + + - name: Verify RED for duplicate migration identity + run: | + set +e + uv run --frozen python -m pytest \ + tests/test_migration_identity.py::test_forward_migration_numeric_prefixes_are_unique \ + -q > /tmp/red-migration.log 2>&1 + status=$? + set -e + cat /tmp/red-migration.log + if [ "$status" -eq 0 ]; then + echo "::error::Duplicate migration prefix was not reproduced before the fix." + exit 1 + fi + grep "0053" /tmp/red-migration.log >/dev/null + + - name: Apply the minimal product fix + run: | + python - <<'PY' + from pathlib import Path + + ingestion = Path("backend/app/post_chat_ingestion.py") + source = ingestion.read_text(encoding="utf-8") + old = ''' list(authorized_corporate_entity_ids), + candidate_ids, + limit, + ) + visible_rows = ''' + new = ''' list(authorized_corporate_entity_ids), + candidate_ids, + limit, + cutoff, + ) + visible_rows = ''' + if source.count(old) != 1: + raise SystemExit("Global Ask cutoff-bind anchor changed") + ingestion.write_text(source.replace(old, new, 1), encoding="utf-8") + + forward_old = Path("migrations/0053_post_chat_knowledge_cutoff.sql") + forward_new = Path("migrations/0054_post_chat_knowledge_cutoff.sql") + rollback_old = Path("migrations/rollback/0053_post_chat_knowledge_cutoff.sql") + rollback_new = Path("migrations/rollback/0054_post_chat_knowledge_cutoff.sql") + if forward_new.exists() or rollback_new.exists(): + raise SystemExit("0054 migration target already exists") + forward_old.rename(forward_new) + rollback_old.rename(rollback_new) + + replacements = { + Path("backend/tests/test_api.py"): ( + "0053_post_chat_knowledge_cutoff.sql", + "0054_post_chat_knowledge_cutoff.sql", + ), + Path("tests/test_ask_project_history_cutoff.py"): ( + "0053_post_chat_knowledge_cutoff.sql", + "0054_post_chat_knowledge_cutoff.sql", + ), + Path("docker/postgres-init/migrate.sh"): ( + "0051_*|0052_*|0053_*) ;;", + "0051_*|0052_*|0053_*|0054_*) ;;", + ), + } + for path, (old_text, new_text) in replacements.items(): + text = path.read_text(encoding="utf-8") + if old_text not in text: + raise SystemExit(f"Expected migration reference missing in {path}") + path.write_text(text.replace(old_text, new_text), encoding="utf-8") + + cutoff_test = Path("tests/test_ask_project_history_cutoff.py") + text = cutoff_test.read_text(encoding="utf-8") + if 'assert "0053_*" in migrate_script' not in text: + raise SystemExit("Cutoff replay assertion anchor changed") + cutoff_test.write_text( + text.replace( + 'assert "0053_*" in migrate_script', + 'assert "0054_*" in migrate_script', + 1, + ), + encoding="utf-8", + ) + PY + + if git grep -n "0053_post_chat_knowledge_cutoff" -- ':!uv.lock'; then + echo "::error::A stale post-chat cutoff migration reference remains." + exit 1 + fi + git diff --check + + - name: Verify GREEN focused contracts + run: | + uv run --frozen python -m pytest \ + tests/test_global_ask_cutoff_postgres.py \ + tests/test_migration_identity.py \ + tests/test_ask_project_history.py \ + tests/test_ask_project_history_cutoff.py \ + tests/test_migration_replay.py \ + -q | tee /tmp/focused.log + + - name: Verify GREEN full Python suite + run: uv run --frozen python -m pytest -q | tee /tmp/full.log + + - name: Verify changed Python import surface + run: | + uv run --frozen python -m compileall -q \ + backend/app/post_chat_ingestion.py \ + backend/tests/test_api.py \ + tests/test_global_ask_cutoff_postgres.py \ + tests/test_migration_identity.py + git diff --check + + - name: Materialize exact verified stabilization bundle + run: | + set -euo pipefail + bundle="$RUNNER_TEMP/pr342-stabilization" + rm -rf "$bundle" + mkdir -p "$bundle/files" "$bundle/verification" + for path in \ + backend/app/post_chat_ingestion.py \ + backend/tests/test_api.py \ + docker/postgres-init/migrate.sh \ + migrations/0054_post_chat_knowledge_cutoff.sql \ + migrations/rollback/0054_post_chat_knowledge_cutoff.sql \ + tests/test_ask_project_history.py \ + tests/test_ask_project_history_cutoff.py \ + tests/test_global_ask_cutoff_postgres.py \ + tests/test_migration_identity.py; do + mkdir -p "$bundle/files/$(dirname "$path")" + cp "$path" "$bundle/files/$path" + done + cat > "$bundle/deletions.txt" <<'EOF' + migrations/0053_post_chat_knowledge_cutoff.sql + migrations/rollback/0053_post_chat_knowledge_cutoff.sql + .github/workflows/stabilize-pr342-global-ask.yml + EOF + cp /tmp/red-global.log "$bundle/verification/red-global.log" + cp /tmp/red-postgres.log "$bundle/verification/red-postgres.log" + cp /tmp/red-migration.log "$bundle/verification/red-migration.log" + cp /tmp/focused.log "$bundle/verification/focused.log" + cp /tmp/full.log "$bundle/verification/full.log" + ( + cd "$bundle" + find files -type f -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS + ) + jq -n \ + --arg repository "$GITHUB_REPOSITORY" \ + --arg sourceHead "$GITHUB_SHA" \ + --arg workflow "$GITHUB_WORKFLOW_REF" \ + --arg runId "$GITHUB_RUN_ID" \ + '{schemaVersion: 1, repository: $repository, sourceHead: $sourceHead, workflow: $workflow, runId: $runId, redGreenVerified: true}' \ + > "$bundle/verification.json" + + - name: Upload verified stabilization bundle + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: pr342-global-ask-stabilization-${{ github.sha }} + path: ${{ runner.temp }}/pr342-stabilization/ + if-no-files-found: error + retention-days: 7 From 1909d1478dcd7cb6c4bd049063ea699c04bf81b9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:37:46 -0700 Subject: [PATCH 24/27] ci: publish verified PR 342 stabilization --- .../stabilize-pr342-global-ask-v2.yml | 387 ++++++++++++++++++ 1 file changed, 387 insertions(+) create mode 100644 .github/workflows/stabilize-pr342-global-ask-v2.yml diff --git a/.github/workflows/stabilize-pr342-global-ask-v2.yml b/.github/workflows/stabilize-pr342-global-ask-v2.yml new file mode 100644 index 000000000..77507fdc3 --- /dev/null +++ b/.github/workflows/stabilize-pr342-global-ask-v2.yml @@ -0,0 +1,387 @@ +name: Stabilize PR 342 Global Ask v2 + +on: + push: + branches: + - feat/project-history-ask-surfaces-v2200 + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: stabilize-pr342-global-ask-v2-${{ github.ref }} + cancel-in-progress: true + +jobs: + red_green_publish: + name: Reproduce, repair, verify, and publish + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + timeout-minutes: 60 + services: + postgres: + image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 + env: + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN: postgresql://postgres:postgres@localhost:5432/postgres + TARGET_BRANCH: feat/project-history-ask-surfaces-v2200 + steps: + - name: Checkout exact branch head + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 + with: + ref: feat/project-history-ask-surfaces-v2200 + fetch-depth: 0 + persist-credentials: true + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Set up locked dependency manager + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + version: "0.11.28" + enable-cache: false + + - name: Install committed lock + run: uv sync --frozen --extra dev --extra backend + + - name: Add RED regression contracts + run: | + python - <<'PY' + from pathlib import Path + + path = Path("tests/test_ask_project_history.py") + source = path.read_text(encoding="utf-8") + old = ( + ' source_queries = [query for query, _args in calls if "array_position($2::uuid[], post_id)" in query]\n' + ' assert candidate_queries\n' + ' assert all("source_draft_code" in query and "created_at <= $3" in query for query in candidate_queries)\n' + ' assert source_queries\n' + ' assert "source_deleted_flag" in source_queries[0]\n' + ' assert "created_at <= $4" in source_queries[0]\n' + ) + new = ( + ' source_calls = [\n' + ' (query, args)\n' + ' for query, args in calls\n' + ' if "array_position($2::uuid[], post_id)" in query\n' + ' ]\n' + ' assert candidate_queries\n' + ' assert all("source_draft_code" in query and "created_at <= $3" in query for query in candidate_queries)\n' + ' assert source_calls\n' + ' source_query, source_args = source_calls[0]\n' + ' assert "source_deleted_flag" in source_query\n' + ' assert "created_at <= $4" in source_query\n' + ' assert source_args[3] == CUTOFF\n' + ) + if source.count(old) != 1: + raise SystemExit("Global Ask regression-test anchor changed") + path.write_text(source.replace(old, new, 1), encoding="utf-8") + PY + + cat > tests/test_global_ask_cutoff_postgres.py <<'PY' + """PostgreSQL regression for the final Global Ask cutoff boundary.""" + + from __future__ import annotations + + import asyncio + import os + from datetime import UTC, datetime + + import asyncpg + import pytest + + from backend.app.post_chat_ingestion import gather_global_chat_sources + + + CUTOFF = datetime(2026, 8, 20, 12, 0, tzinfo=UTC) + POSTGRES_DSN = os.environ.get("LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN") + + + @pytest.mark.skipif(POSTGRES_DSN is None, reason="requires PostgreSQL integration DSN") + def test_final_global_source_query_binds_the_cutoff_in_real_postgresql() -> None: + """The final authorized-source SQL binds every positional parameter.""" + + async def scenario() -> None: + connection = await asyncpg.connect(POSTGRES_DSN) + try: + await connection.execute( + """ + create temporary table source_post ( + post_id uuid primary key, + post_title text, + post_body text, + visibility_code text, + corporate_entity_id uuid, + created_at timestamptz, + source_system_code text, + source_record_key text, + source_author_code text, + source_author_name text, + source_company_code text, + source_company_name text, + source_process_unit_code text, + source_process_unit_name text, + source_sales_pool_code text, + source_sales_pool_name text, + source_customer_code text, + source_customer_name text, + source_project_code text, + source_project_name text, + source_draft_code text, + source_deleted_flag text + ) + """ + ) + + class PostgresBoundary: + """Execute only the final source query against PostgreSQL.""" + + def __init__(self) -> None: + self.final_args: tuple[object, ...] | None = None + + async def fetch(self, query: str, *args: object): + if "array_position($2::uuid[], post_id)" in query: + self.final_args = args + return await connection.fetch(query, *args) + return [] + + boundary = PostgresBoundary() + result = await gather_global_chat_sources( + boundary, + lambda _row: True, + ["00000000-0000-4000-8000-000000000001"], + question="synthetic project", + limit=2, + knowledge_cutoff=CUTOFF, + ) + assert result == [] + assert boundary.final_args is not None + assert boundary.final_args[3] == CUTOFF + finally: + await connection.close() + + asyncio.run(scenario()) + PY + + cat > tests/test_migration_identity.py <<'PY' + """Migration identity and replay-window contracts.""" + + from __future__ import annotations + + from collections import Counter + from pathlib import Path + + + ROOT = Path(__file__).resolve().parents[1] + + + def test_forward_migration_numeric_prefixes_are_unique() -> None: + """Every forward migration has one unambiguous numeric identity.""" + + migrations = sorted((ROOT / "migrations").glob("[0-9][0-9][0-9][0-9]_*.sql")) + counts = Counter(path.name.split("_", 1)[0] for path in migrations) + duplicates = sorted(prefix for prefix, count in counts.items() if count > 1) + assert duplicates == [] + + + def test_post_chat_cutoff_uses_the_next_unique_replayable_migration() -> None: + """The Ask cutoff migration remains independently addressable and replayed.""" + + forward = ROOT / "migrations/0054_post_chat_knowledge_cutoff.sql" + rollback = ROOT / "migrations/rollback/0054_post_chat_knowledge_cutoff.sql" + script = (ROOT / "docker/postgres-init/migrate.sh").read_text(encoding="utf-8") + assert forward.is_file() + assert rollback.is_file() + assert not (ROOT / "migrations/0053_post_chat_knowledge_cutoff.sql").exists() + assert "0054_*" in script + PY + + - name: Prove the missing cutoff bind is RED + run: | + set +e + uv run --frozen python -m pytest \ + tests/test_ask_project_history.py::test_global_source_retrieval_applies_cutoff_and_publication_eligibility \ + -q > /tmp/red-global.log 2>&1 + status=$? + set -e + cat /tmp/red-global.log + test "$status" -ne 0 + grep -E "IndexError|tuple index out of range" /tmp/red-global.log >/dev/null + + - name: Prove real PostgreSQL rejects the unbound cutoff + run: | + set +e + uv run --frozen python -m pytest tests/test_global_ask_cutoff_postgres.py -q \ + > /tmp/red-postgres.log 2>&1 + status=$? + set -e + cat /tmp/red-postgres.log + test "$status" -ne 0 + grep -Ei "expects 4 arguments|4 arguments.*3.*passed|server expects" \ + /tmp/red-postgres.log >/dev/null + + - name: Prove duplicate migration identity is RED + run: | + set +e + uv run --frozen python -m pytest \ + tests/test_migration_identity.py::test_forward_migration_numeric_prefixes_are_unique \ + -q > /tmp/red-migration.log 2>&1 + status=$? + set -e + cat /tmp/red-migration.log + test "$status" -ne 0 + grep "0053" /tmp/red-migration.log >/dev/null + + - name: Apply the minimal production and migration fix + run: | + python - <<'PY' + from pathlib import Path + + ingestion = Path("backend/app/post_chat_ingestion.py") + source = ingestion.read_text(encoding="utf-8") + old = ( + " list(authorized_corporate_entity_ids),\n" + " candidate_ids,\n" + " limit,\n" + " )\n" + " visible_rows = " + ) + new = ( + " list(authorized_corporate_entity_ids),\n" + " candidate_ids,\n" + " limit,\n" + " cutoff,\n" + " )\n" + " visible_rows = " + ) + if source.count(old) != 1: + raise SystemExit("Global Ask cutoff-bind anchor changed") + ingestion.write_text(source.replace(old, new, 1), encoding="utf-8") + + forward_old = Path("migrations/0053_post_chat_knowledge_cutoff.sql") + forward_new = Path("migrations/0054_post_chat_knowledge_cutoff.sql") + rollback_old = Path("migrations/rollback/0053_post_chat_knowledge_cutoff.sql") + rollback_new = Path("migrations/rollback/0054_post_chat_knowledge_cutoff.sql") + if forward_new.exists() or rollback_new.exists(): + raise SystemExit("0054 migration target already exists") + forward_old.rename(forward_new) + rollback_old.rename(rollback_new) + + replacements = { + Path("backend/tests/test_api.py"): ( + "0053_post_chat_knowledge_cutoff.sql", + "0054_post_chat_knowledge_cutoff.sql", + ), + Path("tests/test_ask_project_history_cutoff.py"): ( + "0053_post_chat_knowledge_cutoff.sql", + "0054_post_chat_knowledge_cutoff.sql", + ), + Path("docker/postgres-init/migrate.sh"): ( + "0051_*|0052_*|0053_*) ;;", + "0051_*|0052_*|0053_*|0054_*) ;;", + ), + } + for path, (old_text, new_text) in replacements.items(): + text = path.read_text(encoding="utf-8") + if old_text not in text: + raise SystemExit(f"Expected migration reference missing in {path}") + path.write_text(text.replace(old_text, new_text), encoding="utf-8") + + cutoff_test = Path("tests/test_ask_project_history_cutoff.py") + text = cutoff_test.read_text(encoding="utf-8") + old_assertion = 'assert "0053_*" in migrate_script' + if text.count(old_assertion) != 1: + raise SystemExit("Cutoff replay assertion anchor changed") + cutoff_test.write_text( + text.replace(old_assertion, 'assert "0054_*" in migrate_script', 1), + encoding="utf-8", + ) + PY + + if git grep -n "0053_post_chat_knowledge_cutoff" -- ':!uv.lock'; then + echo "::error::A stale post-chat cutoff migration reference remains." + exit 1 + fi + git diff --check + + - name: Verify focused GREEN contracts + run: | + uv run --frozen python -m pytest \ + tests/test_global_ask_cutoff_postgres.py \ + tests/test_migration_identity.py \ + tests/test_ask_project_history.py \ + tests/test_ask_project_history_cutoff.py \ + tests/test_migration_replay.py \ + -q | tee /tmp/focused.log + + - name: Verify full Python suite GREEN + run: uv run --frozen python -m pytest -q | tee /tmp/full.log + + - name: Verify import and diff contracts + run: | + uv run --frozen python -m compileall -q \ + backend/app/post_chat_ingestion.py \ + backend/tests/test_api.py \ + tests/test_global_ask_cutoff_postgres.py \ + tests/test_migration_identity.py + git diff --check + + - name: Upload bounded RED-GREEN evidence + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: pr342-global-ask-red-green-${{ github.sha }} + path: | + /tmp/red-global.log + /tmp/red-postgres.log + /tmp/red-migration.log + /tmp/focused.log + /tmp/full.log + if-no-files-found: error + retention-days: 7 + + - name: Publish verified stabilization without rewriting history + run: | + set -euo pipefail + git fetch origin "$TARGET_BRANCH" + remote_head="$(git rev-parse "origin/$TARGET_BRANCH")" + if [ "$remote_head" != "$GITHUB_SHA" ]; then + printf '::error::Branch moved concurrently from %s to %s; refusing non-fast-forward publication.\n' \ + "$GITHUB_SHA" "$remote_head" + exit 1 + fi + + rm -f .github/workflows/stabilize-pr342-global-ask.yml + rm -f .github/workflows/stabilize-pr342-global-ask-v2.yml + + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git add -- \ + backend/app/post_chat_ingestion.py \ + backend/tests/test_api.py \ + docker/postgres-init/migrate.sh \ + migrations/0053_post_chat_knowledge_cutoff.sql \ + migrations/0054_post_chat_knowledge_cutoff.sql \ + migrations/rollback/0053_post_chat_knowledge_cutoff.sql \ + migrations/rollback/0054_post_chat_knowledge_cutoff.sql \ + tests/test_ask_project_history.py \ + tests/test_ask_project_history_cutoff.py \ + tests/test_global_ask_cutoff_postgres.py \ + tests/test_migration_identity.py \ + .github/workflows/stabilize-pr342-global-ask.yml \ + .github/workflows/stabilize-pr342-global-ask-v2.yml + git diff --cached --check + git commit -m "fix: stabilize Global Ask cutoff and migration identity" + git push origin "HEAD:$TARGET_BRANCH" From 80ce60411463bf21102397b9edf9053dce28a99d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 15:16:00 +0900 Subject: [PATCH 25/27] fix: make global ask cutoff source-of-truth --- .../stabilize-pr342-global-ask-v2.yml | 387 ----------------- .../workflows/stabilize-pr342-global-ask.yml | 397 ------------------ .../2.20.0-global-ask-cutoff-safety.md | 5 + backend/app/post_chat_ingestion.py | 1 + backend/tests/test_api.py | 2 +- docker/postgres-init/migrate.sh | 2 +- ...lobal-ask-cutoff-and-migration-identity.md | 40 ++ ...ql => 0054_post_chat_knowledge_cutoff.sql} | 0 ...ql => 0054_post_chat_knowledge_cutoff.sql} | 0 tests/test_ask_project_history.py | 14 +- tests/test_ask_project_history_cutoff.py | 6 +- tests/test_global_ask_cutoff_postgres.py | 82 ++++ tests/test_migration_identity.py | 30 ++ 13 files changed, 173 insertions(+), 793 deletions(-) delete mode 100644 .github/workflows/stabilize-pr342-global-ask-v2.yml delete mode 100644 .github/workflows/stabilize-pr342-global-ask.yml create mode 100644 CHANGELOG.d/2.20.0-global-ask-cutoff-safety.md create mode 100644 docs/adr/0125-global-ask-cutoff-and-migration-identity.md rename migrations/{0053_post_chat_knowledge_cutoff.sql => 0054_post_chat_knowledge_cutoff.sql} (100%) rename migrations/rollback/{0053_post_chat_knowledge_cutoff.sql => 0054_post_chat_knowledge_cutoff.sql} (100%) create mode 100644 tests/test_global_ask_cutoff_postgres.py create mode 100644 tests/test_migration_identity.py diff --git a/.github/workflows/stabilize-pr342-global-ask-v2.yml b/.github/workflows/stabilize-pr342-global-ask-v2.yml deleted file mode 100644 index 77507fdc3..000000000 --- a/.github/workflows/stabilize-pr342-global-ask-v2.yml +++ /dev/null @@ -1,387 +0,0 @@ -name: Stabilize PR 342 Global Ask v2 - -on: - push: - branches: - - feat/project-history-ask-surfaces-v2200 - workflow_dispatch: - -permissions: - contents: write - -concurrency: - group: stabilize-pr342-global-ask-v2-${{ github.ref }} - cancel-in-progress: true - -jobs: - red_green_publish: - name: Reproduce, repair, verify, and publish - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - timeout-minutes: 60 - services: - postgres: - image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 - env: - POSTGRES_PASSWORD: postgres - ports: - - 5432:5432 - options: >- - --health-cmd "pg_isready -U postgres" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN: postgresql://postgres:postgres@localhost:5432/postgres - TARGET_BRANCH: feat/project-history-ask-surfaces-v2200 - steps: - - name: Checkout exact branch head - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 - with: - ref: feat/project-history-ask-surfaces-v2200 - fetch-depth: 0 - persist-credentials: true - - - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # actions/setup-python@v6 - with: - python-version: "3.12" - - - name: Set up locked dependency manager - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 - with: - version: "0.11.28" - enable-cache: false - - - name: Install committed lock - run: uv sync --frozen --extra dev --extra backend - - - name: Add RED regression contracts - run: | - python - <<'PY' - from pathlib import Path - - path = Path("tests/test_ask_project_history.py") - source = path.read_text(encoding="utf-8") - old = ( - ' source_queries = [query for query, _args in calls if "array_position($2::uuid[], post_id)" in query]\n' - ' assert candidate_queries\n' - ' assert all("source_draft_code" in query and "created_at <= $3" in query for query in candidate_queries)\n' - ' assert source_queries\n' - ' assert "source_deleted_flag" in source_queries[0]\n' - ' assert "created_at <= $4" in source_queries[0]\n' - ) - new = ( - ' source_calls = [\n' - ' (query, args)\n' - ' for query, args in calls\n' - ' if "array_position($2::uuid[], post_id)" in query\n' - ' ]\n' - ' assert candidate_queries\n' - ' assert all("source_draft_code" in query and "created_at <= $3" in query for query in candidate_queries)\n' - ' assert source_calls\n' - ' source_query, source_args = source_calls[0]\n' - ' assert "source_deleted_flag" in source_query\n' - ' assert "created_at <= $4" in source_query\n' - ' assert source_args[3] == CUTOFF\n' - ) - if source.count(old) != 1: - raise SystemExit("Global Ask regression-test anchor changed") - path.write_text(source.replace(old, new, 1), encoding="utf-8") - PY - - cat > tests/test_global_ask_cutoff_postgres.py <<'PY' - """PostgreSQL regression for the final Global Ask cutoff boundary.""" - - from __future__ import annotations - - import asyncio - import os - from datetime import UTC, datetime - - import asyncpg - import pytest - - from backend.app.post_chat_ingestion import gather_global_chat_sources - - - CUTOFF = datetime(2026, 8, 20, 12, 0, tzinfo=UTC) - POSTGRES_DSN = os.environ.get("LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN") - - - @pytest.mark.skipif(POSTGRES_DSN is None, reason="requires PostgreSQL integration DSN") - def test_final_global_source_query_binds_the_cutoff_in_real_postgresql() -> None: - """The final authorized-source SQL binds every positional parameter.""" - - async def scenario() -> None: - connection = await asyncpg.connect(POSTGRES_DSN) - try: - await connection.execute( - """ - create temporary table source_post ( - post_id uuid primary key, - post_title text, - post_body text, - visibility_code text, - corporate_entity_id uuid, - created_at timestamptz, - source_system_code text, - source_record_key text, - source_author_code text, - source_author_name text, - source_company_code text, - source_company_name text, - source_process_unit_code text, - source_process_unit_name text, - source_sales_pool_code text, - source_sales_pool_name text, - source_customer_code text, - source_customer_name text, - source_project_code text, - source_project_name text, - source_draft_code text, - source_deleted_flag text - ) - """ - ) - - class PostgresBoundary: - """Execute only the final source query against PostgreSQL.""" - - def __init__(self) -> None: - self.final_args: tuple[object, ...] | None = None - - async def fetch(self, query: str, *args: object): - if "array_position($2::uuid[], post_id)" in query: - self.final_args = args - return await connection.fetch(query, *args) - return [] - - boundary = PostgresBoundary() - result = await gather_global_chat_sources( - boundary, - lambda _row: True, - ["00000000-0000-4000-8000-000000000001"], - question="synthetic project", - limit=2, - knowledge_cutoff=CUTOFF, - ) - assert result == [] - assert boundary.final_args is not None - assert boundary.final_args[3] == CUTOFF - finally: - await connection.close() - - asyncio.run(scenario()) - PY - - cat > tests/test_migration_identity.py <<'PY' - """Migration identity and replay-window contracts.""" - - from __future__ import annotations - - from collections import Counter - from pathlib import Path - - - ROOT = Path(__file__).resolve().parents[1] - - - def test_forward_migration_numeric_prefixes_are_unique() -> None: - """Every forward migration has one unambiguous numeric identity.""" - - migrations = sorted((ROOT / "migrations").glob("[0-9][0-9][0-9][0-9]_*.sql")) - counts = Counter(path.name.split("_", 1)[0] for path in migrations) - duplicates = sorted(prefix for prefix, count in counts.items() if count > 1) - assert duplicates == [] - - - def test_post_chat_cutoff_uses_the_next_unique_replayable_migration() -> None: - """The Ask cutoff migration remains independently addressable and replayed.""" - - forward = ROOT / "migrations/0054_post_chat_knowledge_cutoff.sql" - rollback = ROOT / "migrations/rollback/0054_post_chat_knowledge_cutoff.sql" - script = (ROOT / "docker/postgres-init/migrate.sh").read_text(encoding="utf-8") - assert forward.is_file() - assert rollback.is_file() - assert not (ROOT / "migrations/0053_post_chat_knowledge_cutoff.sql").exists() - assert "0054_*" in script - PY - - - name: Prove the missing cutoff bind is RED - run: | - set +e - uv run --frozen python -m pytest \ - tests/test_ask_project_history.py::test_global_source_retrieval_applies_cutoff_and_publication_eligibility \ - -q > /tmp/red-global.log 2>&1 - status=$? - set -e - cat /tmp/red-global.log - test "$status" -ne 0 - grep -E "IndexError|tuple index out of range" /tmp/red-global.log >/dev/null - - - name: Prove real PostgreSQL rejects the unbound cutoff - run: | - set +e - uv run --frozen python -m pytest tests/test_global_ask_cutoff_postgres.py -q \ - > /tmp/red-postgres.log 2>&1 - status=$? - set -e - cat /tmp/red-postgres.log - test "$status" -ne 0 - grep -Ei "expects 4 arguments|4 arguments.*3.*passed|server expects" \ - /tmp/red-postgres.log >/dev/null - - - name: Prove duplicate migration identity is RED - run: | - set +e - uv run --frozen python -m pytest \ - tests/test_migration_identity.py::test_forward_migration_numeric_prefixes_are_unique \ - -q > /tmp/red-migration.log 2>&1 - status=$? - set -e - cat /tmp/red-migration.log - test "$status" -ne 0 - grep "0053" /tmp/red-migration.log >/dev/null - - - name: Apply the minimal production and migration fix - run: | - python - <<'PY' - from pathlib import Path - - ingestion = Path("backend/app/post_chat_ingestion.py") - source = ingestion.read_text(encoding="utf-8") - old = ( - " list(authorized_corporate_entity_ids),\n" - " candidate_ids,\n" - " limit,\n" - " )\n" - " visible_rows = " - ) - new = ( - " list(authorized_corporate_entity_ids),\n" - " candidate_ids,\n" - " limit,\n" - " cutoff,\n" - " )\n" - " visible_rows = " - ) - if source.count(old) != 1: - raise SystemExit("Global Ask cutoff-bind anchor changed") - ingestion.write_text(source.replace(old, new, 1), encoding="utf-8") - - forward_old = Path("migrations/0053_post_chat_knowledge_cutoff.sql") - forward_new = Path("migrations/0054_post_chat_knowledge_cutoff.sql") - rollback_old = Path("migrations/rollback/0053_post_chat_knowledge_cutoff.sql") - rollback_new = Path("migrations/rollback/0054_post_chat_knowledge_cutoff.sql") - if forward_new.exists() or rollback_new.exists(): - raise SystemExit("0054 migration target already exists") - forward_old.rename(forward_new) - rollback_old.rename(rollback_new) - - replacements = { - Path("backend/tests/test_api.py"): ( - "0053_post_chat_knowledge_cutoff.sql", - "0054_post_chat_knowledge_cutoff.sql", - ), - Path("tests/test_ask_project_history_cutoff.py"): ( - "0053_post_chat_knowledge_cutoff.sql", - "0054_post_chat_knowledge_cutoff.sql", - ), - Path("docker/postgres-init/migrate.sh"): ( - "0051_*|0052_*|0053_*) ;;", - "0051_*|0052_*|0053_*|0054_*) ;;", - ), - } - for path, (old_text, new_text) in replacements.items(): - text = path.read_text(encoding="utf-8") - if old_text not in text: - raise SystemExit(f"Expected migration reference missing in {path}") - path.write_text(text.replace(old_text, new_text), encoding="utf-8") - - cutoff_test = Path("tests/test_ask_project_history_cutoff.py") - text = cutoff_test.read_text(encoding="utf-8") - old_assertion = 'assert "0053_*" in migrate_script' - if text.count(old_assertion) != 1: - raise SystemExit("Cutoff replay assertion anchor changed") - cutoff_test.write_text( - text.replace(old_assertion, 'assert "0054_*" in migrate_script', 1), - encoding="utf-8", - ) - PY - - if git grep -n "0053_post_chat_knowledge_cutoff" -- ':!uv.lock'; then - echo "::error::A stale post-chat cutoff migration reference remains." - exit 1 - fi - git diff --check - - - name: Verify focused GREEN contracts - run: | - uv run --frozen python -m pytest \ - tests/test_global_ask_cutoff_postgres.py \ - tests/test_migration_identity.py \ - tests/test_ask_project_history.py \ - tests/test_ask_project_history_cutoff.py \ - tests/test_migration_replay.py \ - -q | tee /tmp/focused.log - - - name: Verify full Python suite GREEN - run: uv run --frozen python -m pytest -q | tee /tmp/full.log - - - name: Verify import and diff contracts - run: | - uv run --frozen python -m compileall -q \ - backend/app/post_chat_ingestion.py \ - backend/tests/test_api.py \ - tests/test_global_ask_cutoff_postgres.py \ - tests/test_migration_identity.py - git diff --check - - - name: Upload bounded RED-GREEN evidence - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: pr342-global-ask-red-green-${{ github.sha }} - path: | - /tmp/red-global.log - /tmp/red-postgres.log - /tmp/red-migration.log - /tmp/focused.log - /tmp/full.log - if-no-files-found: error - retention-days: 7 - - - name: Publish verified stabilization without rewriting history - run: | - set -euo pipefail - git fetch origin "$TARGET_BRANCH" - remote_head="$(git rev-parse "origin/$TARGET_BRANCH")" - if [ "$remote_head" != "$GITHUB_SHA" ]; then - printf '::error::Branch moved concurrently from %s to %s; refusing non-fast-forward publication.\n' \ - "$GITHUB_SHA" "$remote_head" - exit 1 - fi - - rm -f .github/workflows/stabilize-pr342-global-ask.yml - rm -f .github/workflows/stabilize-pr342-global-ask-v2.yml - - git config user.name github-actions[bot] - git config user.email 41898282+github-actions[bot]@users.noreply.github.com - git add -- \ - backend/app/post_chat_ingestion.py \ - backend/tests/test_api.py \ - docker/postgres-init/migrate.sh \ - migrations/0053_post_chat_knowledge_cutoff.sql \ - migrations/0054_post_chat_knowledge_cutoff.sql \ - migrations/rollback/0053_post_chat_knowledge_cutoff.sql \ - migrations/rollback/0054_post_chat_knowledge_cutoff.sql \ - tests/test_ask_project_history.py \ - tests/test_ask_project_history_cutoff.py \ - tests/test_global_ask_cutoff_postgres.py \ - tests/test_migration_identity.py \ - .github/workflows/stabilize-pr342-global-ask.yml \ - .github/workflows/stabilize-pr342-global-ask-v2.yml - git diff --cached --check - git commit -m "fix: stabilize Global Ask cutoff and migration identity" - git push origin "HEAD:$TARGET_BRANCH" diff --git a/.github/workflows/stabilize-pr342-global-ask.yml b/.github/workflows/stabilize-pr342-global-ask.yml deleted file mode 100644 index 79ec0c510..000000000 --- a/.github/workflows/stabilize-pr342-global-ask.yml +++ /dev/null @@ -1,397 +0,0 @@ -name: Stabilize PR 342 Global Ask - -on: - push: - branches: - - feat/project-history-ask-surfaces-v2200 - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: stabilize-pr342-global-ask-${{ github.ref }} - cancel-in-progress: false - -jobs: - red_green: - name: Reproduce, repair, and verify - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - timeout-minutes: 60 - services: - postgres: - image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 - env: - POSTGRES_PASSWORD: postgres - ports: - - 5432:5432 - options: >- - --health-cmd "pg_isready -U postgres" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN: postgresql://postgres:postgres@localhost:5432/postgres - steps: - - name: Checkout exact PR head - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 - with: - persist-credentials: false - fetch-depth: 1 - - - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # actions/setup-python@v6 - with: - python-version: "3.12" - - - name: Set up locked dependency manager - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 - with: - version: "0.11.28" - enable-cache: false - - - name: Install committed lock - run: uv sync --frozen --extra dev --extra backend - - - name: Add regression tests before production changes - run: | - python - <<'PY' - from pathlib import Path - - ask_test = Path("tests/test_ask_project_history.py") - source = ask_test.read_text(encoding="utf-8") - old = ''' source_queries = [query for query, _args in calls if "array_position($2::uuid[], post_id)" in query] - assert candidate_queries - assert all("source_draft_code" in query and "created_at <= $3" in query for query in candidate_queries) - assert source_queries - assert "source_deleted_flag" in source_queries[0] - assert "created_at <= $4" in source_queries[0] - ''' - new = ''' source_calls = [ - (query, args) - for query, args in calls - if "array_position($2::uuid[], post_id)" in query - ] - assert candidate_queries - assert all("source_draft_code" in query and "created_at <= $3" in query for query in candidate_queries) - assert source_calls - source_query, source_args = source_calls[0] - assert "source_deleted_flag" in source_query - assert "created_at <= $4" in source_query - assert source_args[3] == CUTOFF - ''' - if source.count(old) != 1: - raise SystemExit("Global Ask regression-test anchor changed") - ask_test.write_text(source.replace(old, new, 1), encoding="utf-8") - - Path("tests/test_global_ask_cutoff_postgres.py").write_text( - '''"""PostgreSQL regression for the final Global Ask cutoff boundary.""" - - from __future__ import annotations - - import asyncio - import os - from datetime import UTC, datetime - - import asyncpg - import pytest - - from backend.app.post_chat_ingestion import gather_global_chat_sources - - - CUTOFF = datetime(2026, 8, 20, 12, 0, tzinfo=UTC) - POSTGRES_DSN = os.environ.get("LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN") - - - @pytest.mark.skipif(POSTGRES_DSN is None, reason="requires PostgreSQL integration DSN") - def test_final_global_source_query_binds_the_cutoff_in_real_postgresql() -> None: - """The final authorized-source SQL binds every positional parameter.""" - - async def scenario() -> None: - connection = await asyncpg.connect(POSTGRES_DSN) - try: - await connection.execute( - """ - create temporary table source_post ( - post_id uuid primary key, - post_title text, - post_body text, - visibility_code text, - corporate_entity_id uuid, - created_at timestamptz, - source_system_code text, - source_record_key text, - source_author_code text, - source_author_name text, - source_company_code text, - source_company_name text, - source_process_unit_code text, - source_process_unit_name text, - source_sales_pool_code text, - source_sales_pool_name text, - source_customer_code text, - source_customer_name text, - source_project_code text, - source_project_name text, - source_draft_code text, - source_deleted_flag text - ) - """ - ) - - class PostgresBoundary: - """Execute only the final source query against PostgreSQL.""" - - def __init__(self) -> None: - self.final_args: tuple[object, ...] | None = None - - async def fetch(self, query: str, *args: object): - if "array_position($2::uuid[], post_id)" in query: - self.final_args = args - return await connection.fetch(query, *args) - return [] - - boundary = PostgresBoundary() - result = await gather_global_chat_sources( - boundary, - lambda _row: True, - ["00000000-0000-4000-8000-000000000001"], - question="synthetic project", - limit=2, - knowledge_cutoff=CUTOFF, - ) - assert result == [] - assert boundary.final_args is not None - assert boundary.final_args[3] == CUTOFF - finally: - await connection.close() - - asyncio.run(scenario()) - ''', - encoding="utf-8", - ) - - Path("tests/test_migration_identity.py").write_text( - '''"""Migration identity and replay-window contracts.""" - - from __future__ import annotations - - from collections import Counter - from pathlib import Path - - - ROOT = Path(__file__).resolve().parents[1] - - - def test_forward_migration_numeric_prefixes_are_unique() -> None: - """Every forward migration has one unambiguous numeric identity.""" - - migrations = sorted((ROOT / "migrations").glob("[0-9][0-9][0-9][0-9]_*.sql")) - counts = Counter(path.name.split("_", 1)[0] for path in migrations) - duplicates = sorted(prefix for prefix, count in counts.items() if count > 1) - assert duplicates == [] - - - def test_post_chat_cutoff_uses_the_next_unique_replayable_migration() -> None: - """The Ask cutoff migration remains independently addressable and replayed.""" - - forward = ROOT / "migrations/0054_post_chat_knowledge_cutoff.sql" - rollback = ROOT / "migrations/rollback/0054_post_chat_knowledge_cutoff.sql" - script = (ROOT / "docker/postgres-init/migrate.sh").read_text(encoding="utf-8") - assert forward.is_file() - assert rollback.is_file() - assert not (ROOT / "migrations/0053_post_chat_knowledge_cutoff.sql").exists() - assert "0054_*" in script - ''', - encoding="utf-8", - ) - PY - - - name: Verify RED for the missing cutoff bind - run: | - set +e - uv run --frozen python -m pytest \ - tests/test_ask_project_history.py::test_global_source_retrieval_applies_cutoff_and_publication_eligibility \ - -q > /tmp/red-global.log 2>&1 - status=$? - set -e - cat /tmp/red-global.log - if [ "$status" -eq 0 ]; then - echo "::error::Global Ask regression did not reproduce before the fix." - exit 1 - fi - grep -E "IndexError|tuple index out of range" /tmp/red-global.log >/dev/null - - - name: Verify RED against real PostgreSQL - run: | - set +e - uv run --frozen python -m pytest tests/test_global_ask_cutoff_postgres.py -q \ - > /tmp/red-postgres.log 2>&1 - status=$? - set -e - cat /tmp/red-postgres.log - if [ "$status" -eq 0 ]; then - echo "::error::PostgreSQL accepted the unbound cutoff query before the fix." - exit 1 - fi - grep -Ei "expects 4 arguments|4 arguments.*3.*passed|server expects" \ - /tmp/red-postgres.log >/dev/null - - - name: Verify RED for duplicate migration identity - run: | - set +e - uv run --frozen python -m pytest \ - tests/test_migration_identity.py::test_forward_migration_numeric_prefixes_are_unique \ - -q > /tmp/red-migration.log 2>&1 - status=$? - set -e - cat /tmp/red-migration.log - if [ "$status" -eq 0 ]; then - echo "::error::Duplicate migration prefix was not reproduced before the fix." - exit 1 - fi - grep "0053" /tmp/red-migration.log >/dev/null - - - name: Apply the minimal product fix - run: | - python - <<'PY' - from pathlib import Path - - ingestion = Path("backend/app/post_chat_ingestion.py") - source = ingestion.read_text(encoding="utf-8") - old = ''' list(authorized_corporate_entity_ids), - candidate_ids, - limit, - ) - visible_rows = ''' - new = ''' list(authorized_corporate_entity_ids), - candidate_ids, - limit, - cutoff, - ) - visible_rows = ''' - if source.count(old) != 1: - raise SystemExit("Global Ask cutoff-bind anchor changed") - ingestion.write_text(source.replace(old, new, 1), encoding="utf-8") - - forward_old = Path("migrations/0053_post_chat_knowledge_cutoff.sql") - forward_new = Path("migrations/0054_post_chat_knowledge_cutoff.sql") - rollback_old = Path("migrations/rollback/0053_post_chat_knowledge_cutoff.sql") - rollback_new = Path("migrations/rollback/0054_post_chat_knowledge_cutoff.sql") - if forward_new.exists() or rollback_new.exists(): - raise SystemExit("0054 migration target already exists") - forward_old.rename(forward_new) - rollback_old.rename(rollback_new) - - replacements = { - Path("backend/tests/test_api.py"): ( - "0053_post_chat_knowledge_cutoff.sql", - "0054_post_chat_knowledge_cutoff.sql", - ), - Path("tests/test_ask_project_history_cutoff.py"): ( - "0053_post_chat_knowledge_cutoff.sql", - "0054_post_chat_knowledge_cutoff.sql", - ), - Path("docker/postgres-init/migrate.sh"): ( - "0051_*|0052_*|0053_*) ;;", - "0051_*|0052_*|0053_*|0054_*) ;;", - ), - } - for path, (old_text, new_text) in replacements.items(): - text = path.read_text(encoding="utf-8") - if old_text not in text: - raise SystemExit(f"Expected migration reference missing in {path}") - path.write_text(text.replace(old_text, new_text), encoding="utf-8") - - cutoff_test = Path("tests/test_ask_project_history_cutoff.py") - text = cutoff_test.read_text(encoding="utf-8") - if 'assert "0053_*" in migrate_script' not in text: - raise SystemExit("Cutoff replay assertion anchor changed") - cutoff_test.write_text( - text.replace( - 'assert "0053_*" in migrate_script', - 'assert "0054_*" in migrate_script', - 1, - ), - encoding="utf-8", - ) - PY - - if git grep -n "0053_post_chat_knowledge_cutoff" -- ':!uv.lock'; then - echo "::error::A stale post-chat cutoff migration reference remains." - exit 1 - fi - git diff --check - - - name: Verify GREEN focused contracts - run: | - uv run --frozen python -m pytest \ - tests/test_global_ask_cutoff_postgres.py \ - tests/test_migration_identity.py \ - tests/test_ask_project_history.py \ - tests/test_ask_project_history_cutoff.py \ - tests/test_migration_replay.py \ - -q | tee /tmp/focused.log - - - name: Verify GREEN full Python suite - run: uv run --frozen python -m pytest -q | tee /tmp/full.log - - - name: Verify changed Python import surface - run: | - uv run --frozen python -m compileall -q \ - backend/app/post_chat_ingestion.py \ - backend/tests/test_api.py \ - tests/test_global_ask_cutoff_postgres.py \ - tests/test_migration_identity.py - git diff --check - - - name: Materialize exact verified stabilization bundle - run: | - set -euo pipefail - bundle="$RUNNER_TEMP/pr342-stabilization" - rm -rf "$bundle" - mkdir -p "$bundle/files" "$bundle/verification" - for path in \ - backend/app/post_chat_ingestion.py \ - backend/tests/test_api.py \ - docker/postgres-init/migrate.sh \ - migrations/0054_post_chat_knowledge_cutoff.sql \ - migrations/rollback/0054_post_chat_knowledge_cutoff.sql \ - tests/test_ask_project_history.py \ - tests/test_ask_project_history_cutoff.py \ - tests/test_global_ask_cutoff_postgres.py \ - tests/test_migration_identity.py; do - mkdir -p "$bundle/files/$(dirname "$path")" - cp "$path" "$bundle/files/$path" - done - cat > "$bundle/deletions.txt" <<'EOF' - migrations/0053_post_chat_knowledge_cutoff.sql - migrations/rollback/0053_post_chat_knowledge_cutoff.sql - .github/workflows/stabilize-pr342-global-ask.yml - EOF - cp /tmp/red-global.log "$bundle/verification/red-global.log" - cp /tmp/red-postgres.log "$bundle/verification/red-postgres.log" - cp /tmp/red-migration.log "$bundle/verification/red-migration.log" - cp /tmp/focused.log "$bundle/verification/focused.log" - cp /tmp/full.log "$bundle/verification/full.log" - ( - cd "$bundle" - find files -type f -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS - ) - jq -n \ - --arg repository "$GITHUB_REPOSITORY" \ - --arg sourceHead "$GITHUB_SHA" \ - --arg workflow "$GITHUB_WORKFLOW_REF" \ - --arg runId "$GITHUB_RUN_ID" \ - '{schemaVersion: 1, repository: $repository, sourceHead: $sourceHead, workflow: $workflow, runId: $runId, redGreenVerified: true}' \ - > "$bundle/verification.json" - - - name: Upload verified stabilization bundle - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: pr342-global-ask-stabilization-${{ github.sha }} - path: ${{ runner.temp }}/pr342-stabilization/ - if-no-files-found: error - retention-days: 7 diff --git a/CHANGELOG.d/2.20.0-global-ask-cutoff-safety.md b/CHANGELOG.d/2.20.0-global-ask-cutoff-safety.md new file mode 100644 index 000000000..77d039572 --- /dev/null +++ b/CHANGELOG.d/2.20.0-global-ask-cutoff-safety.md @@ -0,0 +1,5 @@ +### Fixed + +- Bind the Global Ask knowledge cutoff in the final authorized-source query. +- Give the post-chat cutoff migration a unique `0054` identity and remove + self-modifying stabilization workflows. diff --git a/backend/app/post_chat_ingestion.py b/backend/app/post_chat_ingestion.py index 24f04d703..7ccc723c4 100644 --- a/backend/app/post_chat_ingestion.py +++ b/backend/app/post_chat_ingestion.py @@ -746,6 +746,7 @@ async def gather_global_chat_sources( list(authorized_corporate_entity_ids), candidate_ids, limit, + cutoff, ) visible_rows = [row for row in rows if can_see_post(row)][:limit] visible_ids = [str(row["post_id"]) for row in visible_rows] diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index c1d1a9538..e6c56c40a 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -113,7 +113,7 @@ _POST_CHAT_CUTOFF_MIGRATION = ( Path(__file__).resolve().parents[2] / "migrations" - / "0053_post_chat_knowledge_cutoff.sql" + / "0054_post_chat_knowledge_cutoff.sql" ) _MAJOR_EVENT_ACTION_MIGRATION = ( Path(__file__).resolve().parents[2] / "migrations" / "0100_major_event_action.sql" diff --git a/docker/postgres-init/migrate.sh b/docker/postgres-init/migrate.sh index af0fc9bba..6fad01496 100644 --- a/docker/postgres-init/migrate.sh +++ b/docker/postgres-init/migrate.sh @@ -18,7 +18,7 @@ for migration in /opt/lineageweave/migrations/*.sql; do migration_name=${migration##*/} case "$migration_name" in 0012_*|0013_*|0014_*|0015_*|0016_*|0017_*|0018_*|0019_*|0020_*|0021_*|0022_*|0023_*|0024_*|0025_*|0026_*|0027_*|0028_*|0029_*|0030_*|0031_*|0032_*|0033_*|0034_*|0035_*|0036_*|0037_*|0038_*|0039_*|0040_*|0041_*|0042_*|0043_*|0044_*|0045_*|0046_*|0047_*|0048_*|0049_*|0050_*) ;; - 0051_*|0052_*|0053_*) ;; + 0051_*|0052_*|0053_*|0054_*) ;; 0060_*|0100_*) ;; *) continue ;; esac diff --git a/docs/adr/0125-global-ask-cutoff-and-migration-identity.md b/docs/adr/0125-global-ask-cutoff-and-migration-identity.md new file mode 100644 index 000000000..48745b065 --- /dev/null +++ b/docs/adr/0125-global-ask-cutoff-and-migration-identity.md @@ -0,0 +1,40 @@ +# ADR 0125 — Bind Global Ask cutoffs and keep migration identities unique + +**Decision status:** Accepted on the PR #342 repair branch +**Date:** 2026-08-21 +**Figma File ID:** N/A — this is a backend, migration, and operability decision. + +## Context + +Global Ask restricts source posts by the requested knowledge cutoff. Its final +PostgreSQL query used the `$4` cutoff placeholder but supplied only three +arguments, so a real PostgreSQL execution could fail before returning any +authorized evidence. The same branch also introduced a second forward +migration with numeric prefix `0053`, colliding with an existing migration. +Temporary self-modifying workflows were compensating for both defects after a +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, + `0054`, and update rollback, migration dispatch, and contract tests. +3. Keep reproduction and regression checks in committed tests. Do not use a + workflow that edits, commits, pushes, or deletes product source at runtime. + +## Consequences + +- Global Ask fails neither at PostgreSQL parameter binding nor by silently + dropping the requested knowledge cutoff. +- Migration replay and rollback address one numeric identity unambiguously. +- Hosted CI evaluates the exact committed source instead of a workflow-mutated + branch state. + +## Verification + +- The synthetic query contract asserts the fourth argument is the requested + cutoff. +- 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 + `0054_*` dispatch path. diff --git a/migrations/0053_post_chat_knowledge_cutoff.sql b/migrations/0054_post_chat_knowledge_cutoff.sql similarity index 100% rename from migrations/0053_post_chat_knowledge_cutoff.sql rename to migrations/0054_post_chat_knowledge_cutoff.sql diff --git a/migrations/rollback/0053_post_chat_knowledge_cutoff.sql b/migrations/rollback/0054_post_chat_knowledge_cutoff.sql similarity index 100% rename from migrations/rollback/0053_post_chat_knowledge_cutoff.sql rename to migrations/rollback/0054_post_chat_knowledge_cutoff.sql diff --git a/tests/test_ask_project_history.py b/tests/test_ask_project_history.py index ad2bdfa6e..c9420be90 100644 --- a/tests/test_ask_project_history.py +++ b/tests/test_ask_project_history.py @@ -193,12 +193,18 @@ async def fetch(self, query: str, *args: object): ) candidate_queries = [query for query, _args in calls if "matched_in" in query] - source_queries = [query for query, _args in calls if "array_position($2::uuid[], post_id)" in query] + source_calls = [ + (query, args) + for query, args in calls + if "array_position($2::uuid[], post_id)" in query + ] assert candidate_queries assert all("source_draft_code" in query and "created_at <= $3" in query for query in candidate_queries) - assert source_queries - assert "source_deleted_flag" in source_queries[0] - assert "created_at <= $4" in source_queries[0] + assert source_calls + source_query, source_args = source_calls[0] + assert "source_deleted_flag" in source_query + assert "created_at <= $4" in source_query + assert source_args[3] == CUTOFF class _Acquire: diff --git a/tests/test_ask_project_history_cutoff.py b/tests/test_ask_project_history_cutoff.py index be3c5edf7..22c7cd059 100644 --- a/tests/test_ask_project_history_cutoff.py +++ b/tests/test_ask_project_history_cutoff.py @@ -64,8 +64,8 @@ def test_persist_post_chat_writes_the_retrieval_cutoff_not_a_later_read_clock() def test_cutoff_migration_is_applied_and_fails_closed_on_inverted_clocks() -> None: - migration = ROOT / "migrations/0053_post_chat_knowledge_cutoff.sql" - rollback = ROOT / "migrations/rollback/0053_post_chat_knowledge_cutoff.sql" + migration = ROOT / "migrations/0054_post_chat_knowledge_cutoff.sql" + rollback = ROOT / "migrations/rollback/0054_post_chat_knowledge_cutoff.sql" migrate_script = (ROOT / "docker/postgres-init/migrate.sh").read_text(encoding="utf-8") assert migration.is_file() @@ -74,4 +74,4 @@ def test_cutoff_migration_is_applied_and_fails_closed_on_inverted_clocks() -> No assert "knowledge_cutoff = computed_at" in text assert "knowledge_cutoff <= computed_at" in text assert rollback.is_file() - assert "0053_*" in migrate_script + assert "0054_*" in migrate_script diff --git a/tests/test_global_ask_cutoff_postgres.py b/tests/test_global_ask_cutoff_postgres.py new file mode 100644 index 000000000..5d07797a8 --- /dev/null +++ b/tests/test_global_ask_cutoff_postgres.py @@ -0,0 +1,82 @@ +"""PostgreSQL regression for the final Global Ask cutoff boundary.""" + +from __future__ import annotations + +import asyncio +import os +from datetime import UTC, datetime + +import asyncpg +import pytest + +from backend.app.post_chat_ingestion import gather_global_chat_sources + + +CUTOFF = datetime(2026, 8, 20, 12, 0, tzinfo=UTC) +POSTGRES_DSN = os.environ.get("LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN") + + +@pytest.mark.skipif(not POSTGRES_DSN, reason="requires PostgreSQL integration DSN") +def test_final_global_source_query_binds_the_cutoff_in_real_postgresql() -> None: + """The final authorized-source SQL binds every positional parameter.""" + + async def scenario() -> None: + connection = await asyncpg.connect(POSTGRES_DSN) + try: + await connection.execute( + """ + create temporary table source_post ( + post_id uuid primary key, + post_title text, + post_body text, + visibility_code text, + corporate_entity_id uuid, + created_at timestamptz, + source_system_code text, + source_record_key text, + source_author_code text, + source_author_name text, + source_company_code text, + source_company_name text, + source_process_unit_code text, + source_process_unit_name text, + source_sales_pool_code text, + source_sales_pool_name text, + source_customer_code text, + source_customer_name text, + source_project_code text, + source_project_name text, + source_draft_code text, + source_deleted_flag text + ) + """ + ) + + class PostgresBoundary: + """Execute only the final source query against PostgreSQL.""" + + def __init__(self) -> None: + self.final_args: tuple[object, ...] | None = None + + async def fetch(self, query: str, *args: object): + if "array_position($2::uuid[], post_id)" in query: + self.final_args = args + return await connection.fetch(query, *args) + return [] + + boundary = PostgresBoundary() + result = await gather_global_chat_sources( + boundary, + lambda _row: True, + ["00000000-0000-4000-8000-000000000001"], + question="synthetic project", + limit=2, + knowledge_cutoff=CUTOFF, + ) + assert result == [] + assert boundary.final_args is not None + assert boundary.final_args[3] == CUTOFF + finally: + await connection.close() + + asyncio.run(scenario()) diff --git a/tests/test_migration_identity.py b/tests/test_migration_identity.py new file mode 100644 index 000000000..3e5c19a92 --- /dev/null +++ b/tests/test_migration_identity.py @@ -0,0 +1,30 @@ +"""Migration identity and replay-window contracts.""" + +from __future__ import annotations + +from collections import Counter +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def test_forward_migration_numeric_prefixes_are_unique() -> None: + """Every forward migration has one unambiguous numeric identity.""" + + migrations = sorted((ROOT / "migrations").glob("[0-9][0-9][0-9][0-9]_*.sql")) + counts = Counter(path.name.split("_", 1)[0] for path in migrations) + duplicates = sorted(prefix for prefix, count in counts.items() if count > 1) + assert duplicates == [] + + +def test_post_chat_cutoff_uses_the_next_unique_replayable_migration() -> None: + """The Ask cutoff migration remains independently addressable and replayed.""" + + forward = ROOT / "migrations/0054_post_chat_knowledge_cutoff.sql" + rollback = ROOT / "migrations/rollback/0054_post_chat_knowledge_cutoff.sql" + script = (ROOT / "docker/postgres-init/migrate.sh").read_text(encoding="utf-8") + assert forward.is_file() + assert rollback.is_file() + assert not (ROOT / "migrations/0053_post_chat_knowledge_cutoff.sql").exists() + assert "0054_*" in script From 35976554ec6f0c30f1f1df3aa1f32782026b9141 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 23:17:55 -0700 Subject: [PATCH 26/27] test: reproduce Global Ask cutoff bind regression --- tests/test_global_ask_cutoff_contract.py | 54 ++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 tests/test_global_ask_cutoff_contract.py diff --git a/tests/test_global_ask_cutoff_contract.py b/tests/test_global_ask_cutoff_contract.py new file mode 100644 index 000000000..bdcf082ad --- /dev/null +++ b/tests/test_global_ask_cutoff_contract.py @@ -0,0 +1,54 @@ +"""Regression contracts for the final Global Ask source query.""" + +from __future__ import annotations + +import asyncio +from datetime import UTC, datetime + +from backend.app.post_chat_ingestion import gather_global_chat_sources + + +CUTOFF = datetime(2026, 8, 20, 12, 0, tzinfo=UTC) +AUTHORIZED_ENTITY_ID = "00000000-0000-4000-8000-000000000001" + + +class _RecordingConnection: + """Record query arguments while returning an empty authorized corpus.""" + + def __init__(self) -> None: + self.calls: list[tuple[str, tuple[object, ...]]] = [] + + async def fetch(self, query: str, *args: object): + """Record one query call and return no rows.""" + + self.calls.append((query, args)) + return [] + + +def test_final_global_source_query_reuses_scope_and_binds_cutoff() -> None: + """One-shot tenant scope and the cutoff survive into the final SQL call.""" + + connection = _RecordingConnection() + authorized_ids = (value for value in [AUTHORIZED_ENTITY_ID]) + + result = asyncio.run( + gather_global_chat_sources( + connection, + lambda _row: True, + authorized_ids, + question="", + limit=2, + knowledge_cutoff=CUTOFF, + ) + ) + + assert result == [] + final_calls = [ + (query, args) + for query, args in connection.calls + if "array_position($2::uuid[], post_id)" in query + ] + assert len(final_calls) == 1 + final_query, final_args = final_calls[0] + assert "created_at <= $4" in final_query + assert final_args == ([AUTHORIZED_ENTITY_ID], [], 2, CUTOFF) From 0627f4bb769b509001292bb05d3eb2204495401b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 15:19:20 +0900 Subject: [PATCH 27/27] fix: preserve global ask tenant scope --- backend/app/post_chat_ingestion.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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,