From 1c1c36c9dae3348345ab1a300a6b0aae10cdf4d7 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 09:16:02 +0900
Subject: [PATCH 1/2] feat(ask): persist per-post conversation history
---
CHANGELOG.md | 2 +
backend/app/main.py | 123 +++++-
backend/app/post_ask_history.py | 408 ++++++++++++++++++
backend/tests/test_api.py | 6 +
.../adr/0228-post-ask-conversation-history.md | 81 ++++
docs/adr/README.md | 1 +
docs/storybook-inventory.md | 3 +
frontend/src/App.css | 30 ++
frontend/src/App.tsx | 75 +++-
frontend/src/ChatPanel.stories.tsx | 96 +++++
frontend/src/ChatPanel.test.tsx | 73 ++++
frontend/src/api.ts | 44 +-
frontend/src/i18n.ts | 12 +
.../0223_post_ask_conversation_history.sql | 48 +++
.../0223_post_ask_conversation_history.sql | 4 +
tests/test_post_ask_history.py | 276 ++++++++++++
16 files changed, 1276 insertions(+), 6 deletions(-)
create mode 100644 backend/app/post_ask_history.py
create mode 100644 docs/adr/0228-post-ask-conversation-history.md
create mode 100644 frontend/src/ChatPanel.stories.tsx
create mode 100644 frontend/src/ChatPanel.test.tsx
create mode 100644 migrations/0223_post_ask_conversation_history.sql
create mode 100644 migrations/rollback/0223_post_ask_conversation_history.sql
create mode 100644 tests/test_post_ask_history.py
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/main.py b/backend/app/main.py
index 08ff32428..b0bc28a58 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,38 @@ 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, "conversation not found") from exc
+
+
@app.get("/api/posts/{post_id}/chat")
async def read_post_chat(
post_id: str,
@@ -3006,16 +3047,31 @@ 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, "conversation not found")
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 +3123,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 +3148,64 @@ 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, "conversation not found")
+ 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..959e0f6b1
--- /dev/null
+++ b/backend/app/post_ask_history.py
@@ -0,0 +1,408 @@
+"""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,
+ coalesce(
+ (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),
+ 'New conversation'
+ ) 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.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.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 "New conversation"
+ 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(
+ """
+ select relation.cited_post_id::text as post_id,
+ post.post_title, post.visibility_code, post.corporate_entity_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
+ 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/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..4868402b8 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: 44px;
+ width: 100%;
+}
+
+.chat-history-controls > button {
+ min-height: 44px;
+ 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.tsx b/frontend/src/App.tsx
index 6e1fff1c9..834963651 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -27,6 +27,8 @@ import {
fetchPostActivity,
fetchPostBookmark,
fetchPostChat,
+ fetchPostChatConversation,
+ fetchPostChatConversations,
fetchPostAffiliateTree,
fetchPostCounterparties,
fetchPostEvaluation,
@@ -58,6 +60,7 @@ import {
type CalendarResponse,
type ChatAnswer,
type ChatExchange,
+ type PostAskConversationSummary,
type CorporateEntityRef,
type CustomerMasterEntity,
type CustomerMasterResponse,
@@ -256,7 +259,7 @@ function ChatCitations({
);
}
-function ChatPanel({
+export function ChatPanel({
postId,
accessToken,
nameFirstAsk,
@@ -267,6 +270,10 @@ function ChatPanel({
}) {
const [question, setQuestion] = useState("");
const [exchanges, setExchanges] = useState([]);
+ const [seededExchanges, setSeededExchanges] = useState([]);
+ const [conversations, setConversations] = useState([]);
+ const [conversationId, setConversationId] = useState(null);
+ const [historyError, setHistoryError] = useState(null);
const [answer, setAnswer] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(false);
@@ -275,22 +282,63 @@ function ChatPanel({
useEffect(() => {
setExchanges([]);
+ setSeededExchanges([]);
+ setConversations([]);
+ setConversationId(null);
+ setHistoryError(null);
setAnswer(null);
setError(null);
setSeededOnly(false);
setEvidencePostId(null);
fetchPostChat(accessToken, postId)
- .then((history) => setExchanges(history.exchanges))
+ .then((history) => {
+ setSeededExchanges(history.exchanges);
+ setExchanges(history.exchanges);
+ })
.catch(() => setExchanges([]));
+ fetchPostChatConversations(accessToken, postId)
+ .then((page) => setConversations(page.conversations))
+ .catch(() => setHistoryError(t("Conversation history could not be loaded.")));
}, [postId, accessToken]);
+ async function selectConversation(nextId: string) {
+ setHistoryError(null);
+ try {
+ const conversation = await fetchPostChatConversation(accessToken, postId, nextId);
+ setConversationId(conversation.conversation_id);
+ setExchanges(conversation.exchanges);
+ } catch {
+ setHistoryError(t("Conversation history could not be loaded."));
+ }
+ }
+
+ function startNewConversation() {
+ setConversationId(null);
+ setExchanges(seededExchanges);
+ setQuestion("");
+ setAnswer(null);
+ setError(null);
+ }
+
async function handleAsk(asked = question) {
if (!asked.trim()) return;
setLoading(true);
setError(null);
try {
- const result = await askPostChat(accessToken, postId, asked);
+ const result = await askPostChat(accessToken, postId, asked, conversationId);
setAnswer(result);
+ if (result.conversation_id) {
+ setConversationId(result.conversation_id);
+ setConversations((current) => [
+ {
+ conversation_id: result.conversation_id!,
+ title: current.find((row) => row.conversation_id === result.conversation_id)?.title ?? asked.trim().slice(0, 80),
+ updated_at: new Date().toISOString(),
+ turn_count: (current.find((row) => row.conversation_id === result.conversation_id)?.turn_count ?? 0) + 1,
+ },
+ ...current.filter((row) => row.conversation_id !== result.conversation_id),
+ ]);
+ }
setExchanges((prev) => {
const next: ChatExchange = {
question_text: asked.trim(),
@@ -362,6 +410,27 @@ function ChatPanel({
{landedEvidenceNextAction(firstCitedTitle)}
) : null}
+
+
+
+
+ {historyError ? {historyError}
: null}
{!seededOnly && (
{
+ const url = String(input);
+ if (url.endsWith("/api/posts/post-1/chat/conversations/conversation-post-1")) {
+ return jsonResponse({
+ conversation_id: "conversation-post-1",
+ title: "Saved post question",
+ older_cursor: null,
+ exchanges: [
+ {
+ turn_id: "turn-1",
+ question_text: "Which site visit was saved?",
+ answer_text: "The saved post answer stays grounded in the linked source.",
+ cited_post_ids: ["post-2"],
+ cited_posts: [{ post_id: "post-2", post_title: "Linked source post" }],
+ source_post_ids: ["post-1", "post-2"],
+ },
+ ],
+ });
+ }
+ if (url.includes("/chat/conversations")) {
+ return jsonResponse({
+ conversations:
+ fixture === "saved"
+ ? [
+ {
+ conversation_id: "conversation-post-1",
+ title: "Saved post question",
+ updated_at: "2026-08-21T00:00:00Z",
+ turn_count: 1,
+ },
+ ]
+ : [],
+ });
+ }
+ return jsonResponse({
+ post_id: "post-1",
+ exchanges: [
+ {
+ question_text: "What happened between these events?",
+ answer_text: "The seeded follow-up after the site visit.",
+ cited_post_ids: ["post-2"],
+ cited_posts: [{ post_id: "post-2", post_title: "Linked source post" }],
+ },
+ ],
+ });
+ };
+}
+
+const meta = {
+ title: "Workspace/ChatPanel",
+ component: ChatPanel,
+ args: {
+ postId: "post-1",
+ accessToken: "synthetic-story-token",
+ },
+ parameters: { layout: "padded" },
+} satisfies Meta
;
+
+export default meta;
+type Story = StoryObj;
+
+export const SeededDump: Story = {
+ render(args) {
+ installFixture("empty");
+ return ;
+ },
+};
+
+export const SavedHistory: Story = {
+ render(args) {
+ installFixture("saved");
+ return ;
+ },
+};
+
+export const Phone: Story = {
+ ...SavedHistory,
+ parameters: {
+ layout: "padded",
+ viewport: { defaultViewport: "mobile1" },
+ },
+};
+
diff --git a/frontend/src/ChatPanel.test.tsx b/frontend/src/ChatPanel.test.tsx
new file mode 100644
index 000000000..7688a6079
--- /dev/null
+++ b/frontend/src/ChatPanel.test.tsx
@@ -0,0 +1,73 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { ChatPanel } from "./App";
+import { setLocale } from "./i18n";
+
+function jsonResponse(body: unknown) {
+ return new Response(JSON.stringify(body), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ setLocale("en");
+});
+
+describe("ChatPanel conversation history", () => {
+ it("reopens an owned conversation and returns to the seeded new-conversation state", async () => {
+ vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url.endsWith("/chat/conversations/conversation-1")) {
+ return jsonResponse({
+ conversation_id: "conversation-1",
+ title: "Saved question",
+ older_cursor: null,
+ exchanges: [{
+ turn_id: "turn-1",
+ question_text: "What was saved?",
+ answer_text: "Only authorized saved evidence.",
+ cited_post_ids: [],
+ cited_posts: [],
+ source_post_ids: ["post-1"],
+ }],
+ });
+ }
+ if (url.endsWith("/chat/conversations")) {
+ return jsonResponse({
+ conversations: [{
+ conversation_id: "conversation-1",
+ title: "Saved question",
+ updated_at: "2026-08-26T00:00:00Z",
+ turn_count: 1,
+ }],
+ next_cursor: null,
+ });
+ }
+ return jsonResponse({
+ post_id: "post-1",
+ exchanges: [{
+ question_text: "Seed question",
+ answer_text: "Seed answer",
+ cited_post_ids: [],
+ cited_posts: [],
+ }],
+ });
+ }));
+
+ render();
+
+ expect(await screen.findByText("Seed answer")).toBeInTheDocument();
+ await userEvent.selectOptions(
+ screen.getByLabelText("Conversation history"),
+ "conversation-1",
+ );
+ expect(await screen.findByText("Only authorized saved evidence.")).toBeInTheDocument();
+
+ await userEvent.click(screen.getByRole("button", { name: "New conversation" }));
+ await waitFor(() => expect(screen.getByText("Seed answer")).toBeInTheDocument());
+ expect(screen.queryByText("Only authorized saved evidence.")).toBeNull();
+ });
+});
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index 2c812ccaa..415442dc3 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -333,6 +333,7 @@ export interface CitedPostEvidence {
export interface ChatAnswer {
post_id: string;
+ conversation_id?: string;
answer_text: string;
cited_post_ids: string[];
cited_posts?: CitedPostRef[];
@@ -351,6 +352,25 @@ export interface ChatHistory {
exchanges: ChatExchange[];
}
+export interface PostAskConversationSummary {
+ conversation_id: string;
+ title: string;
+ updated_at: string;
+ turn_count: number;
+}
+
+export interface PostAskConversationPage {
+ conversations: PostAskConversationSummary[];
+ next_cursor?: { updated_at: string; conversation_id: string } | null;
+}
+
+export interface PostAskConversation {
+ conversation_id: string;
+ title: string;
+ exchanges: ChatExchange[];
+ older_cursor?: string | null;
+}
+
export interface CitedPostImage {
post_id: string;
unit_index: number;
@@ -1125,13 +1145,33 @@ export function fetchPostChat(accessToken: string, postId: string): Promise {
+export function askPostChat(
+ accessToken: string,
+ postId: string,
+ question: string,
+ conversationId?: string | null,
+): Promise {
return backendFetch(`/api/posts/${postId}/chat`, accessToken, {
method: "POST",
- body: JSON.stringify({ question }),
+ body: JSON.stringify({ question, ...(conversationId ? { conversation_id: conversationId } : {}) }),
});
}
+export function fetchPostChatConversations(
+ accessToken: string,
+ postId: string,
+): Promise {
+ return backendFetch(`/api/posts/${postId}/chat/conversations`, accessToken);
+}
+
+export function fetchPostChatConversation(
+ accessToken: string,
+ postId: string,
+ conversationId: string,
+): Promise {
+ return backendFetch(`/api/posts/${postId}/chat/conversations/${conversationId}`, accessToken);
+}
+
/** How often the queued Ask job is polled, and for how long overall.
* A live orchestrator answer can take minutes under shared-gateway load,
* so the ceiling is generous; the poll interval keeps the reader's
diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts
index 2d19b1cb8..87dbf4989 100644
--- a/frontend/src/i18n.ts
+++ b/frontend/src/i18n.ts
@@ -310,6 +310,9 @@ const TRANSLATIONS: Partial>> = {
"첨부 이미지를 해독할 수 없습니다. 원문을 다시 내보내고 다시 여세요.",
"What happened between these events?": "이 사건들 사이에 무슨 일이 있었나요?",
"Ask about this lineage": "이 계보에 대해 질문",
+ "Conversation history": "대화 기록",
+ "New conversation": "새 대화",
+ "Conversation history could not be loaded.": "대화 기록을 불러올 수 없습니다.",
"No reconstructed lineage yet. Rebuild after seeding posts.":
"아직 재구성된 계보가 없습니다. 글을 시드한 뒤 다시 만드세요.",
"Reconstructed lineage": "재구성된 계보",
@@ -810,6 +813,9 @@ const TRANSLATIONS: Partial>> = {
"无法解码嵌入图像。请重新导出原始文章后再打开。",
"What happened between these events?": "这些事件之间发生了什么?",
"Ask about this lineage": "询问此谱系",
+ "Conversation history": "对话历史",
+ "New conversation": "新对话",
+ "Conversation history could not be loaded.": "无法加载对话历史。",
"No reconstructed lineage yet. Rebuild after seeding posts.":
"尚未重建事件谱系。生成文章种子后再重建。",
"Reconstructed lineage": "已重建的事件谱系",
@@ -1322,6 +1328,9 @@ const TRANSLATIONS: Partial>> = {
"埋め込み画像をデコードできませんでした。原文を再エクスポートして、もう一度開いてください。",
"What happened between these events?": "これらのイベントの間に何が起きましたか?",
"Ask about this lineage": "この系譜について質問",
+ "Conversation history": "会話履歴",
+ "New conversation": "新しい会話",
+ "Conversation history could not be loaded.": "会話履歴を読み込めませんでした。",
"No reconstructed lineage yet. Rebuild after seeding posts.":
"再構成された系譜はまだありません。投稿をシードしてから再構成してください。",
"Reconstructed lineage": "再構成された系譜",
@@ -1822,6 +1831,9 @@ const TRANSLATIONS: Partial>> = {
"Không thể giải mã hình ảnh nhúng. Hãy xuất lại bài viết gốc rồi mở lại.",
"What happened between these events?": "Điều gì đã xảy ra giữa các sự kiện này?",
"Ask about this lineage": "Hỏi về dòng sự kiện này",
+ "Conversation history": "Lịch sử hội thoại",
+ "New conversation": "Cuộc hội thoại mới",
+ "Conversation history could not be loaded.": "Không thể tải lịch sử hội thoại.",
"No reconstructed lineage yet. Rebuild after seeding posts.":
"Chưa có dòng sự kiện được tái dựng. Hãy tạo dữ liệu mồi rồi tái dựng lại.",
"Reconstructed lineage": "Dòng sự kiện đã tái dựng",
diff --git a/migrations/0223_post_ask_conversation_history.sql b/migrations/0223_post_ask_conversation_history.sql
new file mode 100644
index 000000000..cd8e0f17f
--- /dev/null
+++ b/migrations/0223_post_ask_conversation_history.sql
@@ -0,0 +1,48 @@
+-- ADR 0228: persist account-owned Ask conversations on each visible post.
+-- Third normal form: session identity, turn text, and citation/source
+-- relations are separate tables. Index leads with user_account_id so a
+-- frequently asked post cannot become a single hot partition key.
+
+create table if not exists post_ask_session (
+ post_ask_session_id uuid primary key,
+ post_id uuid not null references source_post(post_id) on delete cascade,
+ user_account_id uuid not null references user_account(user_account_id) on delete cascade,
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now()
+);
+
+create index if not exists post_ask_session_account_post_idx
+ on post_ask_session (user_account_id, post_id, updated_at desc);
+
+create table if not exists post_ask_turn (
+ post_ask_session_id uuid not null
+ references post_ask_session(post_ask_session_id) on delete cascade,
+ turn_ordinal integer not null check (turn_ordinal > 0),
+ question_text text not null,
+ answer_text text not null,
+ created_at timestamptz not null default now(),
+ primary key (post_ask_session_id, turn_ordinal)
+);
+
+create table if not exists post_ask_turn_citation (
+ post_ask_session_id uuid not null,
+ turn_ordinal integer not null,
+ citation_ordinal integer not null check (citation_ordinal >= 0),
+ cited_post_id uuid not null references source_post(post_id) on delete cascade,
+ primary key (post_ask_session_id, turn_ordinal, citation_ordinal),
+ foreign key (post_ask_session_id, turn_ordinal)
+ references post_ask_turn(post_ask_session_id, turn_ordinal)
+ on delete cascade
+);
+
+create table if not exists post_ask_turn_source (
+ post_ask_session_id uuid not null,
+ turn_ordinal integer not null,
+ source_ordinal integer not null check (source_ordinal >= 0),
+ source_post_id uuid not null references source_post(post_id) on delete cascade,
+ primary key (post_ask_session_id, turn_ordinal, source_ordinal),
+ unique (post_ask_session_id, turn_ordinal, source_post_id),
+ foreign key (post_ask_session_id, turn_ordinal)
+ references post_ask_turn(post_ask_session_id, turn_ordinal)
+ on delete cascade
+);
diff --git a/migrations/rollback/0223_post_ask_conversation_history.sql b/migrations/rollback/0223_post_ask_conversation_history.sql
new file mode 100644
index 000000000..5a820c07a
--- /dev/null
+++ b/migrations/rollback/0223_post_ask_conversation_history.sql
@@ -0,0 +1,4 @@
+drop table if exists post_ask_turn_citation;
+drop table if exists post_ask_turn_source;
+drop table if exists post_ask_turn;
+drop table if exists post_ask_session;
diff --git a/tests/test_post_ask_history.py b/tests/test_post_ask_history.py
new file mode 100644
index 000000000..57051eeb6
--- /dev/null
+++ b/tests/test_post_ask_history.py
@@ -0,0 +1,276 @@
+"""Regression tests for account-owned per-post Ask history queries."""
+
+from __future__ import annotations
+
+import asyncio
+from datetime import UTC, datetime
+from uuid import UUID
+
+import pytest
+
+from backend.app.post_ask_history import (
+ PostAskEvidenceChanged,
+ _visible_post_ids_batch,
+ conversation_exists,
+ list_conversations,
+ persist_turn,
+)
+
+
+class _Connection:
+ """Capture bound SQL calls without needing a database for query-shape tests."""
+
+ def __init__(self, rows: list[dict[str, object]] | None = None) -> None:
+ self.rows = rows or []
+ self.calls: list[tuple[str, tuple[object, ...]]] = []
+
+ async def fetch(self, query: str, *arguments: object) -> list[dict[str, object]]:
+ self.calls.append((query, arguments))
+ return self.rows
+
+ async def fetchval(self, query: str, *arguments: object) -> object:
+ self.calls.append((query, arguments))
+ if "exists(" in query:
+ return True
+ if "coalesce(max(turn_ordinal)" in query:
+ return 1
+ return None
+
+ async def fetchrow(self, query: str, *arguments: object) -> dict[str, object] | None:
+ self.calls.append((query, arguments))
+ return {"post_ask_session_id": arguments[0]}
+
+ async def execute(self, query: str, *arguments: object) -> str:
+ self.calls.append((query, arguments))
+ return "INSERT 0 1"
+
+ def transaction(self) -> _Connection:
+ return self
+
+ async def __aenter__(self) -> _Connection:
+ return self
+
+ async def __aexit__(self, *_exc: object) -> None:
+ return None
+
+
+def test_list_conversations_binds_account_post_and_cursor() -> None:
+ """Pagination and post scope remain parameters, never interpolated SQL."""
+ connection = _Connection(
+ [
+ {
+ "post_ask_session_id": UUID("00000000-0000-0000-0000-000000000001"),
+ "conversation_title": "First",
+ "updated_at": datetime(2026, 1, 2, tzinfo=UTC),
+ "turn_count": 1,
+ },
+ {
+ "post_ask_session_id": UUID("00000000-0000-0000-0000-000000000002"),
+ "conversation_title": "Second",
+ "updated_at": datetime(2026, 1, 1, tzinfo=UTC),
+ "turn_count": 2,
+ },
+ ]
+ )
+ cursor_time = datetime(2026, 1, 3, tzinfo=UTC)
+
+ result = asyncio.run(
+ list_conversations(
+ connection,
+ "account-1",
+ "post-1",
+ limit=1,
+ before_updated_at=cursor_time,
+ before_conversation_id=UUID("00000000-0000-0000-0000-000000000003"),
+ )
+ )
+
+ query, arguments = connection.calls[0]
+ assert "{cursor_clause}" not in query
+ assert "post_ask_session" in query
+ assert arguments == (
+ "account-1",
+ "post-1",
+ cursor_time,
+ UUID("00000000-0000-0000-0000-000000000003"),
+ 2,
+ )
+ assert len(result["conversations"]) == 1
+ assert result["next_cursor"] is not None
+ assert result["conversations"][0]["conversation_id"] == "00000000-0000-0000-0000-000000000001"
+
+
+def test_conversation_exists_requires_account_and_post() -> None:
+ """A conversation id from another post or account must not match."""
+ connection = _Connection()
+ conversation_id = UUID("00000000-0000-0000-0000-000000000009")
+
+ found = asyncio.run(
+ conversation_exists(connection, "account-1", "post-1", conversation_id)
+ )
+
+ query, arguments = connection.calls[0]
+ assert "post_ask_session" in query
+ assert arguments == (conversation_id, "account-1", "post-1")
+ assert found is True
+
+
+def test_visible_post_ids_batch_uses_fixed_source_and_citation_queries() -> None:
+ """Both relation types use fixed identifiers rather than interpolated SQL."""
+ for source, table, column in (
+ (True, "post_ask_turn_source", "source_post_id"),
+ (False, "post_ask_turn_citation", "cited_post_id"),
+ ):
+ connection = _Connection(
+ [
+ {
+ "turn_ordinal": 1,
+ "post_id": "post-1",
+ "post_title": "Synthetic source",
+ "visibility_code": "workspace",
+ "corporate_entity_id": "entity-1",
+ "author_account_id": "account-1",
+ "source_detail_state_code": "current",
+ }
+ ]
+ )
+ by_turn = asyncio.run(
+ _visible_post_ids_batch(
+ connection,
+ UUID("00000000-0000-0000-0000-000000000004"),
+ [1],
+ lambda row: row["post_id"] == "post-1",
+ source=source,
+ )
+ )
+ post_ids, rows = by_turn[1]
+
+ query, arguments = connection.calls[0]
+ assert table in query
+ assert column in query
+ assert "relation.{" not in query
+ assert "source_draft_code" in query
+ assert "source_deleted_flag" in query
+ assert arguments[1] == [1]
+ assert post_ids == ["post-1"]
+ assert rows["post-1"]["post_title"] == "Synthetic source"
+
+
+def test_visible_post_ids_batch_stays_at_one_query_regardless_of_turn_count() -> None:
+ """Query count must not grow with the number of turns being reauthorized."""
+ connection = _Connection([])
+
+ by_turn = asyncio.run(
+ _visible_post_ids_batch(
+ connection,
+ UUID("00000000-0000-0000-0000-000000000004"),
+ list(range(1, 51)),
+ lambda row: True,
+ source=True,
+ )
+ )
+
+ assert len(connection.calls) == 1
+ assert set(by_turn.keys()) == set(range(1, 51))
+ assert all(ids == [] and rows == {} for ids, rows in by_turn.values())
+
+
+def test_visible_post_ids_batch_never_leaks_a_row_into_the_wrong_turn() -> None:
+ """A row is partitioned to its own turn_ordinal, never a neighboring turn."""
+ connection = _Connection(
+ [
+ {
+ "turn_ordinal": 1,
+ "post_id": "post-turn-1",
+ "post_title": "Turn 1 post",
+ "visibility_code": "public",
+ "corporate_entity_id": "entity-1",
+ "author_account_id": "account-1",
+ "source_detail_state_code": "current",
+ },
+ {
+ "turn_ordinal": 2,
+ "post_id": "post-turn-2",
+ "post_title": "Turn 2 post",
+ "visibility_code": "public",
+ "corporate_entity_id": "entity-1",
+ "author_account_id": "account-1",
+ "source_detail_state_code": "current",
+ },
+ ]
+ )
+
+ by_turn = asyncio.run(
+ _visible_post_ids_batch(
+ connection,
+ UUID("00000000-0000-0000-0000-000000000004"),
+ [1, 2, 3],
+ lambda row: True,
+ source=False,
+ )
+ )
+
+ assert by_turn[1][0] == ["post-turn-1"]
+ assert by_turn[2][0] == ["post-turn-2"]
+ assert by_turn[3] == ([], {})
+
+
+def test_persist_turn_creates_a_new_session_then_writes_the_turn() -> None:
+ """A completed answer is stored under the calling account and post."""
+ connection = _Connection()
+ conversation_id = asyncio.run(
+ persist_turn(
+ connection,
+ "account-1",
+ "post-1",
+ None,
+ "Which site visit was saved?",
+ "The saved post answer stays grounded in the linked source.",
+ ["post-1", "post-2"],
+ ["post-2"],
+ )
+ )
+
+ statements = [query for query, _arguments in connection.calls]
+ assert any("insert into post_ask_session" in query for query in statements)
+ assert any("insert into post_ask_turn" in query for query in statements)
+ assert any("insert into post_ask_turn_source" in query for query in statements)
+ assert any("insert into post_ask_turn_citation" in query for query in statements)
+ session_arguments = next(
+ arguments
+ for query, arguments in connection.calls
+ if "insert into post_ask_session" in query
+ )
+ assert session_arguments[0] == conversation_id
+ assert session_arguments[1:] == ("post-1", "account-1")
+
+
+def test_persist_turn_aborts_when_a_citation_loses_authorization() -> None:
+ """The transaction fails closed if cited evidence is no longer visible."""
+ connection = _Connection([
+ {
+ "post_id": "post-2",
+ "post_title": "Synthetic source",
+ "visibility_code": "workspace",
+ "corporate_entity_id": "entity-2",
+ "author_account_id": "account-2",
+ "source_detail_state_code": "current",
+ }
+ ])
+
+ with pytest.raises(PostAskEvidenceChanged):
+ asyncio.run(
+ persist_turn(
+ connection,
+ "account-1",
+ "post-1",
+ None,
+ "What changed?",
+ "A complete answer.",
+ ["post-1", "post-2"],
+ ["post-2"],
+ can_see_post=lambda _row: False,
+ )
+ )
+
+ assert any("for share of post" in query.lower() for query, _ in connection.calls)
From a2558a240aafe21a756b95c8d7ca6d8b3a3e9d60 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 09:16:36 +0900
Subject: [PATCH 2/2] docs(gap): track per-post Ask delivery evidence
---
docs/operability/http-concurrency-evidence.md | 10 ++++++++++
docs/product-technical-gap-baseline.md | 10 +++++-----
2 files changed, 15 insertions(+), 5 deletions(-)
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/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index c0cb4f939..c03aad152 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -383,13 +383,13 @@ feature is equivalent.
| ADR 0133 — source-reference research | No `source_reference_research` persistence, post-admin action, or reader contract exists. The existing relation-verification client verifies an already extracted organization and is not the cited-resource discovery workflow | Missing | Add the ADR first, then one bounded SearXNG → contextual-orchestrator judgment slice with public-host/redirect rejection, normalized provenance, synthetic SSRF tests, and no entity binding from a search hit alone |
| ADR 0134 — token-backed exception messages | Protected main has no shared `StatusNotice`; #643 (`3453ab08`) is the active current-main implementation candidate | In progress, not protected delivery | Land #643 only after exact-head checks and independent approval; then migrate remaining raw/color-only exception surfaces with Storybook unavailable/retry scenes |
| ADR 0135 — analysis-kind exact next actions | Protected main has no `analysisRunGuidance`; stacked #669 (`21bb799c`) adds cancelled-run guidance and responsive layout on top of the baseline branch | In progress, not protected delivery | Land #667 then #669 with exact-head UI tests and desktop/mobile screenshot evidence; follow with the remaining kind × status Storybook interaction matrix without inventing TEPP or report actions |
-| ADR 0136 — per-post Ask history | No `post_ask_session` or `post_ask_turn` schema/API exists on protected main | Missing | Add the ADR and normalized account + post scoped tables, hot-post-safe index, batched visibility reauthorization, list/select/new UI, cross-account/post rejection tests, and citation-revocation evidence |
+| ADR 0136 — per-post Ask history | Protected main has no `post_ask_session` or `post_ask_turn`; the current stacked candidate records the superseding ADR 0228 and adds normalized account + post scoped tables, an account-leading index, batched visibility reauthorization, list/select/new UI, cross-scope rejection, and citation-revocation rollback | In progress, not protected delivery | Re-fetch the candidate's exact head, complete hosted checks and independent approval, then verify the migration and authenticated UI against an exact-head stack; do not treat local tests or screenshots as release evidence |
| ADR 0137 — cross-post customer identity | No `customer_identity_judgment`, `customer_identity_binding`, or `corporate_entity_name_history` schema exists on protected main | Missing | Add the ADR before implementation; retain `(source_system_code, source_customer_code)` identity, require multiple eligible posts and external cited corroboration, persist abstention/tie states, and consume owning-library judgment evidence without local scoring |
-The next implementation order is ADR 0134/#643 and ADR 0135/#669 because
-their focused current-base heads already exist. ADR 0133, ADR 0136, and ADR
-0137 must each start from a new ADR-first current-main PR; none is authorized
-for wholesale replay from #490.
+ADR 0134/#643 and ADR 0135/#669 have focused delivery heads. ADR 0136 is now a
+separate ADR-first candidate reusing only the reviewed persistence contract,
+not #490's 321-file tree. ADR 0133 and ADR 0137 remain missing and each requires
+its own current-main decision and focused PR.
## 5. Open product and technical gaps