{copy.loading}
: null} + {error ? ( +{projectHistoryText(locale, "historyUnavailable")}
+ ) : null} + {!error && projection ? ( +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]
}
/>
+
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 {copy.loading} {projectHistoryText(locale, "historyUnavailable")} {copy.boundary} {copy.truncated}{copy.heading}
+