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/CHANGELOG.md b/CHANGELOG.md index 97ddbc127..c34812a25 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..1bf97a57e --- /dev/null +++ b/backend/app/ask_project_history.py @@ -0,0 +1,310 @@ +"""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 UTC, datetime +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(UTC) + if isinstance(value, datetime): + parsed = value + elif isinstance(value, str) and value.strip(): + try: + 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(UTC) + + +def _cutoff_text(value: datetime) -> str: + """Serialize one validated cutoff as canonical UTC RFC 3339 text.""" + + return value.astimezone(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 1f87e09dc..06f6d2457 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, @@ -2687,9 +2692,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") @@ -2715,18 +2736,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: @@ -2735,7 +2766,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): @@ -2752,7 +2787,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, @@ -2764,8 +2817,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(), } @@ -2786,6 +2839,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( @@ -2798,12 +2852,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) @@ -2831,6 +2896,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, @@ -2852,6 +2922,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: @@ -2880,6 +2953,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, @@ -2890,10 +2974,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..8daf01b1f 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,15 +735,18 @@ 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 """, - list(authorized_corporate_entity_ids), + authorized_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] @@ -759,7 +802,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 +822,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 +860,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 5cc185750..12d60ca98 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" + / "0054_post_chat_knowledge_cutoff.sql" +) _MAJOR_EVENT_ACTION_MIGRATION = ( Path(__file__).resolve().parents[2] / "migrations" / "0100_major_event_action.sql" ) @@ -235,6 +240,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(_PROJECT_BOUND_ACTION_MIGRATION.read_text()) cur.execute(_PROJECT_BOUND_EVENT_MIGRATION.read_text()) diff --git a/docker/postgres-init/migrate.sh b/docker/postgres-init/migrate.sh index f442f628a..712107b27 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_*|0101_*|0102_*) ;; *) 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/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/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 47a2f2a7a..813d19da3 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -325,4 +325,23 @@ runtime note into a shipped/live claim. - 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) + +- 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 + 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. + *This document is continuously updated by the hourly automated agent loop.* 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 270d5f9ac..526d73316 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -76,6 +76,7 @@ import { type PostLineage, type PostSummary, type PostSortOrder, + type ProjectHistoryLink, type RankingList, type PersonRoleHistoryEntry, type RelatedNode, @@ -92,6 +93,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, @@ -293,6 +295,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]; }); @@ -335,6 +340,12 @@ function ChatPanel({ exchanges[0].cited_posts?.[0]?.post_id ?? exchanges[0].cited_post_ids[0] } /> + ) : null} {nameFirstAsk && firstCitedTitle ? ( @@ -415,6 +426,12 @@ function ChatPanel({ citedPostIds={exchange.cited_post_ids} onOpenEvidence={setEvidencePostId} /> + ))} {answer && !exchanges.some((row) => row.answer_text === answer.answer_text) && ( @@ -425,6 +442,12 @@ function ChatPanel({ citedPostIds={answer.cited_post_ids} onOpenEvidence={setEvidencePostId} /> + )} {!nameFirstAsk && evidencePostId ? ( @@ -4598,6 +4621,12 @@ function AskAgentPanel({ window.sessionStorage.getItem(GLOBAL_ASK_SESSION_STORAGE_KEY) ?? 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; @@ -4616,11 +4645,19 @@ function AskAgentPanel({ window.sessionStorage.removeItem(GLOBAL_ASK_SESSION_STORAGE_KEY); nextAnswer = await askAgent(accessToken, normalized); } - setAnswer(nextAnswer); - setSessionId(nextAnswer.session_id); - window.sessionStorage.setItem(GLOBAL_ASK_SESSION_STORAGE_KEY, nextAnswer.session_id); + acceptAnswer(nextAnswer); } 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); @@ -4670,6 +4707,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 1d3bb5a88..838609094 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -282,12 +282,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 { @@ -295,6 +307,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 { @@ -310,6 +325,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.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(); + }); +}); 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/0054_post_chat_knowledge_cutoff.sql b/migrations/0054_post_chat_knowledge_cutoff.sql new file mode 100644 index 000000000..00d05706d --- /dev/null +++ b/migrations/0054_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/0054_post_chat_knowledge_cutoff.sql b/migrations/rollback/0054_post_chat_knowledge_cutoff.sql new file mode 100644 index 000000000..8980fe69f --- /dev/null +++ b/migrations/rollback/0054_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 new file mode 100644 index 000000000..c9420be90 --- /dev/null +++ b/tests/test_ask_project_history.py @@ -0,0 +1,348 @@ +"""Contracts for project histories attached to post-scoped and Global Ask.""" + +from __future__ import annotations + +import asyncio +from datetime import UTC, datetime +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=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_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: + 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_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 + + +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() + + +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/tests/test_ask_project_history_cutoff.py b/tests/test_ask_project_history_cutoff.py new file mode 100644 index 000000000..22c7cd059 --- /dev/null +++ b/tests/test_ask_project_history_cutoff.py @@ -0,0 +1,77 @@ +"""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 "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 + + +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" + 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 "0054_*" in migrate_script 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) 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 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" },