diff --git a/CHANGELOG.md b/CHANGELOG.md
index 641306055..b10777aaf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,8 @@ All notable changes to this project are documented here. Format follows
### Added
+- Account-owned per-post Ask conversations can be listed, reopened, and
+ continued with current authorization reapplied to cited evidence (ADR 0228).
- Persist explicit paragraph, list, table, MathML formula, and caller-parsed
conversation-turn semantic-unit kinds without inferring absent boundaries.
- Event Lineage now persists each reconstructed connection's independent
diff --git a/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py
index 7335f6fb9..c7d67de18 100644
--- a/backend/app/analysis_run_ingestion.py
+++ b/backend/app/analysis_run_ingestion.py
@@ -640,14 +640,12 @@ def _require_lineage_create_kind(run_kind_code: str) -> None:
if run_kind_code == _TEPP_RUN_KIND:
raise AnalysisRunCreateError(
422,
- "Connect a TEPP transport from a Failed TEPP row; this endpoint "
- "does not invent a measurement.",
+ "Ask an administrator to enable measurement, then retry from the failed measurement run.",
)
if run_kind_code == _TOPIC_LINEAGE_RUN_KIND:
raise AnalysisRunCreateError(
422,
- "Connect a TEPP transport from a Failed topic-lineage row; this "
- "endpoint does not invent a topic model.",
+ "Ask an administrator to enable topic-lineage analysis, then retry from the failed run.",
)
if run_kind_code == _REPORT_RUN_KIND:
raise AnalysisRunCreateError(
diff --git a/backend/app/main.py b/backend/app/main.py
index 08ff32428..6ea717364 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -130,6 +130,14 @@
gather_chat_sources,
persist_post_chat,
)
+from backend.app.post_ask_history import (
+ PostAskConversationNotFound,
+ PostAskEvidenceChanged,
+ conversation_exists as post_ask_conversation_exists,
+ fetch_conversation as fetch_post_ask_conversation,
+ list_conversations as list_post_ask_conversations,
+ persist_turn as persist_post_ask_turn,
+)
from backend.app.post_content_queue import (
ensure_post_content_job,
post_content_api_status,
@@ -2954,6 +2962,7 @@ class ChatRequest(BaseModel):
"""JSON body for ``POST /api/posts/{post_id}/chat``."""
question: str
+ conversation_id: UUID | None = None
class GlobalAskRequest(BaseModel):
@@ -2962,6 +2971,41 @@ class GlobalAskRequest(BaseModel):
question: str
+async def _persist_post_ask_turn(
+ conn: asyncpg.Connection,
+ account: CurrentAccount,
+ post_id: str,
+ conversation_id: UUID | None,
+ question: str,
+ answer_text: str,
+ source_post_ids: list[str],
+ cited_post_ids: list[str],
+) -> UUID:
+ """Persist a completed turn after reauthorizing every citation."""
+ try:
+ return await persist_post_ask_turn(
+ conn,
+ account.user_account_id,
+ post_id,
+ conversation_id,
+ question,
+ answer_text,
+ source_post_ids,
+ cited_post_ids,
+ can_see_post=lambda row: _can_see_post(account, row),
+ )
+ except PostAskEvidenceChanged as exc:
+ raise HTTPException(
+ status.HTTP_503_SERVICE_UNAVAILABLE,
+ "Post chat is temporarily unavailable because authorized evidence changed. Retry the question.",
+ ) from exc
+ except PostAskConversationNotFound as exc:
+ raise HTTPException(
+ status.HTTP_404_NOT_FOUND,
+ "This conversation is no longer available. Choose another conversation or start a new one.",
+ ) from exc
+
+
@app.get("/api/posts/{post_id}/chat")
async def read_post_chat(
post_id: str,
@@ -3006,16 +3050,34 @@ async def chat_about_post(
post = await _load_visible_post(post_id, account, pool)
post_metadata = build_post_llm_metadata(post_id, post)
async with pool.acquire() as conn:
+ if request.conversation_id is not None and not await post_ask_conversation_exists(
+ conn, account.user_account_id, post_id, request.conversation_id
+ ):
+ raise HTTPException(
+ status.HTTP_404_NOT_FOUND,
+ "This conversation is no longer available. Choose another conversation or start a new one.",
+ )
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)
+ conversation_id = await _persist_post_ask_turn(
+ conn,
+ account,
+ post_id,
+ request.conversation_id,
+ question,
+ stored["answer_text"],
+ source_ids,
+ list(stored["cited_post_ids"]),
+ )
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,
+ "conversation_id": str(conversation_id),
}
with use_llm_metadata(post_metadata):
with traced(
@@ -3067,8 +3129,19 @@ async def chat_about_post(
"Saved evidence is still available.",
) from exc
cited_ids = list(answer.cited_post_ids)
+ source_ids = [source.post_id for source in sources]
async with pool.acquire() as conn:
await persist_post_chat(conn, post_id, question, answer.answer_text, cited_ids)
+ conversation_id = await _persist_post_ask_turn(
+ conn,
+ account,
+ post_id,
+ request.conversation_id,
+ question,
+ answer.answer_text,
+ source_ids,
+ cited_ids,
+ )
await publish_activity_event(
valkey,
post_id,
@@ -3081,10 +3154,67 @@ async def chat_about_post(
"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],
+ "source_post_ids": source_ids,
+ "conversation_id": str(conversation_id),
}
+@app.get("/api/posts/{post_id}/chat/conversations")
+async def read_post_chat_conversations(
+ post_id: str,
+ limit: int = Query(50, ge=1, le=50),
+ before_updated_at: datetime | None = Query(None),
+ before_conversation_id: UUID | None = Query(None),
+ account: CurrentAccount = Depends(get_current_account),
+ pool: asyncpg.Pool = Depends(get_pool),
+) -> dict[str, Any]:
+ """List this account's saved Ask conversations on one visible post."""
+ await _load_visible_post(post_id, account, pool)
+ if (before_updated_at is None) != (before_conversation_id is None):
+ raise HTTPException(
+ status.HTTP_422_UNPROCESSABLE_CONTENT,
+ "before_updated_at and before_conversation_id must be provided together",
+ )
+ async with pool.acquire() as conn:
+ return await list_post_ask_conversations(
+ conn,
+ account.user_account_id,
+ post_id,
+ limit=limit,
+ before_updated_at=before_updated_at,
+ before_conversation_id=before_conversation_id,
+ )
+
+
+@app.get("/api/posts/{post_id}/chat/conversations/{conversation_id}")
+async def read_post_chat_conversation(
+ post_id: str,
+ conversation_id: UUID,
+ limit: int = Query(50, ge=1, le=50),
+ before_turn: int | None = Query(None, ge=1),
+ account: CurrentAccount = Depends(get_current_account),
+ pool: asyncpg.Pool = Depends(get_pool),
+) -> dict[str, Any]:
+ """Load one owned transcript with currently authorized citations."""
+ await _load_visible_post(post_id, account, pool)
+ async with pool.acquire() as conn:
+ conversation = await fetch_post_ask_conversation(
+ conn,
+ account.user_account_id,
+ post_id,
+ conversation_id,
+ lambda row: _can_see_post(account, row),
+ turn_limit=limit,
+ before_turn_ordinal=before_turn,
+ )
+ if conversation is None:
+ raise HTTPException(
+ status.HTTP_404_NOT_FOUND,
+ "This conversation is no longer available. Choose another conversation or start a new one.",
+ )
+ return conversation
+
+
@app.post("/api/ask", status_code=status.HTTP_202_ACCEPTED)
async def ask_agent(
request: GlobalAskRequest,
diff --git a/backend/app/post_ask_history.py b/backend/app/post_ask_history.py
new file mode 100644
index 000000000..116cffc87
--- /dev/null
+++ b/backend/app/post_ask_history.py
@@ -0,0 +1,409 @@
+"""Account-owned persistence for Ask conversations on one visible post.
+
+ADR 0228 reuses the ADR 0126 list/select/new contract with a required
+post_id scope. Conversation ids are never Global Ask session ids.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Callable, Iterable
+from datetime import datetime
+from typing import Any
+from uuid import UUID, uuid4
+
+import asyncpg
+
+from .post_eligibility import SOURCE_POST_ELIGIBILITY_SQL
+
+
+class PostAskConversationNotFound(LookupError):
+ """The requested conversation is absent, on another post, or another account."""
+
+
+class PostAskEvidenceChanged(RuntimeError):
+ """A cited post became unauthorized before the new turn could commit."""
+
+
+async def conversation_exists(
+ conn: asyncpg.Connection,
+ user_account_id: str,
+ post_id: str,
+ conversation_id: UUID,
+) -> bool:
+ """Return whether this account owns the conversation on ``post_id``."""
+ return bool(
+ await conn.fetchval(
+ """
+ select exists(
+ select 1
+ from post_ask_session
+ where post_ask_session_id = $1
+ and user_account_id = $2
+ and post_id = $3
+ )
+ """,
+ conversation_id,
+ user_account_id,
+ post_id,
+ )
+ )
+
+
+async def list_conversations(
+ conn: asyncpg.Connection,
+ user_account_id: str,
+ post_id: str,
+ *,
+ limit: int = 50,
+ before_updated_at: datetime | None = None,
+ before_conversation_id: UUID | None = None,
+) -> dict[str, Any]:
+ """Return this account's conversations on ``post_id``, newest first."""
+ rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli
+ """
+ select session.post_ask_session_id,
+ (select left(turn.question_text, 80)
+ from post_ask_turn turn
+ where turn.post_ask_session_id = session.post_ask_session_id
+ order by turn.turn_ordinal
+ limit 1) as conversation_title,
+ session.updated_at,
+ count(turn.turn_ordinal)::int as turn_count
+ from post_ask_session session
+ left join post_ask_turn turn
+ on turn.post_ask_session_id = session.post_ask_session_id
+ where session.user_account_id = $1
+ and session.post_id = $2
+ and (
+ $3 is null
+ or $4 is null
+ or session.updated_at < $3
+ or (session.updated_at = $3 and session.post_ask_session_id < $4)
+ )
+ group by session.post_ask_session_id, session.updated_at
+ order by session.updated_at desc, session.post_ask_session_id desc
+ limit $5
+ """,
+ user_account_id,
+ post_id,
+ before_updated_at,
+ before_conversation_id,
+ limit + 1,
+ )
+ page_rows = rows[:limit]
+ next_cursor = None
+ if len(rows) > limit and page_rows:
+ last = page_rows[-1]
+ next_cursor = {
+ "updated_at": last["updated_at"],
+ "conversation_id": str(last["post_ask_session_id"]),
+ }
+ return {
+ "conversations": [
+ {
+ "conversation_id": str(row["post_ask_session_id"]),
+ "title": row["conversation_title"],
+ "updated_at": row["updated_at"],
+ "turn_count": row["turn_count"],
+ }
+ for row in page_rows
+ ],
+ "next_cursor": next_cursor,
+ }
+
+
+async def _visible_post_ids_batch(
+ conn: asyncpg.Connection,
+ conversation_id: UUID,
+ turn_ordinals: list[int],
+ can_see_post: Callable[[asyncpg.Record], bool],
+ *,
+ source: bool,
+) -> dict[int, tuple[list[str], dict[str, asyncpg.Record]]]:
+ """Reauthorize every turn's sources or citations in one query.
+
+ Fetches all `turn_ordinals` at once instead of one query per turn, so a
+ conversation's query count stays constant regardless of how many turns
+ it has. Returns each turn's currently-visible post ids and rows, keyed
+ by turn ordinal; a turn with no visible rows still gets an empty entry.
+ """
+ by_turn: dict[int, tuple[list[str], dict[str, asyncpg.Record]]] = {
+ ordinal: ([], {}) for ordinal in turn_ordinals
+ }
+ if not turn_ordinals:
+ return by_turn
+ if source:
+ rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli
+ f"""
+ select relation.turn_ordinal as turn_ordinal,
+ relation.source_post_id::text as post_id, relation.source_ordinal as ordinal,
+ post.post_title, post.visibility_code, post.corporate_entity_id,
+ post.process_unit_id,
+ post.author_account_id, post.source_detail_state_code
+ from post_ask_turn_source relation
+ join source_post post on post.post_id = relation.source_post_id
+ where relation.post_ask_session_id = $1
+ and relation.turn_ordinal = any($2::int[])
+ and ({SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')})
+ order by relation.turn_ordinal, relation.source_ordinal
+ """,
+ conversation_id,
+ turn_ordinals,
+ )
+ else:
+ rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli
+ f"""
+ select relation.turn_ordinal as turn_ordinal,
+ relation.cited_post_id::text as post_id, relation.citation_ordinal as ordinal,
+ post.post_title, post.visibility_code, post.corporate_entity_id,
+ post.process_unit_id,
+ post.author_account_id, post.source_detail_state_code
+ from post_ask_turn_citation relation
+ join source_post post on post.post_id = relation.cited_post_id
+ where relation.post_ask_session_id = $1
+ and relation.turn_ordinal = any($2::int[])
+ and ({SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')})
+ order by relation.turn_ordinal, relation.citation_ordinal
+ """,
+ conversation_id,
+ turn_ordinals,
+ )
+ for row in rows:
+ if not can_see_post(row):
+ continue
+ ordinal = int(row["turn_ordinal"])
+ post_id = str(row["post_id"])
+ ids, id_map = by_turn[ordinal]
+ ids.append(post_id)
+ id_map[post_id] = row
+ return by_turn
+
+
+async def fetch_conversation(
+ conn: asyncpg.Connection,
+ user_account_id: str,
+ post_id: str,
+ conversation_id: UUID,
+ can_see_post: Callable[[asyncpg.Record], bool],
+ *,
+ turn_limit: int = 50,
+ before_turn_ordinal: int | None = None,
+) -> dict[str, Any] | None:
+ """Return one owned transcript with currently authorized citations."""
+ header = await conn.fetchrow(
+ """
+ select post_ask_session_id
+ from post_ask_session
+ where post_ask_session_id = $1
+ and user_account_id = $2
+ and post_id = $3
+ """,
+ conversation_id,
+ user_account_id,
+ post_id,
+ )
+ if header is None:
+ return None
+
+ title_question = await conn.fetchval(
+ """
+ select question_text
+ from post_ask_turn
+ where post_ask_session_id = $1
+ order by turn_ordinal
+ limit 1
+ """,
+ conversation_id,
+ )
+ if before_turn_ordinal is None:
+ turns = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli
+ """
+ select turn_ordinal, question_text, answer_text
+ from post_ask_turn
+ where post_ask_session_id = $1
+ order by turn_ordinal desc
+ limit $2
+ """,
+ conversation_id,
+ turn_limit + 1,
+ )
+ else:
+ turns = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli
+ """
+ select turn_ordinal, question_text, answer_text
+ from post_ask_turn
+ where post_ask_session_id = $1
+ and turn_ordinal < $2
+ order by turn_ordinal desc
+ limit $3
+ """,
+ conversation_id,
+ before_turn_ordinal,
+ turn_limit + 1,
+ )
+ has_older = len(turns) > turn_limit
+ turns = list(turns[:turn_limit])
+ turns.reverse()
+ ordinals = [int(turn["turn_ordinal"]) for turn in turns]
+ sources_by_turn = await _visible_post_ids_batch(
+ conn, conversation_id, ordinals, can_see_post, source=True
+ )
+ citations_by_turn = await _visible_post_ids_batch(
+ conn, conversation_id, ordinals, can_see_post, source=False
+ )
+ exchanges: list[dict[str, Any]] = []
+ for turn in turns:
+ ordinal = int(turn["turn_ordinal"])
+ source_ids, _ = sources_by_turn[ordinal]
+ cited_ids, cited_rows = citations_by_turn[ordinal]
+ exchanges.append(
+ {
+ "turn_id": f"{conversation_id}:{ordinal}",
+ "question_text": turn["question_text"],
+ "answer_text": turn["answer_text"],
+ "cited_post_ids": cited_ids,
+ "cited_posts": [
+ {"post_id": post_id_value, "post_title": cited_rows[post_id_value]["post_title"]}
+ for post_id_value in cited_ids
+ ],
+ "source_post_ids": source_ids,
+ }
+ )
+ title = title_question[:80] if title_question else None
+ return {
+ "conversation_id": str(header["post_ask_session_id"]),
+ "title": title,
+ "exchanges": exchanges,
+ "older_cursor": str(turns[0]["turn_ordinal"]) if has_older and turns else None,
+ }
+
+
+async def _ensure_citations_visible(
+ conn: asyncpg.Connection,
+ conversation_id: UUID,
+ turn_ordinal: int,
+ cited_post_count: int,
+ can_see_post: Callable[[asyncpg.Record], bool],
+) -> None:
+ """Lock and re-authorize new citations before their transaction commits."""
+ rows = await conn.fetch(
+ f"""
+ select relation.cited_post_id::text as post_id,
+ post.post_title, post.visibility_code, post.corporate_entity_id,
+ post.process_unit_id,
+ post.author_account_id, post.source_detail_state_code
+ from post_ask_turn_citation relation
+ join source_post post on post.post_id = relation.cited_post_id
+ where relation.post_ask_session_id = $1
+ and relation.turn_ordinal = $2
+ and ({SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')})
+ for share of post
+ """,
+ conversation_id,
+ turn_ordinal,
+ )
+ if len(rows) != cited_post_count or any(not can_see_post(row) for row in rows):
+ raise PostAskEvidenceChanged
+
+
+async def persist_turn(
+ conn: asyncpg.Connection,
+ user_account_id: str,
+ post_id: str,
+ conversation_id: UUID | None,
+ question: str,
+ answer_text: str,
+ source_post_ids: Iterable[str],
+ cited_post_ids: Iterable[str],
+ can_see_post: Callable[[asyncpg.Record], bool] | None = None,
+) -> UUID:
+ """Append one completed turn and return the conversation id."""
+ source_ids = list(dict.fromkeys(str(post_id_value) for post_id_value in source_post_ids))
+ source_set = set(source_ids)
+ cited_ids = list(
+ dict.fromkeys(str(post_id_value) for post_id_value in cited_post_ids if str(post_id_value) in source_set)
+ )
+ async with conn.transaction():
+ if conversation_id is None:
+ conversation_id = uuid4()
+ await conn.execute(
+ """
+ insert into post_ask_session (post_ask_session_id, post_id, user_account_id)
+ values ($1, $2, $3)
+ """,
+ conversation_id,
+ post_id,
+ user_account_id,
+ )
+ else:
+ conversation = await conn.fetchrow(
+ """
+ select post_ask_session_id
+ from post_ask_session
+ where post_ask_session_id = $1
+ and user_account_id = $2
+ and post_id = $3
+ for update
+ """,
+ conversation_id,
+ user_account_id,
+ post_id,
+ )
+ if conversation is None:
+ raise PostAskConversationNotFound
+
+ ordinal = int(
+ await conn.fetchval(
+ "select coalesce(max(turn_ordinal), 0) + 1 from post_ask_turn where post_ask_session_id = $1",
+ conversation_id,
+ )
+ )
+ await conn.execute(
+ """
+ insert into post_ask_turn
+ (post_ask_session_id, turn_ordinal, question_text, answer_text)
+ values ($1, $2, $3, $4)
+ """,
+ conversation_id,
+ ordinal,
+ question,
+ answer_text,
+ )
+ for source_ordinal, source_post_id in enumerate(source_ids):
+ await conn.execute(
+ """
+ insert into post_ask_turn_source
+ (post_ask_session_id, turn_ordinal, source_ordinal, source_post_id)
+ values ($1, $2, $3, $4)
+ """,
+ conversation_id,
+ ordinal,
+ source_ordinal,
+ source_post_id,
+ )
+ for citation_ordinal, cited_post_id in enumerate(cited_ids):
+ await conn.execute(
+ """
+ insert into post_ask_turn_citation
+ (post_ask_session_id, turn_ordinal, citation_ordinal, cited_post_id)
+ values ($1, $2, $3, $4)
+ """,
+ conversation_id,
+ ordinal,
+ citation_ordinal,
+ cited_post_id,
+ )
+ await conn.execute(
+ "update post_ask_session set updated_at = now() where post_ask_session_id = $1",
+ conversation_id,
+ )
+ if can_see_post is not None:
+ await _ensure_citations_visible(
+ conn,
+ conversation_id,
+ ordinal,
+ len(cited_ids),
+ can_see_post,
+ )
+ assert conversation_id is not None
+ return conversation_id
diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py
index 45ed5d9d4..e8a483835 100644
--- a/backend/tests/test_api.py
+++ b/backend/tests/test_api.py
@@ -217,6 +217,11 @@
/ "migrations"
/ "0183_source_post_event_occurred_at.sql"
)
+_POST_ASK_HISTORY_MIGRATION = (
+ Path(__file__).resolve().parents[2]
+ / "migrations"
+ / "0223_post_ask_conversation_history.sql"
+)
def _postgres_available() -> bool:
@@ -372,6 +377,7 @@ def seeded_db(demo_analyst_token):
cur.execute(_LEFTOVER_MAP_UNEXPLAINED_MIGRATION.read_text())
cur.execute(_LEFTOVER_MAP_CROSS_SHARE_MIGRATION.read_text())
cur.execute(_LEFTOVER_MAP_RECONSTRUCTION_MIGRATION.read_text())
+ cur.execute(_POST_ASK_HISTORY_MIGRATION.read_text())
cur.execute(
"insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values "
"('corporate_entity_level', 'group', 'Group'), "
diff --git a/docs/adr/0228-post-ask-conversation-history.md b/docs/adr/0228-post-ask-conversation-history.md
new file mode 100644
index 000000000..775599294
--- /dev/null
+++ b/docs/adr/0228-post-ask-conversation-history.md
@@ -0,0 +1,81 @@
+# ADR 0228: Persisted per-post Ask conversation history
+
+* Status: Accepted
+* Date: 2026-08-23
+* Figma: File ID `1Su3lDRmiZdcUs47t1QwIX`
+* Related: [0090](0090-global-ask-lineage-timeline-expansion.md), [0118](0118-uiux-standard-guide-v3-design-overhaul.md)
+
+## Context
+
+The post popup **Ask about this lineage** surface stored one shared
+`post_chat_result` row per
+`(post_id, question_norm)` and rendered a linear transcript plus re-ask
+chips. Leaving the popup, switching questions, or starting a new thread
+could not reopen an earlier account-owned conversation on that post.
+
+That is a different metaphor from the conversation-history sidebar the
+reader already uses on Ask Agent. Seeded fixture answers remain the
+orchestrator-off demo cache; they are not a substitute for account-owned
+history.
+
+## Decision
+
+Persist per-post Ask conversations under the authenticated `user_account`
+and the visible `source_post`: an explicit
+conversation id, list/select/new, and visibility-filtered citations on
+read. Do not represent this id as a Global Ask session id or as a fake
+post-scoped orchestrator session id.
+
+Normalized tables:
+
+* `post_ask_session` — one conversation per account and post
+* `post_ask_turn` — ordered questions and answers
+* `post_ask_turn_citation` / `post_ask_turn_source` — cited and retrieved
+ posts
+
+The composite index `(user_account_id, post_id, updated_at desc)` leads
+with the account so a hot post cannot concentrate list traffic on one
+partition key. A turn is written only after a complete answer exists
+(seeded cache hit or orchestrator object). History reads re-apply current
+post visibility before returning titles or citations.
+
+`post_chat_result` stays the post-level seeded/cache store used when the
+orchestrator is off. Account history is additional, not a replacement.
+
+## Consequences
+
+* A reader can list saved questions on a post, reopen one and see its
+ turns, and start a new conversation without losing the list.
+* A user cannot read another account's post conversation by changing a
+ UUID, and cannot load a conversation against a different post id.
+* Revoked post visibility removes that post's citation projection; the
+ stored answer remains account-owned transcript data.
+* TEPP topic modeling of how many posts can connect, and how many
+ lineages form under temporal precedence, remains deferred.
+* Reauthorization for a conversation's turns is batched
+ (`_visible_post_ids_batch`, one query per relation type per page instead
+ of per turn), preventing transcript length from creating an N+1 query path.
+* `persist_turn` now re-authorizes every citation inside its own commit
+ transaction (`_ensure_citations_visible`, row-share-locked, raising
+ `PostAskEvidenceChanged` -> 503). A citation that loses authorization
+ between source-gathering and commit therefore aborts the whole turn.
+
+## Implementation Plan
+
+* **Affected paths:** `migrations/0223_post_ask_conversation_history.sql`,
+ `backend/app/post_ask_history.py`, `backend/app/main.py`,
+ `frontend/src/api.ts`, `frontend/src/App.tsx` (`ChatPanel`),
+ `frontend/src/i18n.ts`, `tests/test_post_ask_history.py`,
+ `frontend/src/ChatPanel.test.tsx`
+* **Pattern:** require `post_id` and `user_account_id` scope on every query.
+* **Verification:** backend tests drive `list_conversations`,
+ `fetch_conversation`, and `persist_turn`. Frontend tests click New
+ conversation, select a saved conversation, and assert the matching turns.
+
+## References — APA 7th
+
+World Wide Web Consortium. (2013). *PROV-O: The PROV ontology* (W3C
+Recommendation). https://www.w3.org/TR/prov-o/
+
+World Wide Web Consortium. (2024). *Web Content Accessibility Guidelines
+(WCAG) 2.2* (W3C Recommendation). https://www.w3.org/TR/WCAG22/
diff --git a/docs/adr/README.md b/docs/adr/README.md
index eadef2874..144eeb426 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -17,6 +17,7 @@ decision from them.
| [`ONTOLOGY_NAMESPACE_INVENTORY.md`](../doctoring/ONTOLOGY_NAMESPACE_INVENTORY.md) | [0207](0207-repository-case-ontology-namespace-canonical.md), [0157](0157-public-ontology-namespace-identity.md) |
| [`image-content-schema.md`](../image-content-schema.md) | [0066](0066-position-preserving-image-content.md) |
| [`storybook-inventory.md`](../storybook-inventory.md) | [0118](0118-uiux-standard-guide-v3-design-overhaul.md), [0184](0184-ontology-provenance-explorer.md) |
+| Per-post Ask conversation history | [0228](0228-post-ask-conversation-history.md) |
| [`POSTGRESQL_CONCURRENCY_REFERENCES.md`](../doctoring/POSTGRESQL_CONCURRENCY_REFERENCES.md) | [0204](0204-analysis-run-short-transaction-delivery.md) |
| [`operability/http-concurrency-evidence.md`](../operability/http-concurrency-evidence.md) | [0204](0204-analysis-run-short-transaction-delivery.md), [0212](0212-single-query-authorized-post-filter-options.md) |
| Evidence operations Dashboard (`/`) | [0206](0206-evidence-operations-dashboard.md) |
diff --git a/docs/operability/http-concurrency-evidence.md b/docs/operability/http-concurrency-evidence.md
index ae3684142..56fbde830 100644
--- a/docs/operability/http-concurrency-evidence.md
+++ b/docs/operability/http-concurrency-evidence.md
@@ -70,6 +70,16 @@ above on an application-ready stack to obtain the product measurement.
## Older-image diagnostic observation
+On 2026-08-26, the application-ready local synthetic stack completed a
+5-VU, 15-second k6 run with five iterations and 0 failed requests. Ask enqueue
+was 967.49 ms; Ask polling averaged 11.94 seconds; ordinary authenticated
+posts/lineage reads averaged 34.53 seconds (p95 43.26 seconds). The run used
+the stack's deployed image rather than this candidate head, so it proves only
+that the existing HTTP boundary remains responsive without request failures
+while also exposing unacceptable reader latency for investigation. It is not
+an ADR 0228 capacity result or an SLO. Rebuild the exact candidate image and
+repeat with resource and query-plan telemetry before attributing the delay.
+
On 2026-08-25, an application-ready local Compose stack configured with four
worker VUs completed zero full iterations in two observations. In the second
30-second observation, Ask enqueue took 2.69 seconds, the maximum completed
diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md
index 7ec497477..e33e39498 100644
--- a/docs/storybook-inventory.md
+++ b/docs/storybook-inventory.md
@@ -30,5 +30,8 @@ https://www.w3.org/community/reports/design-tokens/CG-FINAL-format-20251028/
Storybook. (2026). *Storybook for React & Vite*.
https://storybook.js.org/docs/get-started/frameworks/react-vite
+`Workspace/ChatPanel` covers seeded-only, saved-history, and narrow mobile
+states for the ADR 0228 list/select/new conversation controls.
+
World Wide Web Consortium. (2024). *Web Content Accessibility Guidelines
(WCAG) 2.2* (W3C Recommendation). https://www.w3.org/TR/WCAG22/
diff --git a/frontend/src/App.css b/frontend/src/App.css
index b7482e61f..4fc37055c 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -1007,6 +1007,36 @@
gap: 0.5rem;
}
+.chat-history-controls {
+ display: flex;
+ align-items: end;
+ gap: var(--space-2);
+ margin-bottom: var(--space-3);
+}
+
+.chat-history-controls label {
+ display: grid;
+ flex: 1;
+ gap: var(--space-1);
+}
+
+.chat-history-controls select {
+ min-height: var(--size-control-min);
+ width: 100%;
+}
+
+.chat-history-controls > button {
+ min-height: var(--size-control-min);
+ white-space: nowrap;
+}
+
+@media (max-width: 640px) {
+ .chat-history-controls {
+ align-items: stretch;
+ flex-direction: column;
+ }
+}
+
.chat-input-row input {
flex: 1;
padding: 0.5rem;
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index 1110d8fb0..67d7c59af 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -675,7 +675,7 @@ describe("App, authenticated", () => {
JSON.stringify({
detail:
payload.run_kind_code === "analysis_run_tepp"
- ? "Connect a TEPP transport from a Failed TEPP row; this endpoint does not invent a measurement."
+ ? "Ask an administrator to enable measurement, then retry from the failed measurement run."
: "Rebuild the period report from the Reports panel.",
}),
{ status: 422, headers: { "Content-Type": "application/json" } },
@@ -3276,7 +3276,7 @@ describe("App, authenticated", () => {
expect(list).toHaveTextContent("TEPP measurement · Failed · Demo Corp");
expect(list).toHaveTextContent("Period report · Succeeded · Demo Corp");
expect(list).toHaveTextContent(
- "Open this run to see why it failed, then connect the measurement service and re-run.",
+ "Open this run to see why it failed. Ask an administrator to enable measurement, then run it again.",
);
expect(list).toHaveTextContent("3 documents");
expect(list).not.toHaveTextContent("postgresql://");
@@ -3430,11 +3430,11 @@ describe("App, authenticated", () => {
["analysis_run_lineage", "Request a new lineage reconstruction from a current snapshot."],
[
"analysis_run_tepp",
- "Connect the measurement service, then ask an administrator to submit a new TEPP run from a current snapshot.",
+ "Ask an administrator to enable measurement and submit a new run from a current snapshot.",
],
[
"analysis_run_topic_lineage",
- "Connect the TEPP transport, then ask an administrator to submit new topic-lineage analysis from a current snapshot.",
+ "Ask an administrator to enable topic-lineage analysis and submit a new run from a current snapshot.",
],
["analysis_run_report", "Rebuild the period report from a current snapshot."],
] satisfies [AnalysisRunKindCode, string][])(
@@ -3453,7 +3453,7 @@ describe("App, authenticated", () => {
},
);
- it("tells a running lineage run to refresh the durable outbox", async () => {
+ it("tells a running lineage run how to check for results", async () => {
stubBackend({ runningLineageRun: true });
render(
{historyError}
: null} {!seededOnly && (