{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 f01138653..8f685e9d1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,6 +20,29 @@ All notable changes to this project are documented here. Format follows
and commitment-derived ticket writes enforce the owning-post authorization
boundary before provider work.
+## [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
+
+- Recovered the credential-free TEPP project-history validation boundary on top of
+ the canonical Buyer timeline. TEPP may return only cutoff-safe temporal
+ associations over the exact authorized events; the timeline remains readable
+ when TEPP is absent, and no result is labelled as a cause (ADR 0127).
+
## [2.18.0] - 2026-08-20
### Added
@@ -28,8 +51,6 @@ All notable changes to this project are documented here. Format follows
bounded, authorized exact-project chronology. The release remains pending
protected-main review and Checks (ADR 0111).
-## [2.19.0] - 2026-08-20
-
### Added
- Opening a Board Weekly VOC post, Calendar commitment, Customer master
@@ -39,7 +60,6 @@ All notable changes to this project are documented here. Format follows
does not add that focus or copy. No TEPP theta is invented. No cited
post, customer, week, or cutoff body is invented (ADR 0100 / ADR 0097
/ ADR 0016).
-
## [2.17.0] - 2026-08-19
### Added
diff --git a/backend/app/analysis_run_start.py b/backend/app/analysis_run_start.py
index 324c6cd67..fd5be4912 100644
--- a/backend/app/analysis_run_start.py
+++ b/backend/app/analysis_run_start.py
@@ -11,7 +11,7 @@
import hashlib
import json
-from datetime import datetime, timezone
+from datetime import UTC, datetime
from typing import Any
from uuid import UUID
@@ -21,13 +21,13 @@
AnalysisRunCreateError,
fetch_visible_analysis_run,
)
-from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL
from backend.app.analysis_run_outbox import (
latest_outbox_delivery_is_claimed,
latest_outbox_delivery_is_delivered,
outbox_request_digest,
)
from backend.app.lineage_ingestion import records_from_source_posts
+from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL
from lineageweave.adjudication_client import AdjudicationClient
from lineageweave.http_client import HttpClientError, post_json
from lineageweave.lineage_persistence import lineage_edge_specs
@@ -119,12 +119,12 @@ def tepp_run_request(
"""Build TEPP's published request from the frozen run, never a theta."""
cutoff = knowledge_cutoff
if cutoff.tzinfo is None:
- cutoff = cutoff.replace(tzinfo=timezone.utc)
+ cutoff = cutoff.replace(tzinfo=UTC)
return AnalysisRunRequest(
idempotency_key=idempotency_key,
tenant_workspace_id=str(corporate_entity_id),
snapshot_id=snapshot_sha256,
- knowledge_cutoff=cutoff.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
+ knowledge_cutoff=cutoff.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
model_contract_version=_TEPP_MODEL_CONTRACT,
output_profile=_TEPP_OUTPUT_PROFILE,
)
@@ -610,7 +610,7 @@ async def deliver_queued_analysis_run(
return await _visible_or_404(
conn, analysis_run_id, account_id, affiliated_entity_ids
)
- now = datetime.now(timezone.utc)
+ now = datetime.now(UTC)
try:
if not latest_outbox_delivery_is_claimed(latest):
await _append_outbox_delivery(
@@ -636,9 +636,8 @@ async def deliver_queued_analysis_run(
affiliated_entity_ids=affiliated_entity_ids,
adjudication_client=adjudication_client,
)
- finished = datetime.now(timezone.utc)
- if finished < now:
- finished = now
+ finished = datetime.now(UTC)
+ finished = max(finished, now)
await _append_outbox_delivery(
conn,
analysis_run_id,
@@ -699,7 +698,7 @@ async def _deliver_lineage_reconstruction(
adjudication_client: AdjudicationClient | None = None,
) -> None:
"""Persist ThreadWeave parent choices for the frozen bag."""
- now = datetime.now(timezone.utc)
+ now = datetime.now(UTC)
member_rows = await _snapshot_member_posts(
conn,
locked["analysis_source_snapshot_id"],
@@ -715,9 +714,8 @@ async def _deliver_lineage_reconstruction(
)
edges = lineage_edge_specs(records_from_source_posts(rows), llm=adjudication_client)
digest = reconstruction_result_digest(edges)
- finished = datetime.now(timezone.utc)
- if finished < now:
- finished = now
+ finished = datetime.now(UTC)
+ finished = max(finished, now)
await conn.execute(
"""
insert into analysis_run_reconstruction
@@ -759,7 +757,7 @@ async def _deliver_tepp_measurement(
tepp_client: TeppClient,
) -> None:
"""Submit the frozen snapshot through ``tepp_client``. Never persist a theta."""
- now = datetime.now(timezone.utc)
+ now = datetime.now(UTC)
request = tepp_run_request(
idempotency_key=str(locked["idempotency_key"]),
snapshot_sha256=str(locked["snapshot_sha256"]),
@@ -767,17 +765,15 @@ async def _deliver_tepp_measurement(
corporate_entity_id=str(locked["corporate_entity_id"]),
)
status_code, failure_code, envelope = _tepp_submission(tepp_client, request)
- if status_code == _SUCCEEDED and envelope is not None:
- if not await _persist_tepp_result(
- conn,
- analysis_run_id=analysis_run_id,
- envelope=envelope,
- ):
- status_code = _FAILED
- failure_code = "tepp_result_not_persisted"
- finished = datetime.now(timezone.utc)
- if finished < now:
- finished = now
+ if status_code == _SUCCEEDED and envelope is not None and not await _persist_tepp_result(
+ conn,
+ analysis_run_id=analysis_run_id,
+ envelope=envelope,
+ ):
+ status_code = _FAILED
+ failure_code = "tepp_result_not_persisted"
+ finished = datetime.now(UTC)
+ finished = max(finished, now)
await _append_status(
conn,
analysis_run_id,
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 eabd4a638..820c6dd91 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -190,6 +190,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,
@@ -200,6 +205,10 @@
fetch_project_history_index,
fetch_project_history_projection,
)
+from backend.app.tepp_project_history import (
+ tenant_workspace_reference,
+ validate_project_history_with_tepp,
+)
from lineageweave.project_history import normalize_project_key
from backend.app.demo_scope import (
fetch_demo_corporate_entity_ids,
@@ -2704,9 +2713,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")
@@ -2732,18 +2757,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:
@@ -2752,7 +2787,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):
@@ -2769,7 +2808,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,
@@ -2781,8 +2838,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(),
}
@@ -2803,15 +2860,15 @@ 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 = None
- if request.knowledge_cutoff is not None and request.knowledge_cutoff.strip():
- try:
- knowledge_cutoff = parse_as_of_clock(request.knowledge_cutoff)
- except ValueError as exc:
- raise HTTPException(
- status.HTTP_422_UNPROCESSABLE_ENTITY,
- "knowledge_cutoff must be an ISO-8601 timestamp",
- ) from exc
+ try:
+ knowledge_cutoff = ask_knowledge_cutoff(
+ request.knowledge_cutoff.strip() if request.knowledge_cutoff else None
+ )
+ except ValueError as exc:
+ raise HTTPException(
+ status.HTTP_422_UNPROCESSABLE_ENTITY,
+ "knowledge_cutoff must be an ISO-8601 timestamp",
+ ) from exc
client = _post_chat_client()
if not client.available:
raise HTTPException(
@@ -2824,6 +2881,16 @@ 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,
@@ -2858,14 +2925,25 @@ 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,
)
- grounding_status = ask_grounding_status(sources, knowledge_cutoff)
- limitations = historical_body_limitations(sources)
- cutoff_text = knowledge_cutoff.isoformat() if knowledge_cutoff is not None else None
- llm_sources = [source for source in sources if not source.historical_body_unavailable]
+ try:
+ grounding_status = ask_grounding_status(sources, knowledge_cutoff)
+ limitations = historical_body_limitations(sources)
+ cutoff_text = knowledge_cutoff.isoformat() if knowledge_cutoff is not None else None
+ llm_sources = [source for source in sources if not source.historical_body_unavailable]
+ except Exception as exc: # noqa: BLE001 - malformed evidence must fail closed.
+ raise HTTPException(
+ status.HTTP_503_SERVICE_UNAVAILABLE,
+ "Ask Agent is unavailable: contextual-orchestrator returned no complete evidence object",
+ ) from exc
if not llm_sources:
async with pool.acquire() as conn:
await persist_global_ask_turn(conn, conversation.session_id, question, "", ())
@@ -2879,11 +2957,13 @@ async def ask_agent(
"session_id": conversation.session_id,
"answer_text": "",
"cited_post_ids": [],
- "cited_posts": cited_post_citations(sources, [source.post_id for source in sources]),
"source_post_ids": [source.post_id for source in sources],
"cited_post_evidence": [],
- "timeline": global_ask_timeline(sources),
+ "project_histories": [],
+ "project_histories_truncated": False,
+ "cited_posts": [],
"knowledge_cutoff": cutoff_text,
+ "timeline": global_ask_timeline(sources),
"grounding_status": grounding_status,
"limitations": limitations,
"next_action": ask_next_action(
@@ -2918,6 +2998,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,
@@ -2944,6 +3035,7 @@ async def ask_agent(
has_sources=True,
has_retained_bodies=bool(llm_sources),
),
+ **answer_evidence.response_fields(),
}
@@ -3496,7 +3588,7 @@ async def read_project_history(
) from exc
async with pool.acquire() as conn:
try:
- return await fetch_project_history_projection(
+ projection = await fetch_project_history_projection(
conn,
project_key=project_key,
focus_post_id=focus_post_id,
@@ -3506,6 +3598,13 @@ async def read_project_history(
)
except ProjectHistoryNotFound as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, "project history not found") from exc
+ projection["tepp_validation"] = await asyncio.to_thread(
+ validate_project_history_with_tepp,
+ projection=projection,
+ tenant_workspace_id=tenant_workspace_reference(account.corporate_entity_ids),
+ transport_url=load_settings().tepp_transport_url,
+ )
+ return projection
@app.get("/api/rankings")
diff --git a/backend/app/post_chat_ingestion.py b/backend/app/post_chat_ingestion.py
index d5d9b2ec4..6c47d499f 100644
--- a/backend/app/post_chat_ingestion.py
+++ b/backend/app/post_chat_ingestion.py
@@ -45,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 .source_post_revision import fetch_cutoff_revisions
from lineageweave.ontology import ontology_annotations
@@ -344,6 +345,16 @@ async def _graph_facts_for_posts(
_GLOBAL_ASK_TERM_PATTERN = re.compile(r"[^\W_]+(?:-[^\W_]+)*", re.UNICODE)
_POST_CHAT_SOURCE_LIMIT = 6
_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, ...]:
@@ -458,6 +469,7 @@ async def gather_chat_sources(
can_see_post: Callable[[asyncpg.Record], bool],
vision_client: ImageContentClient | None = None,
*,
+ knowledge_cutoff: datetime | None = None,
session_id: str | None = None,
metadata: dict[str, str] | None = None,
) -> list[ChatSourceDocument]:
@@ -468,16 +480,29 @@ async def gather_chat_sources(
"""
if vision_client is None:
vision_client = NullImageContentClient()
-
- anchor = await conn.fetchrow(
+ cutoff = _ask_cutoff(knowledge_cutoff) if knowledge_cutoff is not None else None
+ anchor_sql = (
"select post_id, post_title, visibility_code, corporate_entity_id, 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",
- post_id,
+ f"source_project_name from source_post where post_id = $1 and {_SOURCE_ELIGIBILITY}"
+ if cutoff is None
+ else "select post_id, post_title, visibility_code, corporate_entity_id, 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, "
+ f"source_project_name from source_post where post_id = $1 "
+ f"and created_at <= $2 and {_SOURCE_ELIGIBILITY}"
+ )
+ anchor = (
+ await conn.fetchrow(anchor_sql, post_id)
+ if cutoff is None
+ else await conn.fetchrow(anchor_sql, post_id, cutoff)
)
if anchor is None or not can_see_post(anchor):
return []
@@ -516,16 +541,35 @@ async def gather_chat_sources(
if not candidate_ids:
return sources
- rows = await conn.fetch(
+ linked_sql = (
"select post_id, post_title, visibility_code, corporate_entity_id, 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 = any($1::uuid[]) "
- "order by array_position($1::uuid[], post_id)",
- candidate_ids,
+ f"from source_post where post_id = any($1::uuid[]) and {_SOURCE_ELIGIBILITY} "
+ "order by array_position($1::uuid[], post_id)"
+ if cutoff is None
+ else "select post_id, post_title, visibility_code, corporate_entity_id, 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 "
+ 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"
+ )
+ rows = (
+ await conn.fetch(linked_sql, candidate_ids)
+ if cutoff is None
+ else await conn.fetch(
+ linked_sql,
+ candidate_ids,
+ _POST_CHAT_CANDIDATE_LIMIT,
+ cutoff,
+ )
)
admitted_rows = [row for row in rows if can_see_post(row)]
direct_rows = sorted(
@@ -604,29 +648,34 @@ def _clock_iso(value: datetime) -> str:
def _global_ask_candidate_sql(*, knowledge_cutoff: bool) -> str:
if not knowledge_cutoff:
- return """
+ eligibility = SOURCE_POST_ELIGIBILITY_SQL.format(alias="source_post")
+ return 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 {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 {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 {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 {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,
@@ -639,10 +688,13 @@ def _global_ask_candidate_sql(*, knowledge_cutoff: bool) -> str:
order by created_at desc, post_id desc
limit 32
"""
+ eligibility = SOURCE_POST_ELIGIBILITY_SQL.format(alias="sp")
covering = (
- "spr.written_at <= $2 "
- "and (spr.superseded_at is null or spr.superseded_at > $2) "
- "and sp.created_at <= $2"
+ "spr.written_at <= $3 "
+ "and (spr.superseded_at is null or spr.superseded_at > $3) "
+ "and sp.created_at <= $3 "
+ "and (sp.visibility_code = 'public' or sp.corporate_entity_id::text = any($2::text[])) "
+ f"and {eligibility}"
)
return f"""
select post_id, matched_in
@@ -697,6 +749,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()
@@ -741,7 +795,9 @@ async def gather_global_chat_sources(
candidate_sql = _global_ask_candidate_sql(knowledge_cutoff=knowledge_cutoff is not None)
for term in search_terms:
candidate_args: tuple[object, ...] = (
- (term, knowledge_cutoff) if knowledge_cutoff is not None else (term,)
+ (term, authorized_entity_ids, cutoff)
+ if knowledge_cutoff is not None
+ else (term, authorized_entity_ids)
)
candidate_rows = await conn.fetch(candidate_sql, *candidate_args)
for row in candidate_rows:
@@ -770,7 +826,7 @@ async def gather_global_chat_sources(
"join source_post on source_post.post_id = parent_post_id "
"where child_post_id = $1 and source_post.created_at <= $2",
lineage_anchor_id,
- knowledge_cutoff,
+ cutoff,
)
else:
lineage_rows = await conn.fetch(
@@ -792,55 +848,34 @@ async def gather_global_chat_sources(
candidate_ids = candidate_ids[:candidate_budget]
lineage_neighbor_id_set = frozenset(lineage_neighbor_ids)
- if knowledge_cutoff is not None:
- rows = await conn.fetch(
- """
- select post_id, post_title, post_body, visibility_code, corporate_entity_id,
- created_at, updated_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 (visibility_code = 'public'
- or corporate_entity_id::text = any($1::text[]))
- and created_at <= $4
- order by array_position($2::uuid[], post_id) nulls last,
- created_at desc, post_id desc
- limit $3
- """,
- list(authorized_corporate_entity_ids),
- candidate_ids,
- limit,
- knowledge_cutoff,
- )
- else:
- rows = await conn.fetch(
- """
- 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,
- 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 visibility_code = 'public'
- or corporate_entity_id::text = any($1::text[])
- order by array_position($2::uuid[], post_id) nulls last,
- created_at desc, post_id desc
- limit $3
- """,
- list(authorized_corporate_entity_ids),
- candidate_ids,
- limit,
- )
+ rows = await conn.fetch(
+ f"""
+ select post_id, post_title, post_body, visibility_code, corporate_entity_id,
+ created_at, updated_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 (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
+ """,
+ 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]
anchor_is_visible = lineage_anchor_id in visible_ids
cutoff_revisions = (
- await fetch_cutoff_revisions(conn, visible_ids, knowledge_cutoff)
+ await fetch_cutoff_revisions(conn, visible_ids, cutoff)
if knowledge_cutoff is not None
else {}
)
@@ -897,7 +932,7 @@ async def gather_global_chat_sources(
if getattr(updated_at, "tzinfo", None) is None:
live_clock = updated_at.replace(tzinfo=timezone.utc)
try:
- live_after_cutoff = live_clock > knowledge_cutoff
+ live_after_cutoff = live_clock > cutoff
except TypeError:
live_after_cutoff = False
sources.append(
@@ -938,7 +973,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,
@@ -958,6 +993,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
@@ -995,23 +1031,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/app/tepp_project_history.py b/backend/app/tepp_project_history.py
new file mode 100644
index 000000000..e7951b520
--- /dev/null
+++ b/backend/app/tepp_project_history.py
@@ -0,0 +1,208 @@
+"""Map the canonical Buyer project history into TEPP's strict wire contract."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+from collections.abc import Iterable, Mapping, Sequence
+from typing import Any
+
+from lineageweave.tepp_project_history import (
+ PROJECT_HISTORY_CONTRACT_VERSION,
+ TeppProjectHistoryClient,
+ TeppProjectHistoryInvalidResponse,
+ TeppProjectHistoryUnavailable,
+ parse_rfc3339_utc,
+ project_history_event_sort_key,
+ validate_tepp_project_history_request,
+)
+
+
+def tenant_workspace_reference(corporate_entity_ids: Iterable[str]) -> str:
+ """Return a deterministic opaque workspace reference for the ABAC scope."""
+
+ normalized = sorted({str(value).strip() for value in corporate_entity_ids if str(value).strip()})
+ material = "\u001f".join(normalized) if normalized else "public-only"
+ digest = hashlib.sha256(material.encode("utf-8")).hexdigest()
+ return f"lw-workspace-{digest}"
+
+
+def _utc_text(value: object, field_name: str) -> str:
+ """Return canonical UTC text from one offset-aware source timestamp."""
+
+ return parse_rfc3339_utc(value, field_name)[1]
+
+
+def _opaque_actor_ids(
+ event: Mapping[str, Any],
+ *,
+ tenant_workspace_id: str,
+) -> list[str]:
+ """Hash canonical actor keys so names and local identifiers do not cross."""
+
+ raw_roles = event.get("responsibility_evidence")
+ if raw_roles is None:
+ raw_roles = event.get("observed_responsibilities")
+ if not isinstance(raw_roles, Sequence) or isinstance(raw_roles, (str, bytes)):
+ raw_roles = ()
+ actor_ids: set[str] = set()
+ for role in raw_roles:
+ if not isinstance(role, Mapping):
+ continue
+ actor_key = str(role.get("actor_key") or "").strip()
+ if not actor_key:
+ continue
+ material = f"{tenant_workspace_id}\u0000{actor_key}".encode("utf-8")
+ actor_ids.add(f"lw-actor-{hashlib.sha256(material).hexdigest()}")
+ return sorted(actor_ids)
+
+
+def _evidence_text(event: Mapping[str, Any]) -> str:
+ """Build bounded source-field evidence without sending a post body."""
+
+ title = str(event.get("event_title") or "").strip()
+ event_type = str(event.get("event_type_code") or "").strip()
+ if not title or not event_type:
+ raise TeppProjectHistoryUnavailable("canonical event title and type are required")
+ parts = [title, f"event_type={event_type}"]
+ for key in ("source_stage_code", "source_detail_state_code", "voc_type_code"):
+ value = str(event.get(key) or "").strip()
+ if value:
+ parts.append(f"{key}={value}")
+ rendered = " | ".join(parts)
+ encoded = rendered.encode("utf-8")
+ if len(encoded) <= 4096:
+ return rendered
+ return encoded[:4096].decode("utf-8", errors="ignore").rstrip()
+
+
+def _idempotency_key(request_without_key: Mapping[str, Any]) -> str:
+ """Hash the exact authorized evidence bundle into a stable request key."""
+
+ material = json.dumps(
+ request_without_key,
+ ensure_ascii=False,
+ sort_keys=True,
+ separators=(",", ":"),
+ )
+ digest = hashlib.sha256(material.encode("utf-8")).hexdigest()
+ return f"lineageweave-project-history-{digest}"
+
+
+def build_tepp_project_history_request(
+ *,
+ projection: Mapping[str, Any],
+ tenant_workspace_id: str,
+) -> dict[str, Any]:
+ """Build TEPP #159 input from the already-authorized canonical timeline."""
+
+ if projection.get("contract_version") != 1:
+ raise TeppProjectHistoryUnavailable("unsupported canonical project-history version")
+ events_value = projection.get("events")
+ if not isinstance(events_value, Sequence) or isinstance(events_value, (str, bytes)):
+ raise TeppProjectHistoryUnavailable("canonical project history has no event list")
+ cutoff = _utc_text(projection.get("knowledge_cutoff"), "knowledge_cutoff")
+ events: list[dict[str, Any]] = []
+ for value in events_value:
+ if not isinstance(value, Mapping):
+ raise TeppProjectHistoryUnavailable("canonical project event must be an object")
+ occurred_at = _utc_text(value.get("occurred_at"), "occurred_at")
+ event_id = str(value.get("event_id") or "").strip()
+ source_post_id = str(value.get("source_post_id") or "").strip()
+ if not event_id or not source_post_id:
+ raise TeppProjectHistoryUnavailable("canonical project event identity is missing")
+ events.append(
+ {
+ "event_id": event_id,
+ "event_type_code": str(value.get("event_type_code") or "").strip(),
+ "event_title": str(value.get("event_title") or "").strip(),
+ "occurred_at": occurred_at,
+ # The canonical timeline explicitly declares source-post creation
+ # time as its fallback clock. It is therefore also the earliest
+ # evidence-availability instant LineageWeave can substantiate.
+ "available_at": occurred_at,
+ "source_post_id": source_post_id,
+ "evidence_text": _evidence_text(value),
+ "actor_ids": _opaque_actor_ids(
+ value,
+ tenant_workspace_id=tenant_workspace_id,
+ ),
+ }
+ )
+ events.sort(key=project_history_event_sort_key)
+ request: dict[str, Any] = {
+ "contract_version": PROJECT_HISTORY_CONTRACT_VERSION,
+ "tenant_workspace_id": tenant_workspace_id,
+ "project_key": str(projection.get("project_key") or "").strip(),
+ "project_name": str(projection.get("project_name") or "").strip(),
+ "knowledge_cutoff": cutoff,
+ "focus_event_id": str(projection.get("focus_event_id") or "").strip(),
+ "events": events,
+ }
+ request["idempotency_key"] = _idempotency_key(request)
+ return validate_tepp_project_history_request(request)
+
+
+def _buyer_metadata(projection: Mapping[str, Any]) -> dict[str, Any]:
+ """Strip duplicate event rows while preserving TEPP findings and evidence IDs."""
+
+ events = projection["events"]
+ return {
+ "contract_version": projection["contract_version"],
+ "project_key": projection["project_key"],
+ "project_name": projection["project_name"],
+ "focus_event_id": projection["focus_event_id"],
+ "knowledge_cutoff": projection["knowledge_cutoff"],
+ "history_span_start": projection["history_span_start"],
+ "history_span_end": projection["history_span_end"],
+ "participant_count": projection["participant_count"],
+ "inference_status": projection["inference_status"],
+ "event_count": len(events),
+ "findings": projection["findings"],
+ }
+
+
+def validate_project_history_with_tepp(
+ *,
+ projection: Mapping[str, Any],
+ tenant_workspace_id: str,
+ transport_url: str,
+) -> dict[str, Any]:
+ """Return optional TEPP metadata without hiding the canonical timeline."""
+
+ if not transport_url.strip():
+ return {
+ "status": "not_configured",
+ "project_history": None,
+ "next_action_code": "configure_tepp_project_history",
+ }
+ try:
+ request = build_tepp_project_history_request(
+ projection=projection,
+ tenant_workspace_id=tenant_workspace_id,
+ )
+ except TeppProjectHistoryUnavailable:
+ return {
+ "status": "invalid_evidence",
+ "project_history": None,
+ "next_action_code": "open_source_evidence",
+ }
+ try:
+ validated = TeppProjectHistoryClient(transport_url).project(request)
+ except TeppProjectHistoryInvalidResponse:
+ return {
+ "status": "invalid_evidence",
+ "project_history": None,
+ "next_action_code": "open_source_evidence",
+ }
+ except TeppProjectHistoryUnavailable:
+ return {
+ "status": "unavailable",
+ "project_history": None,
+ "next_action_code": "retry_tepp_project_history",
+ }
+ return {
+ "status": "validated",
+ "project_history": _buyer_metadata(validated),
+ "next_action_code": "open_source_evidence",
+ }
diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py
index f2936a8bf..524b618dd 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"
)
@@ -241,6 +246,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..de521c1c0
--- /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, ADR 0127, 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/adr/0127-recover-tepp-validation-on-canonical-project-history.md b/docs/adr/0127-recover-tepp-validation-on-canonical-project-history.md
new file mode 100644
index 000000000..5a42d964e
--- /dev/null
+++ b/docs/adr/0127-recover-tepp-validation-on-canonical-project-history.md
@@ -0,0 +1,98 @@
+# ADR 0127: Recover TEPP validation on the canonical project history
+
+- Status: Proposed
+- Date: 2026-08-21
+- Depends on: LineageWeave Project history stack; `ContextualWisdomLab/TEPP#159`
+- Supersedes: the duplicate project-history implementation carried by LineageWeave #281/#282
+
+## Context
+
+A Buyer project-history timeline was implemented on a canonical, authorization-first
+LineageWeave read model. An earlier TEPP integration was then left behind in a closed
+parent PR and an open child PR whose branch reimplemented the project query, event
+classification, and timeline. The user-supplied product screen requires one project
+lifecycle timeline and an optional TEPP-linked answer, not two competing histories.
+
+The TEPP contract in PR #159 accepts only an exact project identity, a knowledge cutoff,
+a focus event, and explicit source-grounded events. It may order those events and return
+coded temporal-association findings. It does not accept or return a latent score, a
+probability of causation, or an authoritative assignment record.
+
+## Decision
+
+1. LineageWeave remains authoritative for RBAC/ABAC, source eligibility, exact project
+ identity, event classification, visible responsibility evidence, and the Buyer
+ timeline.
+2. The TEPP request is derived from that already-authorized canonical projection. No
+ second database query or second timeline component is allowed.
+3. Source-post creation time is sent as both `occurred_at` and `available_at` only because
+ the canonical timeline explicitly declares it as the current fallback clock. The UI
+ continues to disclose that limitation.
+4. Actor names and local actor keys do not cross the service boundary. TEPP receives a
+ deterministic opaque SHA-256 reference scoped to the authorized workspace. This is a
+ data-minimizing pseudonymous reference, not a claim of irreversible anonymization.
+5. Evidence text is bounded and composed from the event title and persisted source-state
+ fields. Post bodies, browser tokens, review credentials, provider keys, and
+ `TEPP_API_KEY` are not forwarded.
+6. The client requires the exact versioned field set, exact event cardinality and content,
+ deterministic chronological ordering, unchanged project/focus/cutoff identity, and
+ evidence-derived participant counts. Unknown fields, changed evidence, or a response above
+ TEPP's published 256 KiB contract limit fail closed before JSON decoding.
+7. Accepted findings are limited to the six published TEPP #159 finding codes. Duplicate
+ event or evidence references are rejected. Buyer UI copy is owned by LineageWeave and
+ keyed by those codes; provider-authored summary prose is retained for contract
+ validation but is not rendered as the interpretation.
+8. `temporal_association_only` is the only accepted inference status. Buyer copy states
+ that the result does not identify a cause.
+9. A transport outage is distinct from an invalid response. `not_configured`,
+ `unavailable`, and `invalid_evidence` states leave the canonical timeline readable and
+ tell the operator or Buyer what to do next.
+10. Global Ask and post-scoped Ask are a subsequent stacked slice and must reuse this same
+ canonical projection and TEPP envelope.
+11. Any unexpected TEPP transport/provider exception is converted to the stable
+ `TEPP transport request failed` state. Raw response bodies and exception text remain
+ internal chained causes and never cross the public contract.
+
+## Consequences
+
+- The previously implemented capability is recovered without reviving the orphaned
+ duplicate stack.
+- A TEPP outage cannot remove or alter authorized LineageWeave evidence.
+- TEPP findings remain inspectable through exact source-post references.
+- An unrecognized finding vocabulary cannot introduce provider-authored Buyer claims.
+- The product does not answer “what caused the VOC?” as a causal claim. It answers which
+ explicit prior records are temporally associated and provides evidence for human review.
+- A future distinct event-time or available-time source can replace the current fallback
+ only through a versioned contract and migration.
+
+## Rejected alternatives
+
+- **Merge the old #282 branch as-is.** It is based on a closed parent and carries a second
+ project-history implementation with a large unrelated ancestry.
+- **Let TEPP query the LineageWeave database.** This breaks authorization ownership and
+ modular deployment.
+- **Send full post bodies or actor names.** These are unnecessary for the published
+ temporal contract and expand the privacy boundary.
+- **Render a separate TEPP timeline.** Duplicate timelines can disagree and obscure which
+ system owns evidence selection.
+- **Render arbitrary TEPP summary prose.** The provider may validate time, but it does not
+ own Buyer-facing interpretation or an open-ended claim vocabulary.
+- **Describe preceding events as causes.** Event order alone does not identify causality.
+
+## References
+
+Allen, J. F. (1983). Maintaining knowledge about temporal intervals. *Communications of
+the ACM, 26*(11), 832–843. https://doi.org/10.1145/182.358434
+
+World Wide Web Consortium. (2013). *PROV-O: The PROV ontology*.
+https://www.w3.org/TR/prov-o/
+
+World Wide Web Consortium. (2017). *Time ontology in OWL*.
+https://www.w3.org/TR/owl-time/
+
+MITRE. (n.d.). *CWE-209: Generation of error message containing sensitive information*.
+https://cwe.mitre.org/data/definitions/209.html
+
+National Institute of Standards and Technology. (2020). *Security and privacy controls
+for information systems and organizations: NIST SP 800-53 Rev. 5*.
+https://doi.org/10.6028/NIST.SP.800-53r5
diff --git a/docs/adr/0128-authorized-project-history-buyer-surface.md b/docs/adr/0128-authorized-project-history-buyer-surface.md
new file mode 100644
index 000000000..c0c3b4c10
--- /dev/null
+++ b/docs/adr/0128-authorized-project-history-buyer-surface.md
@@ -0,0 +1,74 @@
+# ADR 0128: Authorized project-history buyer surface
+
+- Status: Proposed on PR #285; not protected-main behavior
+- Date: 2026-08-20
+- Figma file: `SBpgot7uTvMxEaxUwvoc0S`
+- Figma frames: `308:2` (desktop), `309:2` (mobile), `309:50` (evidence boundary), `310:2` (selected event)
+
+## Context
+
+Project evidence existed as post-level hints, but a buyer could not select one
+exact authorized project and follow its visible chronology. A fuzzy project
+search would create false joins, while a post-only view hides repeated
+responsibility, event, and related-lineage evidence. The feature must remain
+source-grounded: a semantic mention is an inferred candidate, a source field
+is an observed hint, and a lineage edge is related history rather than proof of
+causation.
+
+## Decision
+
+Add a bounded project index and project-history read model behind the existing
+`post_read` RBAC and source-eligibility plus public/same-corporate-entity ABAC
+checks. Normalize exact project identities with the same Unicode-compatible
+key on both reads. Apply the knowledge cutoff before selecting event IDs, then
+constrain matches, roles, and lineage paths to that authorized ID set.
+The project index first bounds its input to the newest authorized source rows,
+marks the response truncated when that bound is reached, and applies a local
+five-second PostgreSQL statement timeout. Expression and recency indexes support
+the bounded list and exact-detail paths; forward and rollback migrations remain
+symmetric. All response clocks use canonical UTC RFC 3339 `Z` serialization.
+
+Expose the read model through the Buyer `Project history` destination and the
+post-detail project-evidence card. Both entry points use the same
+`ProjectHistoryTimeline`; source-post drill-through returns to the Board while
+preserving Project History as the Event Lineage focus. Counts, display names,
+responsibility transitions, and related paths are bounded projections, not an
+HR ledger or a causal graph.
+
+The UI uses the existing design-token and Storybook component boundary. The
+Figma file above is the design source for the desktop, mobile, and evidence
+boundary states; no second component-specific token system is introduced.
+
+## Consequences
+
+- Buyers can move from an exact project identity to authorized chronology and
+ source evidence in one workflow.
+- Hidden or post-cutoff records cannot affect the index, counts, transitions,
+ or related paths.
+- Semantic project mentions remain visibly inferred and do not overwrite a
+ source project identity.
+- The current document-time fallback remains explicit until a durable event
+ clock is introduced.
+- The project chooser is a bounded recent-project view, not an unbounded catalog
+ export; buyers are told when its source or display limit is reached.
+- A future customer-master graph may reuse the projection pattern, but this
+ ADR deliberately does not invent organization roles or temporal facts that
+ are absent from persisted evidence.
+
+## Verification
+
+- `tests/test_project_history.py` covers exact normalization, lifecycle
+ classification, responsibility evidence, and bounded index SQL.
+- `backend/tests/test_api.py` covers live PostgreSQL/API index and history
+ reads, cutoff propagation, source/semantic project evidence, and malformed
+ project/focus inputs.
+- Frontend tests, lint, production build, and Storybook cover the shared
+ destination, post-detail entry point, and keyboard-accessible timeline.
+
+## References
+
+World Wide Web Consortium. (2013). *PROV-O: The PROV ontology*.
+https://www.w3.org/TR/prov-o/
+
+World Wide Web Consortium. (2021). *WAI-ARIA Authoring Practices 1.2*.
+https://www.w3.org/WAI/ARIA/apg/
diff --git a/docs/adr/0125-customer-master-three-pane-workspace.md b/docs/adr/0129-customer-master-three-pane-workspace.md
similarity index 99%
rename from docs/adr/0125-customer-master-three-pane-workspace.md
rename to docs/adr/0129-customer-master-three-pane-workspace.md
index b6ae71a74..3f83cd7fb 100644
--- a/docs/adr/0125-customer-master-three-pane-workspace.md
+++ b/docs/adr/0129-customer-master-three-pane-workspace.md
@@ -1,4 +1,4 @@
-# ADR 0125: Customer-centered three-pane Customer Master workspace
+# ADR 0129: Customer-centered three-pane Customer Master workspace
- **Status:** Accepted
- **Date:** 2026-08-21
diff --git a/docs/adr/0127-ask-agent-evidence-workspace.md b/docs/adr/0137-ask-agent-evidence-workspace.md
similarity index 98%
rename from docs/adr/0127-ask-agent-evidence-workspace.md
rename to docs/adr/0137-ask-agent-evidence-workspace.md
index c1381ae20..716d8029f 100644
--- a/docs/adr/0127-ask-agent-evidence-workspace.md
+++ b/docs/adr/0137-ask-agent-evidence-workspace.md
@@ -1,4 +1,4 @@
-# ADR 0127 — Ask Agent evidence workspace and composer contract
+# ADR 0137 — Ask Agent evidence workspace and composer contract
**Decision status:** Accepted
**Date:** 2026-08-21
diff --git a/docs/adr/0127-authenticated-mcp-global-ask.md b/docs/adr/0138-authenticated-mcp-global-ask.md
similarity index 99%
rename from docs/adr/0127-authenticated-mcp-global-ask.md
rename to docs/adr/0138-authenticated-mcp-global-ask.md
index 3fb320b35..f703cadcc 100644
--- a/docs/adr/0127-authenticated-mcp-global-ask.md
+++ b/docs/adr/0138-authenticated-mcp-global-ask.md
@@ -1,4 +1,4 @@
-# ADR 0127: Authenticated MCP Global Ask
+# ADR 0138: Authenticated MCP Global Ask
- **Status:** Accepted
- **Date:** 2026-08-20
diff --git a/docs/adr/0128-valkey-account-operation-events.md b/docs/adr/0139-valkey-account-operation-events.md
similarity index 96%
rename from docs/adr/0128-valkey-account-operation-events.md
rename to docs/adr/0139-valkey-account-operation-events.md
index bb317f570..c64947c53 100644
--- a/docs/adr/0128-valkey-account-operation-events.md
+++ b/docs/adr/0139-valkey-account-operation-events.md
@@ -1,4 +1,4 @@
-# ADR 0128: Register account operation events in Valkey
+# ADR 0139: Register account operation events in Valkey
- Status: Accepted
- Date: 2026-08-20
diff --git a/docs/doctoring/MCP_REFERENCES.md b/docs/doctoring/MCP_REFERENCES.md
index 3aefb9f5b..1d251c039 100644
--- a/docs/doctoring/MCP_REFERENCES.md
+++ b/docs/doctoring/MCP_REFERENCES.md
@@ -12,7 +12,7 @@
| Retrieval-augmented generation | Retrieve authorized sources, then source-only reason-and-cite | `backend/app/global_ask.py`; `lineageweave.post_chat` |
| FEVER claim verification | Keep Supported / Refuted / insufficient-evidence judgment tied to retrieved evidence, not model memory | `backend/app/global_ask_verification.py`; external-verification regressions |
| Data-boundary minimization | Open-web verification is explicit opt-in; the internal answer body is never a Searxng search query | `global_ask(..., verify_external=false)`; privacy-boundary regression |
-| Keycloak startup realm import | Treat `--import-realm` as fresh-environment bootstrap because an existing realm is skipped | `docker/keycloak/entrypoint.sh`; ADR 0127 |
+| Keycloak startup realm import | Treat `--import-realm` as fresh-environment bootstrap because an existing realm is skipped | `docker/keycloak/entrypoint.sh`; ADR 0138 |
| Keycloak Admin REST protocol-mapper endpoints | Reconcile only the named MCP audience mapper with bounded GET/POST/PUT operations | `backend/app/keycloak_audience_reconciler.py`; persistent-port-change regressions |
| Point-of-disclosure authorization | Re-check live `post_read` and corporate affiliation state before cited image bytes leave the database boundary | `backend/app/global_ask_media.py`; permission-revocation regressions |
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index ce23014e4..677552de7 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -3,20 +3,69 @@
**Active Ask Agent design head:** PR #353, stacked directly on #264 exact head `39f21261052a9d2ae82c4b851a54831eaf909805`; this is proposed work until merged.
## 1. Known Parsing & Frontend Display Gaps
-- **Footnote Parsing**: `post=00505695-3e61-1fd1-83c5-263f88a9e77a` fails to recognize footnotes (li/oi level errors).
-- **Table Parsing**: `post=00505695-3e61-1fd1-80c6-86bb61c8ddc5` completely fails at parsing tables.
-- **Indentation**: Incorrect indentation rendering in `post=00505695-7571-1fd1-83c3-d521b187ad5b` and `post=00505695-3e61-1fd1-83c0-497b3c1c455e`.
-- **Image/Table OCR**: `post=00505695-7571-1fd1-83dd-3d22a61a5734` fails text recognition for tables inside images, markdown parsing fails, and image OCR description is too shallow for Ontology & Semantics.
-- **Math/Superscripts**: `post=00505695-9612-1fe1-83a7-e30153323f25` fails to parse superscripts like m^3 properly. Needs strict Ontology grammar for math formulas.
-- **Missing UI Elements**: DAG (Directed Acyclic Graph) view is currently missing from the frontend for `post=00505695-7571-1fd1-83c5-895ed333cdbc`.
-
-## 2. LLM Extraction & Knowledge Graph Gaps
-- **Multiple Project Extraction**: (Resolved) LLM prompt updated to request key_events as objects with project_name, separating events correctly.
-- **5W1H Missing**: (Resolved) LLM prompt updated to explicitly request 5W1H evidence items in the JSON output array.
-- **R&R and Keyman Missing**: (Resolved) LLM prompt updated to explicitly instruct using actual stated names rather than collective titles.
-- **Entity Resolution / Searxng**: Abbreviations like "한전" and "한국전력" are not mapped properly using Searxng and KG corroboration.
-- **Meso-level Team Mapping**: (Resolved) Checked extraction logic; `team` mapping logic is present and correct, but LLM needed better explicit instruction which is covered by R&R resolution.
-- **Base64 Image Omni-modal**: Current text-only embedding fails on images. Omni-modal LLM processing is required for images to capture layout, font size, colors, and spatial meaning.
+- **Footnote and table parsing**: rich-text exports still require synthetic regression cases for footnote ownership, table-row grouping, and nested list semantics.
+- **Indentation**: mixed source whitespace, CSS, and OOXML indentation must remain distinguishable so visual alignment cannot manufacture hierarchy.
+- **Image/table OCR**: partial visual regions, table text, markdown-like source, and image captions require persisted, position-aware evidence or an explicit unavailable state.
+- **Math/superscripts**: superscript and formula-like source needs a semantic-unit grammar that preserves the original text and exposes normalized search text.
+- **Buyer navigation**: Board, Project History, Global Ask, Customer Master, Calendar, and Admin routes must preserve source focus and the next actionable step across transitions.
+
+Exact private runtime identifiers are intentionally omitted; the authorized
+runtime and synthetic fixtures retain the reproducibility detail.
+
+## Historical exact-head checkpoint (2026-08-20 19:14 Asia/Seoul)
+
+The following is the current GitHub observation used for this branch. It
+supersedes the historical 17:08 snapshot and does not claim protected-main
+behavior. GitHub reports 24 open PRs from #190 through #309; none of the
+`#258`-and-later stack has an independent `APPROVED` review at this checkpoint.
+
+| PR group | Exact observed heads | Merge observation |
+|---|---|---|
+| #258-#266 | `#258 f8d2fa98`, `#260 dfd95d9c`, `#261 bd1b4d2f`, `#262 80445b8a`, `#263 d670acd5`, `#264 d5dbdf71`, `#266 26a6d9c6` | `BLOCKED`, review required |
+| #270-#276 | `#270 c58aef89`, `#275 35035783`, `#276 55679fa2` | `BLOCKED`, review required or draft |
+| #282-#287 | `#282 6eeaf89d`, `#285 cbb959ce`, `#286 65a461de`, `#287 554efb9b` | `UNSTABLE`/`UNKNOWN`/`BLOCKED`; not merge-ready |
+| #298-#303 | `#298 49c9976f`, `#301 59ccdf91`, `#302 40b0a8ea`, `#303 fe0a4f26` | `UNKNOWN`/`CLEAN`/`UNSTABLE`; independent review pending |
+| #306-#309 | `#306 e0dbc386`, `#307 313d38a4`, `#308 42e6230c`, `#309 e6fd907e` | `UNSTABLE`; independent review pending |
+
+PR #285 received concurrent remote commits through `cbb959ce` while its local
+Buyer wiring was under review. Those commits were incorporated with a normal
+merge; no force push is permitted. The current change adds the missing API/GNB
+connection, exact-input validation, source-name whitespace fallback, a shared
+timeline entry point, Storybook-compatible truth rendering, and live
+PostgreSQL/API regressions. The final exact head and Checks must be recorded
+after the ordinary push.
+
+## Historical exact-head refresh (2026-08-20 19:50 Asia/Seoul)
+
+This refresh supersedes the 19:14 checkpoint for the PRs it names. It records
+GitHub observations, not protected-main behavior. The repository has 25 open
+PRs; no approval or queued Check is treated as merge evidence.
+
+| PR | Exact observed head | Current observation |
+|---|---|---|
+| #258 | `f8d2fa98` | `BLOCKED`, review required |
+| #260-#266 | `dfd95d9c`, `bd1b4d2f`, `80445b8a`, `d670acd5`, `d5dbdf71`, `26a6d9c6` | stacked, review required; #264 is `DIRTY` |
+| #282 | `6eeaf89d` | `CLEAN`, no formal approval |
+| #285 | `30dae74a` | `UNSTABLE`, exact-head Checks queued, no formal approval |
+| #287 | `26fa7346` | `UNKNOWN`, review required, exact-head Checks queued |
+| #298-#303 | `49c9976f`, `59ccdf91`, `40b0a8ea`, `b7e6e82d` | mixed `DIRTY`/`CLEAN`/`UNSTABLE`, review pending |
+| #306-#311 | `e0dbc386`, `a4d1de59`, `42e6230c`, `e6fd907e`, `d8b7f561` | `CLEAN`/`UNSTABLE`, review pending |
+
+The #285 exact head includes the independent review repairs for case-preserving
+project identity, route-specific bounds, and sibling-project match isolation;
+the local tree recorded `741 passed, 16 skipped`. The #287 exact head removes
+the Semgrep dynamic-SQL findings and aligns public claim adjudication with the
+contextual-orchestrator `mode=auto` strict structured contract; its local tree
+recorded `791 passed, 16 skipped`. Both remain open until current-head Checks
+and protected approval are observed.
+
+The organization-owned `.github` repository already provides the hourly
+commercial-readiness coordinator at cron `7 * * * *` and the review/merge
+scheduler's hourly fallback. This repository does not add a competing local
+timer; the central OpenCode/scheduler credential boundary remains authoritative
+and `COPILOT_GITHUB_TOKEN` is not used.
+
+## PRD
## 3. General Architecture Gaps
- **DB Architecture**: Ensure PostgreSQL is strictly used (no file DBs), 3rd normal form is maintained, and Hot Partitions are handled. DB locks must be managed (or use read/write replicas).
@@ -79,7 +128,7 @@ claims that an unmerged PR or historical runtime observation is live behavior.
| FR-11 | Post summaries expose evidence-bearing events and R&R. Requester/processor actions are nullable and may only name actors already bound to the same post summary. | ADR 0052, ADR 0102 | Commit `15e1a378` is on PR #258 and the schema exists locally; the current database has zero populated action rows, so buyer-data acceptance remains unproven |
| FR-12 | A hierarchy-enrichment timeout leaves the source-grounded summary readable and the actor unbound; it never creates a guessed catalog identity. | ADR 0101, ADR 0010, ADR 0026 | Commit `1c260f20` contains the boundary, ADR, and focused test; independent review, protected-main merge, and fresh runtime evidence remain pending |
| FR-13 | Customer Master projects authorized corporate entities as a Group → Company → Plant tree. Real organization containment uses W3C ORG while Group/Company/Plant remain separate SKOS level concepts. Missing-parent, self-parent, and cyclic edges remain visible as unresolved roots; the UI owns nested `group` elements from their parent `treeitem`, supports Arrow/Home/End and Enter/Space operation, and opens source-backed evidence outside the tree. | ADR 0124, ADR 0004, ADR 0010 | Ontology/SHACL interoperability tests, `customerMasterTree.ts`, `CustomerMasterTree.tsx`, component tests, Storybook, and code commit `21074cf80cbfab3001bf18b6e1a618f75f4bed24` |
-| FR-14 | Global Ask presents a dedicated evidence workspace: semantic form submission, IME-safe keyboard behavior, explicit empty/loading/error/answer states, separated timeline and cited evidence, answer focus, responsive phone/tablet/PC layout, and the existing authorized cited-post → Event Lineage handoff. | ADR 0127, ADR 0002, ADR 0032, ADR 0090 | `AskAgentWorkspace.tsx`, focused component/token tests, Storybook state inventory, and existing App navigation regressions on #353 |
+| FR-14 | Global Ask presents a dedicated evidence workspace: semantic form submission, IME-safe keyboard behavior, explicit empty/loading/error/answer states, separated timeline and cited evidence, answer focus, responsive phone/tablet/PC layout, and the existing authorized cited-post → Event Lineage handoff. | ADR 0137, ADR 0002, ADR 0032, ADR 0090 | `AskAgentWorkspace.tsx`, focused component/token tests, Storybook state inventory, and existing App navigation regressions on #353 |
## TRD
@@ -266,4 +315,61 @@ projection: update the affected FR/NFR row and Gap closure evidence when an ADR
or PR changes product behavior. Never turn a PR title, green unit test, or old
runtime note into a shipped/live claim.
+## Recovered TEPP project-history integration (2026-08-21)
+
+- The canonical Buyer project timeline remains owned by the stacked Project history PR.
+- The previously implemented TEPP work had become stranded in a closed parent and an
+ orphaned duplicate stack. This recovery consumes the canonical timeline instead of
+ introducing another project query, classifier, or timeline component.
+- The dependency is the exact `ContextualWisdomLab/TEPP#159` project-history contract.
+ Until that contract is merged and a TEPP endpoint is deployed, the UI reports an
+ actionable fail-closed state and keeps the authorized LineageWeave timeline readable.
+- TEPP receives opaque actor references and bounded source-field evidence only. Browser,
+ review, provider, and `TEPP_API_KEY` credentials are not forwarded.
+- `temporal_association_only` is the maximum accepted authority. Buyer copy must say
+ that a preceding event is related in time, not that it caused the VOC.
+- The next stacked slice attaches this same canonical timeline and TEPP metadata to
+ Global Ask and post-scoped Ask without re-retrieving hidden evidence.
+
+## Ask-to-project-history integration (2026-08-21)
+
+- 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.
+
+## Current stacked PR product-surface gaps
+
+- **Customer Master relationship composition — PR #262**: Resolved on the
+ current feature branch. ADR 0129 and Figma frames `313:2` / `314:2` define a
+ customer-centered three-pane workspace that keeps the selected customer
+ stable while the user inspects relationships and source posts.
+- **Responsive Customer Master flow — PR #262**: Resolved on the current
+ feature branch. PC uses three horizontal panes, tablet uses two columns plus
+ full-width evidence, and phone preserves the semantic order hierarchy →
+ selected customer → evidence at the shared 1024 px / 768 px breakpoints.
+- **Effective-dated relationship authority**: Open. The current projection
+ still owns one `parent_entity_id`; legal ownership, operating structure,
+ sales roll-up, billing hierarchy, historical roles, and simultaneous
+ relationship types require a normalized effective-dated relation model.
+- **Unresolved hierarchy repair workflow**: Open. Cycle, self-parent, and
+ missing-visible-parent members remain visible and unresolved, but operators
+ still need a source-data quality queue, evidence review, and approved
+ correction workflow.
+- **Customer relationship exact-value export**: Open. An auditable CSV/JSON
+ export of the selected customer, visible relations, truth status, effective
+ interval, and evidence references remains a later product slice.
+
*This document is continuously updated by the hourly automated agent loop.*
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index 740162a77..9eb0483be 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -89,8 +89,9 @@ describe("App, authenticated", () => {
deferMe?: boolean;
deferPostOneSummary?: boolean;
deferSecondAsk?: boolean;
- partialCutoff?: boolean;
deferProjectHistory?: boolean;
+ projectHistoryProjectKey?: string;
+ partialCutoff?: boolean;
invalidAskSessionOnce?: boolean;
meFailed?: boolean;
postBody?: string;
@@ -1172,7 +1173,7 @@ describe("App, authenticated", () => {
visibility_label: "Public",
project_evidence: [
{
- project_key: "semantic-project",
+ project_key: options?.projectHistoryProjectKey ?? "semantic-project",
project_name: "Semantic project",
evidence: "project was described in the body",
confidence: 0.9,
@@ -2203,6 +2204,19 @@ describe("App, authenticated", () => {
expect(projectHistoryRequestUrl).toContain("focus_post_id=post-1");
});
+ it("keeps the focus post when the project key differs only by identity normalization", async () => {
+ stubBackend({ projectHistoryProjectKey: "SEMANTIC-PROJECT" });
+ render( {copy.loading} {projectHistoryText(locale, "historyUnavailable")} {copy.boundary} {copy.truncated}{copy.heading}
+
{statusMessage}
+{copy.eyebrow}
+{copy.boundary}
+{copy.noFindings}
: null} + {history.findings.length > 0 ? ( +{copy.findingLabels[finding.finding_code]}
+