diff --git a/.env.example b/.env.example
index f661a06e0..439aecf17 100644
--- a/.env.example
+++ b/.env.example
@@ -39,6 +39,8 @@ MCP_RATE_LIMIT_WINDOW_SECONDS=
# running contextual-orchestrator to turn the channels on.
ORCHESTRATOR_BASE_URL=
ORCHESTRATOR_API_KEY=
+SOURCE_RESEARCH_MAXIMUM_LEADS=
+SOURCE_RESEARCH_MAXIMUM_RESULTS=
# GitHub workflows inject the canonical provider names from masked secrets.
# Non-GitHub Compose runs also accept the operator's ~/.env compatibility
diff --git a/AGENTS.md b/AGENTS.md
index 1486c6ccf..9a7fddb6c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -196,8 +196,10 @@ contextual-orchestrator owns model discovery and selection.
`NullEmbeddingClient`, `NullAdjudicationClient`,
`NullKeymanExtractionClient`, `NullEntityRelationshipClient`,
-`NullPostSummaryClient`, `NullPostChatClient`, and
-`NullCommitmentExtractionClient` (and any new channel client you add)
+`NullPostSummaryClient`, `NullPostChatClient`,
+`NullCommitmentExtractionClient`, `NullRelationVerificationClient`,
+`NullClaimVerificationClient`, and `NullSourceResearchClient`
+(and any new channel client you add)
must set `available = False` and make their channel dropped +
renormalized (`reconstruct.active_weights`), never silently return a
placeholder score, invented Keyman, guessed relationship, fabricated
@@ -210,6 +212,14 @@ adjudication does -- never a raw LLM API. Demo TEPP seed goes through
envelope is Failed (`tepp_not_available` / `tepp_result_not_persisted`),
never a fabricated theta or a local psychometric substitute.
+Public source-reference research (ADR 0248) is a post-scoped write action
+on existing semantic units or image regions. Only `visibility_code=public`
+posts may send lead text to SearXNG or retrieve a result URL. Private posts
+fail closed without egress. Redirects and non-global targets are rejected.
+Unavailable search, retrieval, or adjudication is `research_unavailable`,
+never a fabricated supported/refuted judgment. Global Ask public
+verification (ADR 0215) still never fetches result URLs.
+
The lineage `text` channel follows [ADR 0190](docs/adr/0190-lineage-text-channel-embedding-swap.md):
when an embedding provider is configured, `reconstruct()` precomputes
batched label embeddings once per reconstruction and scores cosine
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index f02bf22a4..2ecc900bb 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -826,6 +826,19 @@ against a deliberately fabricated one in the same request, asserting
the former comes back `verify_corroborated` with a real evidence URL
and the latter `verify_uncorroborated` with none.
+## Phase 6e: post-scoped source-reference research
+
+Issue #611's remaining ADR 0133 criterion is a different workflow from
+relation verification and from Global Ask snippet verification (ADR 0215).
+A public post may send an existing semantic unit or image-region excerpt
+to self-hosted SearXNG, retrieve one cited public page under SSRF and
+redirect rejection, and ask contextual-orchestrator to judge in
+`mode="verify"`. Private posts fail closed without egress. Citations
+persist to `source_research_citation` (migration 0236, ADR 0248). The
+reader next action is to open the cited public resource and compare it
+with the highlighted passage or image detail. Global Ask still never
+fetches result URLs.
+
## Phase 7: R&R's named actor is a PROV-O Agent, not always a person
`post_summary.py`'s R&R extraction forced every named actor into a
diff --git a/CHANGELOG.d/2.19.0-post-scoped-source-reference-research.md b/CHANGELOG.d/2.19.0-post-scoped-source-reference-research.md
new file mode 100644
index 000000000..efa76f3f0
--- /dev/null
+++ b/CHANGELOG.d/2.19.0-post-scoped-source-reference-research.md
@@ -0,0 +1,17 @@
+# 2.19.0 — Post-scoped source-reference research
+
+## Added
+
+- Public posts can research a highlighted passage or image detail against a
+ cited public page (ADR 0248, remaining ADR 0133 / issue #611). The workflow
+ reuses self-hosted SearXNG, retrieves one public HTTP(S) target with
+ redirects disabled and non-global addresses rejected, and judges through
+ contextual-orchestrator `mode=verify`. Private posts fail closed without
+ egress. Deployments must set both source-research resource budgets explicitly;
+ otherwise the channel remains unavailable. Citations persist in 3NF
+ `source_research_citation`.
+- Reader next action: open the cited public resource, then compare it with
+ the highlighted passage or image detail. Supported or refuted judgments
+ without a cited URL downgrade to not enough information. Missing search,
+ retrieval, or adjudication is `research_unavailable`, never a fabricated
+ score.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9712b74d2..b182dbb5d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -51,6 +51,20 @@ All notable changes to this project are documented here. Format follows
### Added
+- Customer, MCP-client, and operator manuals now describe the shipped
+ Dashboard, twelve Voice categories, product evidence, asynchronous Ask
+ citations and related public originals, canonical Compose stack, OIDC/MCP
+ session handling, k6 observation procedure, and fail-closed TEPP boundary.
+ Each unavailable result points to a recovery action instead of exposing an
+ internal model or provider choice.
+
+- Public posts can research a highlighted passage or image detail against a
+ cited public page (ADR 0248 / remaining ADR 0133). SearXNG finds candidates;
+ retrieval refuses redirects and non-global targets; contextual-orchestrator
+ judges in `mode=verify`. Private posts fail closed without sending content,
+ and absent explicit source-research resource budgets keep the channel unavailable.
+ After seed, open a public post and choose **Research public sources**, then
+ open the cited public resource and compare it with that highlighted content.
- Expanded Voice-of-X post taxonomy (ADR 0246): the governed `voc_type`
scheme adds Voice of Supplier, Employee, Business, Regulator, Investor,
Society, and Process as source-post categories. Ontology, SHACL, the
diff --git a/CLAUDE.md b/CLAUDE.md
index eb9e85eab..ec8360f0f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -50,7 +50,7 @@ Create/start endpoint rules (ADR 0017 / 0021), tie-vs-miss similarity
(ADR 0026), R&R catalog ids (ADR 0019 / 0027), leftover pairs
(ADR 0048–0164 / 0182 / 0201), the text-channel embedding swap and cosine
clamp (ADR 0190), per-edge channel-score persistence (ADR 0195),
-migration replay (ADR 0166), docstring coverage, and the measurement
-boundary are all stated in [AGENTS.md](AGENTS.md) -- read it before
-changing code, tests, or runtime policy rather than restating anything
-here.
+migration replay (ADR 0166), docstring coverage, source-reference
+research (ADR 0248), and the measurement boundary are all stated in
+[AGENTS.md](AGENTS.md) -- read it before changing code, tests, or runtime
+policy rather than restating anything here.
diff --git a/README.md b/README.md
index 4a38de32e..85025c9fa 100644
--- a/README.md
+++ b/README.md
@@ -184,6 +184,11 @@ docker compose --profile mcp up mcp
# Streamable HTTP resource: http://localhost:18001/mcp
```
+For client initialization, tool arguments, durable status handling, and quota
+recovery, see the [MCP manual](docs/manuals/mcp-manual.md). Workspace users can
+start with the [user guide](docs/manuals/user-guide.md); deployment and incident
+procedures are in the [operations manual](docs/manuals/operations-manual.md).
+
`GET /api/posts`, `GET /api/posts/{post_id}`,
`GET /api/posts/{post_id}/keymen`, `GET /api/keymen/{person_id}/related`,
`GET /api/posts/{post_id}/affiliate-tree`,
diff --git a/backend/Dockerfile b/backend/Dockerfile
index 9230812d3..e46b44dcc 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -39,6 +39,8 @@ RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev --extra backend --no-editable \
&& chown -R appuser:appuser /app
+ARG LINEAGEWEAVE_SOURCE_REVISION=unknown
+LABEL org.opencontainers.image.revision=${LINEAGEWEAVE_SOURCE_REVISION}
USER appuser
EXPOSE 8000
CMD ["uvicorn", "backend.app.main:app", "--host", "0.0.0.0", "--port", "8000"]
diff --git a/backend/app/config.py b/backend/app/config.py
index a49bd5390..0e5dba9ef 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -58,6 +58,8 @@ class Settings:
orchestrator_answer_timeout_seconds: float
valkey_url: str
searxng_base_url: str
+ source_research_maximum_leads: int | None
+ source_research_maximum_results: int | None
tepp_transport_url: str
tepp_api_key: str
caldav_base_url: str
@@ -205,6 +207,12 @@ def load_settings() -> Settings:
),
valkey_url=os.environ.get("VALKEY_URL", "redis://localhost:16379/0"),
searxng_base_url=os.environ.get("SEARXNG_BASE_URL", ""),
+ source_research_maximum_leads=_optional_positive_int(
+ "SOURCE_RESEARCH_MAXIMUM_LEADS"
+ ),
+ source_research_maximum_results=_optional_positive_int(
+ "SOURCE_RESEARCH_MAXIMUM_RESULTS"
+ ),
tepp_transport_url=os.environ.get("TEPP_TRANSPORT_URL", ""),
tepp_api_key=os.environ.get("TEPP_API_KEY", ""),
caldav_base_url=os.environ.get("CALDAV_BASE_URL", "").strip(),
diff --git a/backend/app/global_ask_queue.py b/backend/app/global_ask_queue.py
index 8dc402bae..9d593009f 100644
--- a/backend/app/global_ask_queue.py
+++ b/backend/app/global_ask_queue.py
@@ -64,6 +64,7 @@
gather_global_chat_sources,
prepare_global_question_embedding,
)
+from .source_research_ingestion import list_ask_source_references
GLOBAL_ASK_STREAM_KEY = "global_ask_request_stream"
@@ -438,13 +439,18 @@ def can_see(row: asyncpg.Record) -> bool:
verify_external=verify_external,
client=verification_client,
)
- if knowledge_cutoff is None:
- async with pool.acquire() as conn:
+ async with pool.acquire() as conn:
+ if knowledge_cutoff is None:
lineage_graph = await lineage_graphs_for_posts(conn, can_see, cited_ids)
images = await cited_post_images(conn, cited_ids)
- else:
- lineage_graph = {"nodes": [], "edges": [], "truncated": False}
- images = []
+ else:
+ lineage_graph = {"nodes": [], "edges": [], "truncated": False}
+ images = []
+ source_references = await list_ask_source_references(
+ conn,
+ cited_ids,
+ checked_by=knowledge_cutoff,
+ )
cited_posts = cited_post_summaries(usable_sources, cited_ids)
cited_events = cited_post_events(usable_sources, cited_ids)
cited_evidence = cited_post_evidence(usable_sources, cited_ids)
@@ -462,9 +468,15 @@ def can_see(row: asyncpg.Record) -> bool:
"cited_events": cited_events,
"cited_post_evidence": cited_evidence,
"cited_post_images": images,
+ "cited_source_references": source_references,
"source_post_ids": [source.post_id for source in sources],
"lineage_graph": lineage_graph,
- "delivery": build_ask_delivery(answer.answer_text, cited_posts, cited_evidence),
+ "delivery": build_ask_delivery(
+ answer.answer_text,
+ cited_posts,
+ cited_evidence,
+ source_references,
+ ),
"external_verification_status": verification_status,
"external_claims": [claim.to_payload() for claim in external_claims],
"next_action": next_action,
diff --git a/backend/app/main.py b/backend/app/main.py
index 289a8bcb6..f27420384 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -152,6 +152,10 @@
)
from backend.app.ranking_ingestion import load_visible_ranking_posts
from backend.app.relation_verification_ingestion import verify_post_relations_from_pool
+from backend.app.source_research_ingestion import (
+ list_source_research_citations,
+ research_post_sources_from_pool,
+)
from backend.app.report_ingestion import (
GROUPING_KINDS,
fetch_period_comparison,
@@ -239,6 +243,12 @@
NullRelationVerificationClient,
SearxngRelationVerificationClient,
)
+from lineageweave.source_reference_research import (
+ PRIVATE_POST_UNAVAILABLE,
+ VISIBILITY_PUBLIC,
+ NullSourceResearchClient,
+ SearxngOrchestratedSourceResearchClient,
+)
from lineageweave.semantic_hints import customer_hint_trust, format_semantic_hints
from lineageweave.semantic_query import (
ContextualOrchestratorSemanticQueryClient,
@@ -349,6 +359,27 @@ def _claim_verification_client_factory():
return _claim_verification_client()
+def _source_research_client():
+ """Return the post-scoped public-research client, or its unavailable null."""
+
+ settings = load_settings()
+ if not (
+ settings.searxng_base_url
+ and settings.orchestrator_base_url
+ and settings.orchestrator_api_key
+ and settings.source_research_maximum_leads is not None
+ and settings.source_research_maximum_results is not None
+ ):
+ return NullSourceResearchClient()
+ return SearxngOrchestratedSourceResearchClient(
+ settings.searxng_base_url,
+ settings.orchestrator_base_url,
+ settings.orchestrator_api_key,
+ maximum_leads=settings.source_research_maximum_leads,
+ maximum_results=settings.source_research_maximum_results,
+ )
+
+
def _organization_name_resolution_client():
"""Live orchestrator client when configured; otherwise the unavailable null."""
settings = load_settings()
@@ -2459,6 +2490,112 @@ async def verify_post_entity_relationships(
}
+@app.get("/api/posts/{post_id}/research-citations")
+async def read_post_research_citations(
+ post_id: str,
+ account: CurrentAccount = Depends(get_current_account),
+ pool: asyncpg.Pool = Depends(get_pool),
+) -> dict[str, Any]:
+ """Return persisted public-research citations for this post's source leads."""
+
+ post = await _load_visible_post(post_id, account, pool)
+ if str(post["visibility_code"]) != VISIBILITY_PUBLIC:
+ return {
+ "post_id": str(post["post_id"]),
+ "visibility_code": post["visibility_code"],
+ "unavailable_reason": PRIVATE_POST_UNAVAILABLE,
+ "citations": [],
+ }
+ async with pool.acquire() as conn:
+ citations = await list_source_research_citations(conn, post_id)
+ return {
+ "post_id": str(post["post_id"]),
+ "visibility_code": post["visibility_code"],
+ "unavailable_reason": None,
+ "citations": [
+ {
+ "lead_kind_code": row["lead_kind_code"],
+ "lead_source_unit_id": row["lead_source_unit_id"],
+ "lead_image_region_id": row["lead_image_region_id"],
+ "lead_excerpt_text": row["lead_excerpt_text"],
+ "search_query_text": row["search_query_text"],
+ "evidence_url": row["evidence_url"],
+ "evidence_title_text": row["evidence_title_text"],
+ "evidence_excerpt_text": row["evidence_excerpt_text"],
+ "judgment_code": row["judgment_code"],
+ "rationale_text": row["rationale_text"],
+ "next_action_text": row["next_action_text"],
+ "checked_at": row["checked_at"],
+ }
+ for row in citations
+ ],
+ }
+
+
+@app.post("/api/posts/{post_id}/research-citations")
+async def research_post_source_references(
+ post_id: str,
+ account: CurrentAccount = Depends(get_current_account),
+ pool: asyncpg.Pool = Depends(get_pool),
+ valkey: redis.Redis = Depends(get_valkey),
+) -> dict[str, Any]:
+ """Search and retrieve a public resource for this post's source leads.
+
+ Private posts fail closed without sending content. Gated by post_admin
+ because retrieval is a real external-search write action.
+ """
+
+ _require_post_admin(account)
+ post = await _load_visible_post(post_id, account, pool)
+ if str(post["visibility_code"]) != VISIBILITY_PUBLIC:
+ return {
+ "post_id": str(post["post_id"]),
+ "visibility_code": post["visibility_code"],
+ "unavailable_reason": PRIVATE_POST_UNAVAILABLE,
+ "citations": [],
+ }
+ client = _source_research_client()
+ if not client.available:
+ raise HTTPException(
+ status.HTTP_503_SERVICE_UNAVAILABLE,
+ "Public research is unavailable. Ask an administrator to enable it, "
+ "then try again.",
+ )
+ try:
+ with use_llm_metadata(build_post_llm_metadata(post_id, post)):
+ run = await research_post_sources_from_pool(
+ pool,
+ client,
+ post_id,
+ visibility_code=str(post["visibility_code"]),
+ )
+ except (HttpClientError, OSError, ValueError) as exc:
+ raise HTTPException(
+ status.HTTP_503_SERVICE_UNAVAILABLE,
+ "Public research could not be completed. Try again later or review "
+ "this post's existing evidence.",
+ ) from exc
+ except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed.
+ raise HTTPException(
+ status.HTTP_503_SERVICE_UNAVAILABLE,
+ "Public research could not be completed. Try again later or review "
+ "this post's existing evidence.",
+ ) from exc
+ await publish_activity_event(
+ valkey,
+ post_id,
+ "source_research_checked",
+ account.user_account_id,
+ f"Public sources reviewed: {len(run.citations)} item(s)",
+ )
+ return {
+ "post_id": run.post_id,
+ "visibility_code": run.visibility_code,
+ "unavailable_reason": run.unavailable_reason,
+ "citations": [citation.to_payload() for citation in run.citations],
+ }
+
+
@app.post("/api/posts/{post_id}/extract-keymen")
async def extract_post_keymen(
post_id: str,
diff --git a/backend/app/mcp_server.py b/backend/app/mcp_server.py
index 9fa71de2a..c7e67d6b8 100644
--- a/backend/app/mcp_server.py
+++ b/backend/app/mcp_server.py
@@ -246,7 +246,7 @@ async def lifespan(_: MCPServer) -> AsyncIterator[McpAppContext]:
"lineageweave",
title="LineageWeave",
description="Authenticated provenance-bearing lineage intelligence.",
- version="2.18.0",
+ version="2.19.0",
lifespan=lifespan,
token_verifier=token_verifier or KeyverseMcpTokenVerifier(resolved),
auth=AuthSettings(
diff --git a/backend/app/post_content_queue.py b/backend/app/post_content_queue.py
index df0ea529a..38e79d4b6 100644
--- a/backend/app/post_content_queue.py
+++ b/backend/app/post_content_queue.py
@@ -381,28 +381,21 @@ async def ensure_post_content_job(
)
-async def enqueue_post_content_backfill(
- pool: asyncpg.Pool,
- client: redis.Redis | None,
- *,
- limit: int,
- require_embedding: bool,
- require_structure: bool,
-) -> dict[str, int]:
- """Durably enqueue one bounded page of eligible incomplete source posts.
-
- PostgreSQL is committed before Valkey is touched. A missing wake-up is
- therefore recoverable by :func:`republish_queued_post_content_jobs` rather
- than turning an operator request into lost work. Active and terminal jobs
- are excluded so repeated requests neither duplicate work nor reset the
- explicit retry boundary.
- """
- if not 1 <= limit <= 200:
- raise ValueError("limit must be between 1 and 200")
- query = f"""
+POST_CONTENT_BACKFILL_CANDIDATE_SQL = f"""
select post.post_id, post.post_body
from source_post post
left join post_content_ingestion_job job on job.post_id = post.post_id
+ left join operations_case_analysis analysis
+ on analysis.post_id = post.post_id
+ and analysis.source_body_sha256 = job.source_body_sha256
+ left join post_product_analysis product_analysis
+ on product_analysis.post_id = post.post_id
+ and product_analysis.source_body_sha256 = job.source_body_sha256
+ left join (
+ select distinct project.post_id
+ from post_project_mention project
+ where nullif(btrim(project.ontology_iri), '') is not null
+ ) ontology_project on ontology_project.post_id = post.post_id
where {SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')}
and (job.post_id is null or job.status_code = $1)
and (
@@ -443,34 +436,74 @@ async def enqueue_post_content_backfill(
or structure.decision_source_code = 'unresolved'
)
))
- or ($3::boolean and not exists (
- select 1
- from operations_case_analysis analysis
- where analysis.post_id = post.post_id
- and analysis.source_body_sha256 = job.source_body_sha256
- ))
- or ($3::boolean and not exists (
- select 1
- from post_product_analysis analysis
- where analysis.post_id = post.post_id
- and analysis.source_body_sha256 = job.source_body_sha256
- ))
+ or ($3::boolean and analysis.post_id is null)
+ or ($3::boolean and product_analysis.post_id is null)
)
- order by post.created_at, post.post_id
+ and ($5::boolean = (
+ $3::boolean
+ and ontology_project.post_id is not null
+ and job.source_body_sha256 is not null
+ and analysis.post_id is null
+ ))
+ order by coalesce(post.event_occurred_at, post.created_at),
+ post.created_at,
+ post.post_id
limit $4
for update of post skip locked
"""
+
+
+async def enqueue_post_content_backfill(
+ pool: asyncpg.Pool,
+ client: redis.Redis | None,
+ *,
+ limit: int,
+ require_embedding: bool,
+ require_structure: bool,
+) -> dict[str, int]:
+ """Durably enqueue one bounded page of eligible incomplete source posts.
+
+ PostgreSQL is committed before Valkey is touched. A missing wake-up is
+ therefore recoverable by :func:`republish_queued_post_content_jobs` rather
+ than turning an operator request into lost work. Active and terminal jobs
+ are excluded so repeated requests neither duplicate work nor reset the
+ explicit retry boundary.
+ """
+ if not 1 <= limit <= 200:
+ raise ValueError("limit must be between 1 and 200")
requests: list[PostContentJobRequest] = []
async with pool.acquire() as conn:
async with conn.transaction():
- # Safe SQL: the eligibility predicate is an immutable schema fragment; values are bound.
- rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli
- query,
- SUCCEEDED,
- require_embedding,
- require_structure,
- limit,
- )
+ rows = []
+ if require_structure:
+ # Safe SQL: the eligibility predicate is an immutable schema fragment; values are bound.
+ rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli
+ POST_CONTENT_BACKFILL_CANDIDATE_SQL,
+ SUCCEEDED,
+ require_embedding,
+ require_structure,
+ limit,
+ True,
+ )
+ if len(rows) < limit:
+ # Safe SQL: the same immutable candidate statement is reused with bound tier values.
+ rows += await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli
+ POST_CONTENT_BACKFILL_CANDIDATE_SQL,
+ SUCCEEDED,
+ require_embedding,
+ require_structure,
+ limit - len(rows),
+ False,
+ )
+ unique_rows = []
+ seen_post_ids: set[str] = set()
+ for row in rows:
+ post_id = str(row["post_id"])
+ if post_id in seen_post_ids:
+ continue
+ seen_post_ids.add(post_id)
+ unique_rows.append(row)
+ rows = unique_rows
for row in rows:
post_id = str(row["post_id"])
body = str(row["post_body"] or "")
diff --git a/backend/app/source_research_ingestion.py b/backend/app/source_research_ingestion.py
new file mode 100644
index 000000000..257ac3f76
--- /dev/null
+++ b/backend/app/source_research_ingestion.py
@@ -0,0 +1,304 @@
+"""Load source leads, run public research, and persist citations.
+
+Private posts fail closed before any search or retrieval. Already-checked
+leads retain their last determinate public evidence when a later provider
+attempt is unavailable.
+"""
+
+from __future__ import annotations
+
+import asyncio
+from dataclasses import dataclass
+from datetime import datetime
+
+import asyncpg
+
+from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL
+from lineageweave.http_client import HttpClientError
+from lineageweave.source_reference_research import (
+ NO_LEAD_UNAVAILABLE,
+ PRIVATE_POST_UNAVAILABLE,
+ VISIBILITY_PUBLIC,
+ SourceResearchCitation,
+ SourceResearchClient,
+ SourceResearchLead,
+ select_source_research_leads,
+ unavailable_citation,
+)
+
+
+@dataclass(frozen=True)
+class SourceResearchRun:
+ """One post-scoped research attempt, including fail-closed unavailability."""
+
+ post_id: str
+ visibility_code: str
+ citations: tuple[SourceResearchCitation, ...]
+ unavailable_reason: str | None = None
+
+
+async def load_source_research_leads(
+ conn: asyncpg.Connection,
+ post_id: str,
+ maximum_leads: int,
+) -> tuple[SourceResearchLead, ...]:
+ """Read persisted semantic units and image regions for ``post_id``."""
+
+ units = await conn.fetch(
+ """
+ select post_content_unit_id::text as post_content_unit_id,
+ unit_index,
+ unit_kind_code,
+ unit_text
+ from post_content_unit
+ where post_id = $1
+ order by unit_index
+ """,
+ post_id,
+ )
+ regions = await conn.fetch(
+ """
+ select region.post_content_image_region_id::text as post_content_image_region_id,
+ unit.unit_index as source_unit_index,
+ region.region_index,
+ region.caption,
+ region.extracted_text
+ from post_content_image_region region
+ join post_content_image image
+ on image.post_content_image_id = region.post_content_image_id
+ join post_content_unit unit
+ on unit.post_content_unit_id = image.post_content_unit_id
+ where unit.post_id = $1
+ order by unit.unit_index, region.region_index,
+ region.post_content_image_region_id
+ """,
+ post_id,
+ )
+ return select_source_research_leads(
+ [dict(row) for row in units],
+ [dict(row) for row in regions],
+ maximum_leads=maximum_leads,
+ )
+
+
+async def list_source_research_citations(
+ conn: asyncpg.Connection,
+ post_id: str,
+) -> list[dict[str, object]]:
+ """Return persisted citations for one authorized post, newest first."""
+
+ rows = await conn.fetch(
+ """
+ select lead_kind_code,
+ lead_source_unit_id::text as lead_source_unit_id,
+ lead_image_region_id::text as lead_image_region_id,
+ lead_excerpt_text,
+ search_query_text,
+ evidence_url,
+ evidence_title_text,
+ evidence_excerpt_text,
+ judgment_code,
+ rationale_text,
+ next_action_text,
+ checked_at
+ from source_research_citation citation
+ left join post_content_unit unit
+ on unit.post_content_unit_id = citation.lead_source_unit_id
+ left join post_content_image_region region
+ on region.post_content_image_region_id = citation.lead_image_region_id
+ left join post_content_image image
+ on image.post_content_image_id = region.post_content_image_id
+ left join post_content_unit image_unit
+ on image_unit.post_content_unit_id = image.post_content_unit_id
+ where citation.post_id = $1
+ order by citation.checked_at desc,
+ case when citation.lead_source_unit_id is not null then 0 else 1 end,
+ unit.unit_index,
+ image_unit.unit_index,
+ region.region_index,
+ citation.source_research_citation_id
+ """,
+ post_id,
+ )
+ return [dict(row) for row in rows]
+
+
+async def list_ask_source_references(
+ conn: asyncpg.Connection,
+ post_ids: list[str],
+ *,
+ checked_by: datetime | None = None,
+) -> list[dict[str, object]]:
+ """Return persisted, publication-eligible public references for cited posts.
+
+ ``post_ids`` has already crossed the Ask authorization boundary. The
+ query rechecks current publication eligibility so a visibility or source
+ lifecycle change cannot leak a citation between retrieval and delivery.
+ A cutoff answer receives only citations that already existed by its
+ cutoff; absent determinate evidence remains absent rather than invented.
+ """
+
+ if not post_ids:
+ return []
+ rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli
+ f"""
+ select citation.post_id::text as post_id,
+ citation.lead_kind_code,
+ citation.evidence_url,
+ citation.evidence_title_text,
+ citation.evidence_excerpt_text,
+ citation.judgment_code,
+ citation.next_action_text,
+ citation.checked_at
+ from source_research_citation citation
+ join source_post post on post.post_id = citation.post_id
+ where citation.post_id = any($1::uuid[])
+ and post.visibility_code = 'public'
+ and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')}
+ and citation.judgment_code in ('research_supported', 'research_refuted')
+ and citation.evidence_url is not null
+ and ($2::timestamptz is null or citation.checked_at <= $2)
+ order by array_position($1::uuid[], citation.post_id),
+ citation.checked_at desc,
+ citation.source_research_citation_id
+ """,
+ post_ids,
+ checked_by,
+ )
+ return [dict(row) for row in rows]
+
+
+async def persist_source_research_citation(
+ conn: asyncpg.Connection,
+ post_id: str,
+ citation: SourceResearchCitation,
+) -> None:
+ """Replace a lead citation without erasing determinate evidence on outage."""
+
+ values = (
+ post_id,
+ citation.lead_kind_code,
+ citation.lead_source_unit_id,
+ citation.lead_image_region_id,
+ citation.lead_excerpt_text,
+ citation.search_query_text,
+ citation.evidence_url,
+ citation.evidence_title_text,
+ citation.evidence_excerpt_text,
+ citation.judgment_code,
+ citation.rationale_text,
+ citation.next_action_text,
+ )
+ if citation.lead_source_unit_id is not None:
+ await conn.execute(
+ """
+ insert into source_research_citation (
+ post_id,
+ lead_kind_code,
+ lead_source_unit_id,
+ lead_image_region_id,
+ lead_excerpt_text,
+ search_query_text,
+ evidence_url,
+ evidence_title_text,
+ evidence_excerpt_text,
+ judgment_code,
+ rationale_text,
+ next_action_text
+ ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
+ on conflict (post_id, lead_source_unit_id)
+ where lead_source_unit_id is not null
+ do update set
+ lead_excerpt_text = excluded.lead_excerpt_text,
+ search_query_text = excluded.search_query_text,
+ evidence_url = excluded.evidence_url,
+ evidence_title_text = excluded.evidence_title_text,
+ evidence_excerpt_text = excluded.evidence_excerpt_text,
+ judgment_code = excluded.judgment_code,
+ rationale_text = excluded.rationale_text,
+ next_action_text = excluded.next_action_text,
+ checked_at = now()
+ where excluded.judgment_code <> 'research_unavailable'
+ or source_research_citation.judgment_code = 'research_unavailable'
+ """,
+ *values,
+ )
+ return
+ await conn.execute(
+ """
+ insert into source_research_citation (
+ post_id,
+ lead_kind_code,
+ lead_source_unit_id,
+ lead_image_region_id,
+ lead_excerpt_text,
+ search_query_text,
+ evidence_url,
+ evidence_title_text,
+ evidence_excerpt_text,
+ judgment_code,
+ rationale_text,
+ next_action_text
+ ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
+ on conflict (post_id, lead_image_region_id)
+ where lead_image_region_id is not null
+ do update set
+ lead_excerpt_text = excluded.lead_excerpt_text,
+ search_query_text = excluded.search_query_text,
+ evidence_url = excluded.evidence_url,
+ evidence_title_text = excluded.evidence_title_text,
+ evidence_excerpt_text = excluded.evidence_excerpt_text,
+ judgment_code = excluded.judgment_code,
+ rationale_text = excluded.rationale_text,
+ next_action_text = excluded.next_action_text,
+ checked_at = now()
+ where excluded.judgment_code <> 'research_unavailable'
+ or source_research_citation.judgment_code = 'research_unavailable'
+ """,
+ *values,
+ )
+
+
+
+async def research_post_sources_from_pool(
+ pool: asyncpg.Pool,
+ client: SourceResearchClient,
+ post_id: str,
+ visibility_code: str,
+) -> SourceResearchRun:
+ """Research public leads without holding a DB connection during web I/O."""
+
+ if visibility_code != VISIBILITY_PUBLIC:
+ return SourceResearchRun(
+ post_id=post_id,
+ visibility_code=visibility_code,
+ citations=(),
+ unavailable_reason=PRIVATE_POST_UNAVAILABLE,
+ )
+ async with pool.acquire() as conn:
+ leads = await load_source_research_leads(conn, post_id, client.maximum_leads)
+ if not leads:
+ return SourceResearchRun(
+ post_id=post_id,
+ visibility_code=visibility_code,
+ citations=(),
+ unavailable_reason=NO_LEAD_UNAVAILABLE,
+ )
+ citations: list[SourceResearchCitation] = []
+ for lead in leads:
+ try:
+ citation = await asyncio.to_thread(client.research, lead)
+ except (HttpClientError, OSError, ValueError):
+ citation = unavailable_citation(
+ lead,
+ "This item could not be checked. Review its existing evidence instead.",
+ )
+ citations.append(citation)
+ async with pool.acquire() as conn, conn.transaction():
+ for citation in citations:
+ await persist_source_research_citation(conn, post_id, citation)
+ return SourceResearchRun(
+ post_id=post_id,
+ visibility_code=visibility_code,
+ citations=tuple(citations),
+ )
diff --git a/backend/worker-healthcheck.sh b/backend/worker-healthcheck.sh
new file mode 100755
index 000000000..2d698e585
--- /dev/null
+++ b/backend/worker-healthcheck.sh
@@ -0,0 +1,29 @@
+#!/bin/sh
+# Check that the durable worker heartbeat advanced without importing Python.
+#
+# The worker writes a trusted monotonic integer. This probe keeps the same
+# progress contract as backend.app.worker_health while avoiding a Python
+# interpreter and package import for every container health check.
+
+set -eu
+
+heartbeat_path=${1:-/tmp/lineageweave-worker-heartbeat}
+state_path=${2:-/tmp/lineageweave-worker-healthcheck-state}
+
+current_heartbeat=$(cat "$heartbeat_path" 2>/dev/null) || exit 1
+case "$current_heartbeat" in
+ ''|*[!0-9]*) exit 1 ;;
+esac
+
+if IFS= read -r previous_heartbeat 2>/dev/null < "$state_path"; then
+ case "$previous_heartbeat" in
+ ''|*[!0-9]*) previous_heartbeat= ;;
+ esac
+ if [ -n "$previous_heartbeat" ] && [ "$current_heartbeat" -le "$previous_heartbeat" ]; then
+ exit 1
+ fi
+fi
+
+temporary_state="${state_path}.$$"
+printf '%s\n' "$current_heartbeat" > "$temporary_state"
+mv "$temporary_state" "$state_path"
diff --git a/docker-compose.yml b/docker-compose.yml
index d3b41468c..a6e945b44 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -147,6 +147,8 @@ services:
build:
context: .
dockerfile: backend/Dockerfile
+ args:
+ LINEAGEWEAVE_SOURCE_REVISION: ${LINEAGEWEAVE_SOURCE_REVISION:-unknown}
environment: &backend-environment
DATABASE_URL: postgresql://${POSTGRES_USER:-lineageweave}:${POSTGRES_PASSWORD:-lineageweave_dev_only}@postgres:5432/${POSTGRES_DB:-lineageweave}
# Internal DNS name for JWKS fetches (always reachable from inside the
@@ -215,6 +217,8 @@ services:
build:
context: .
dockerfile: backend/Dockerfile
+ args:
+ LINEAGEWEAVE_SOURCE_REVISION: ${LINEAGEWEAVE_SOURCE_REVISION:-unknown}
command: ["python", "-m", "backend.app.worker"]
restart: unless-stopped
environment: *backend-environment
@@ -230,7 +234,7 @@ services:
searxng:
condition: service_healthy
healthcheck:
- test: ["CMD", "python", "-m", "backend.app.worker_health"]
+ test: ["CMD", "/bin/sh", "/app/backend/worker-healthcheck.sh"]
interval: 10s
timeout: 3s
retries: 3
@@ -288,6 +292,7 @@ services:
build:
context: ./frontend
args:
+ LINEAGEWEAVE_SOURCE_REVISION: ${LINEAGEWEAVE_SOURCE_REVISION:-unknown}
VITE_KEYVERSE_ISSUER: ${KEYVERSE_ISSUER:-http://localhost:${KEYCLOAK_PORT:-18080}/realms/lineageweave-demo}
VITE_KEYVERSE_CLIENT_ID: ${KEYVERSE_CLIENT_ID:-lineageweave-frontend}
VITE_BACKEND_BASE_URL: http://localhost:${BACKEND_PORT:-18420}
diff --git a/docs/adr/0071-post-scoped-llm-session-metadata.md b/docs/adr/0071-post-scoped-llm-session-metadata.md
index a4fccc6a1..d5090f63c 100644
--- a/docs/adr/0071-post-scoped-llm-session-metadata.md
+++ b/docs/adr/0071-post-scoped-llm-session-metadata.md
@@ -11,6 +11,8 @@ deterministic `lineageweave_post_session_id` in the existing OpenAI-compatible
`session_id`. The correlation header defined by ADR 0122 carries that same
value. The ID is derived from `post_id` with a LineageWeave-only UUID namespace;
it is not a database key and does not require a `user_account + post_id` table.
+An explicitly supplied top-level value must equal the active post session;
+the transport rejects a mismatch instead of silently splitting provenance.
The same metadata object carries non-body provenance hints when available:
PU, author account ID, corporate-entity code, and source author/company,
diff --git a/docs/adr/0206-evidence-operations-dashboard.md b/docs/adr/0206-evidence-operations-dashboard.md
index 3cb1f64f8..71ff03498 100644
--- a/docs/adr/0206-evidence-operations-dashboard.md
+++ b/docs/adr/0206-evidence-operations-dashboard.md
@@ -170,6 +170,15 @@ provenance.
lossless human-readable form, names each milestone's clock, and links the
reader to both endpoint sources. State and next action are conveyed in text
rather than color alone.
+20. The bounded durable content backfill prefers an eligible post with a
+ canonical `post_project_mention.ontology_iri` projection when its exact
+ queued source-body digest lacks operations analysis. `EXISTS` prevents a
+ multi-project mention fan-out from duplicating the post. The remaining
+ incomplete posts stay in the same fallback queue, ordered after that tier by
+ event time (with the ADR 0202 created-time fallback), created time, and post
+ id before the existing bounded `LIMIT` / `SKIP LOCKED` claim. Titles, body
+ keywords, source lifecycle codes, and inferred stages do not affect this
+ priority.
## Consequences
@@ -193,6 +202,37 @@ treated as a negative case.
evidence links, keyboard semantics, and non-color status copy.
- Storybook interaction tests and authenticated browser screenshots audit the
rendered desktop and narrow layouts.
+- `scripts/accept_operations_dashboard_runtime.sh` fails closed on the exact
+ orchestrator image revision, performs the explicit structured-readiness
+ refresh only after operator opt-in, verifies one normalized preferred
+ candidate and a positive grounded-case aggregate delta, then exercises the
+ authenticated Dashboard API and rendered UI without printing source rows.
+ The same operator-declared run invokes `scripts/k6_operations_dashboard.js`
+ with explicit VUs and duration; it observes Dashboard reads only, defines no
+ performance threshold, and keeps its summary outside the repository. The
+ runner accepts the observation only when the summary records zero failed
+ functional checks and a zero HTTP-request failure rate; this is a correctness
+ postcondition, not a latency or capacity SLO.
+- `scripts/accept_operations_dashboard_synthetic.sh` obtains only the local
+ synthetic Keycloak identity and makes authenticated Dashboard reads without
+ starting content analysis or calling a provider. It rejects backend, worker,
+ or frontend images whose OCI revision label is not the operator-declared
+ exact LineageWeave commit, and keeps distinct desktop/mobile screenshots,
+ browser output, and k6 evidence
+ outside the repository. An empty synthetic case list remains a valid UI/API
+ shape check; it is not evidence that grounded production cases exist.
+- `scripts/explain_post_content_backfill.py` executes the exact bounded
+ candidate SQL with `EXPLAIN (ANALYZE, BUFFERS, WAL, FORMAT JSON)` inside a
+ rolled-back transaction. It reports only aggregate timing, buffer, temporary
+ block, node-kind, and relation-scan counts, so priority-sort, correlated
+ subquery, index, spill, and lock-path evidence is reproducible without
+ emitting source rows.
+- Backfill admission reads the ontology-backed priority tier first and reads
+ the remaining eligible tier only when fewer than the requested bounded page
+ are locked. This preserves the documented total order while avoiding a
+ corpus-wide priority `CASE` sort and its per-row correlated probes.
+ Candidate post identifiers are de-duplicated before mutation because the two
+ `READ COMMITTED` statements may observe a target moving between tiers.
## References
diff --git a/docs/adr/0224-canonical-compose-project.md b/docs/adr/0224-canonical-compose-project.md
index 47d04f80c..0fb683e0a 100644
--- a/docs/adr/0224-canonical-compose-project.md
+++ b/docs/adr/0224-canonical-compose-project.md
@@ -34,12 +34,23 @@ only the synthetic `lineageweave-demo` Keycloak realm.
The API process never owns a queue consumer. Instead, `backend` has a required
`service_healthy` dependency on `backend-worker`, whose progress-based health
-probe observes its event loop. Consequently, targeted canonical startup such
+probe observes its event loop. The probe reads the worker's monotonic heartbeat
+with the image's POSIX shell rather than starting and importing a Python
+process on every interval. This preserves progress detection while preventing
+concurrent health probes from amplifying container-runtime and filesystem load.
+Consequently, targeted canonical startup such
as `docker compose up backend` also starts the worker and does not expose an API
that can accept durable jobs while no consumer exists. Non-Compose deployments
must express the same co-deployment and readiness dependency in their service
manager; process liveness alone is not durable-job readiness.
+Backend, worker, and frontend images carry the
+`org.opencontainers.image.revision` label supplied by the explicit
+`LINEAGEWEAVE_SOURCE_REVISION` build argument. Its default is `unknown`, so an
+acceptance runner cannot mistake an ordinary local build for exact-head
+evidence. Exact-head evidence requires a full commit SHA supplied at build time
+and verified on every participating product container before the run.
+
## Consequences
- `make up`, `make ps`, `make logs`, and `make down` address the same project
diff --git a/docs/adr/0248-post-scoped-source-reference-research.md b/docs/adr/0248-post-scoped-source-reference-research.md
new file mode 100644
index 000000000..b76228fca
--- /dev/null
+++ b/docs/adr/0248-post-scoped-source-reference-research.md
@@ -0,0 +1,98 @@
+# ADR 0248: Post-scoped source-reference research
+
+**Status:** Accepted
+**Date:** 2026-08-26
+
+## Context
+
+Issue #611 decomposes closed PR #490. The remaining ADR 0133 criterion is
+absent from protected `main`: a post-scoped lead from a source semantic unit or
+image region, public search, retrieval of a cited public page, orchestrator
+judgment, and a persisted research citation.
+
+ADR 0005 verifies an already extracted ontology relation with a presence or
+absence search signal. ADR 0215 verifies Global Ask public claims from SearXNG
+snippets and **never fetches result URLs**. Those contracts stay unchanged.
+Source-reference research needs the retrieved page itself because the reader
+next action is to open the cited public resource and compare it with this
+post's source unit or image region.
+
+Private source content, people facts, TEPP artifacts, and fast-mlsirm artifacts
+must not leave the authorization boundary. EgressWeave is an exact-host
+allowlist and cannot retrieve arbitrary public pages. Retrieval therefore needs
+its own public-target SSRF and redirect rejection.
+
+## Decision
+
+1. Only a source post whose persisted `visibility_code` is `public` may send
+ lead text to SearXNG or retrieve a result URL. Private posts fail closed
+ without egress.
+2. Leads are existing `post_content_unit` rows (non-image kinds with non-empty
+ `unit_text`) or `post_content_image_region` rows with caption or extracted
+ text. The workflow does not invent a unit, region, claim, or score.
+3. SearXNG search reuses the self-hosted `SEARXNG_BASE_URL` boundary already
+ used by ADR 0005 and ADR 0215. The deployment must explicitly provide
+ positive `SOURCE_RESEARCH_MAXIMUM_LEADS` and
+ `SOURCE_RESEARCH_MAXIMUM_RESULTS` resource budgets. No undocumented default
+ or evidence-free ranking threshold is inferred; without both budgets the
+ channel is unavailable.
+4. Result retrieval is a distinct public-target client: HTTP(S) only, no
+ userinfo, no localhost or `.local` hosts, no non-global resolved addresses
+ including IPv4-mapped forms, no search-engine hosts, redirects refused, and
+ a bounded response body. DNS is resolved before connect; the client connects
+ to a previously classified public address and sends the original Host header.
+5. The retrieved excerpt crosses contextual-orchestrator with `mode="verify"`
+ and `reasoning_effort="auto"`. Allowed judgments are
+ `research_supported`, `research_refuted`,
+ `research_not_enough_information`, and `research_unavailable`. Supported or
+ refuted without a cited URL downgrades to not enough information.
+6. Citations persist in 3NF `source_research_citation`. External URLs stay
+ distinct from internal post identifiers. The workflow never mutates
+ ontology, Knowledge Graph, Event Lineage, TEPP, or fast-mlsirm state.
+7. Missing SearXNG, orchestrator, public target, or retrieved text is an
+ explicit unavailable outcome, never a fabricated negative judgment.
+8. A transient unavailable re-check is returned for the current attempt but
+ does not erase a lead's last determinate persisted judgment or cited public
+ resource. Citation reads use the persisted source-unit and image-region
+ order as the deterministic tie-break within one transaction timestamp.
+9. The bounded lead sequence alternates the two persisted source-kind streams,
+ beginning with whichever kind occurs first in document order. This gives
+ both a semantic-unit stream and an image-region stream a place whenever the
+ supplied budget can contain both, without an inferred score, weight, or
+ content-ranking heuristic. Each stream retains its persisted source order.
+10. A settled Global Ask answer may attach only the determinate persisted
+ references belonging to its already-authorized cited posts. Delivery
+ rechecks current publication eligibility, limits historical answers to
+ references checked by the requested cutoff, and returns the same reference
+ fields through REST, UI, report, and MCP's shared durable answer. Missing
+ references remain absent; no title or URL is synthesized.
+
+## Consequences
+
+- Readers can research a public post's own source unit or image region without
+ mixing Global Ask snippet verification into the same table.
+- A reader can move from an Ask citation to its event card, internal post, and
+ persisted related public document without treating that document as Event
+ Lineage or ontology state.
+- Private posts remain inside the authorization boundary.
+- Redirect-based SSRF and DNS rebinding are rejected at the retrieval client,
+ not compensated later in UI copy.
+
+## Related
+
+Implements the remaining ADR 0133 delivery named in issue #611 on current
+`main`. Distinct from [ADR 0005](0005-relation-verification-agent.md) and
+[ADR 0215](0215-global-ask-public-claim-verification.md).
+
+## References
+
+Lebo, T., Sahoo, S., & McGuinness, D. (Eds.). (2013). *PROV-O: The PROV
+ontology*. World Wide Web Consortium. https://www.w3.org/TR/prov-o/
+
+SearXNG. (2026). *Search API*. https://docs.searxng.org/dev/search_api.html
+
+Thorne, J., Vlachos, A., Christodoulopoulos, C., & Mittal, A. (2018). FEVER: A
+large-scale dataset for fact extraction and verification. In *Proceedings of
+the 2018 Conference of the North American Chapter of the Association for
+Computational Linguistics: Human Language Technologies* (Vol. 1, pp. 809–819).
+Association for Computational Linguistics. https://doi.org/10.18653/v1/N18-1074
diff --git a/docs/adr/README.md b/docs/adr/README.md
index 56dbb3dcc..3729bf52d 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -16,9 +16,14 @@ decision from them.
| [`PROV_O_IMPLEMENTATION_MATRIX.md`](../PROV_O_IMPLEMENTATION_MATRIX.md) | [0065](0065-prov-o-provenance-boundary.md) |
| [`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) |
-| [`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) |
+| [`storybook-inventory.md`](../storybook-inventory.md) | [0118](0118-uiux-standard-guide-v3-design-overhaul.md), [0184](0184-ontology-provenance-explorer.md), [0222](0222-project-nodes-in-ontology-neighborhood.md), [0248](0248-post-scoped-source-reference-research.md) |
+| [`POSTGRESQL_CONCURRENCY_REFERENCES.md`](../doctoring/POSTGRESQL_CONCURRENCY_REFERENCES.md) | [0204](0204-analysis-run-short-transaction-delivery.md), [0213](0213-global-ask-embedding-pool-release.md) |
+| [`GLOBAL_ASK_PUBLIC_VERIFICATION_REFERENCES.md`](../doctoring/GLOBAL_ASK_PUBLIC_VERIFICATION_REFERENCES.md) | [0215](0215-global-ask-public-claim-verification.md) |
+| [`GLOBAL_ASK_KNOWLEDGE_CUTOFF_REFERENCES.md`](../doctoring/GLOBAL_ASK_KNOWLEDGE_CUTOFF_REFERENCES.md) | [0216](0216-global-ask-knowledge-cutoff.md) |
+| [`GLOBAL_ASK_QUERY_REWRITE_REFERENCES.md`](../doctoring/GLOBAL_ASK_QUERY_REWRITE_REFERENCES.md) | [0217](0217-evidence-constrained-semantic-query-rewrite.md) |
+| [`MCP_GLOBAL_ASK_REFERENCES.md`](../doctoring/MCP_GLOBAL_ASK_REFERENCES.md) | [0218](0218-current-contract-mcp-global-ask.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), [0213](0213-global-ask-embedding-pool-release.md) |
+| [`operability/mcp-concurrency-evidence.md`](../operability/mcp-concurrency-evidence.md) | [0218](0218-current-contract-mcp-global-ask.md) |
| [`operability/compose-project-consolidation.md`](../operability/compose-project-consolidation.md) | [0224](0224-canonical-compose-project.md) |
| Evidence operations Dashboard (`/`) | [0206](0206-evidence-operations-dashboard.md) |
| [`temporal-topic-context-influence-research.md`](../temporal-topic-context-influence-research.md) | [0210](0210-temporal-topic-context-influence-dashboard.md) |
@@ -32,6 +37,7 @@ decision from them.
| Expanded Voice-of-X post lookup and ontology | [0246](0246-expanded-voice-of-x-post-taxonomy.md) |
| Worker cgroup memory evidence | [0247](0247-worker-cgroup-memory-evidence.md) |
| [`WORKER_CGROUP_MEMORY_REFERENCES.md`](../doctoring/WORKER_CGROUP_MEMORY_REFERENCES.md) | [0247](0247-worker-cgroup-memory-evidence.md) |
+| Post-scoped public source research | [0248](0248-post-scoped-source-reference-research.md) |
[0011](0011-prov-o-standard-relations.md) and [0065](0065-prov-o-provenance-boundary.md) cite the dated W3C PROV-O and PROV-DM Recommendations (https://www.w3.org/TR/2013/REC-prov-o-20130430/ and https://www.w3.org/TR/2013/REC-prov-dm-20130430/).
diff --git a/docs/manuals/mcp-manual.md b/docs/manuals/mcp-manual.md
new file mode 100644
index 000000000..7292ae2ed
--- /dev/null
+++ b/docs/manuals/mcp-manual.md
@@ -0,0 +1,102 @@
+# LineageWeave MCP manual
+
+LineageWeave exposes authenticated, asynchronous Global Ask over Streamable
+HTTP. MCP and the browser use the same durable Ask jobs, access rules, status
+values, citations, related public sources, limitations, and knowledge cutoff.
+
+## Before connecting
+
+Ask the deployment operator for:
+
+- the HTTPS MCP resource URL;
+- the exact OAuth resource audience and required scopes; and
+- an access token issued for that resource to a provisioned LineageWeave
+ account with record-read permission.
+
+Do not reuse a browser client secret, provider credential, or analysis-service
+key as an MCP credential. Clients must preserve the `Mcp-Session-Id` returned
+by initialization and send it on subsequent requests.
+
+For local synthetic testing only, the optional Compose profile exposes
+`http://localhost:18001/mcp`. Start it after the operator has supplied quota
+values derived from that deployment's k6 evidence:
+
+```bash
+MCP_RATE_LIMIT_REQUESTS= \
+MCP_RATE_LIMIT_WINDOW_SECONDS= \
+docker compose --profile mcp up -d mcp
+```
+
+## Tools
+
+### `submit_global_ask`
+
+Queues a question and returns without waiting for analysis.
+
+| Argument | Required | Meaning |
+| --- | --- | --- |
+| `question` | yes | The question to answer from authorized evidence. |
+| `verify_external` | no | Compare eligible public claims with public sources. Defaults to `false`. |
+| `knowledge_cutoff` | no | ISO-8601 cutoff; evidence later than this instant is excluded. |
+
+Save the returned `ask_job_id`. Submission is not an answer and clients must
+not repeat it merely because the job remains queued or running.
+
+### `read_global_ask_job`
+
+Reads one job owned by the authenticated account.
+
+| Argument | Required | Meaning |
+| --- | --- | --- |
+| `ask_job_id` | yes | UUID returned by `submit_global_ask`. |
+
+Poll with bounded backoff until the status is terminal. A completed result can
+include cited records, event cards, images, report and alert delivery, and
+`cited_source_references`. Open only the returned URLs; absence of a title or
+URL is an unavailable source, not permission to synthesize one.
+
+## Status and recovery
+
+| Observation | Client action |
+| --- | --- |
+| queued or running | Keep the job id and poll later with bounded backoff. |
+| succeeded | Render the persisted answer and keep citations linked to their record ids. |
+| failed | Show the returned safe failure detail and allow a new submission after the operator restores the dependency. |
+| 401 | Renew the resource token, initialize a new MCP session, and retry the read. |
+| 403 | Request the required permission or affiliation; do not broaden the query locally. |
+| not found | Confirm the job id and account. Jobs are owner-scoped. |
+| rate limited | Wait for the returned `Retry-After` interval. |
+| limiter unavailable | Retry later; the service cannot safely admit the call. |
+
+Never infer a completed result from a transport timeout. Re-read the saved job
+id after connectivity returns.
+
+## Response handling
+
+- Preserve each citation's record id and event-clock metadata when rendering
+ the answer.
+- Render related public sources only from the persisted citation payload.
+- Treat an unavailable TEPP or topic/importance measurement as unavailable;
+ do not manufacture a score, weight, or journey edge.
+- Do not log bearer tokens, prompts, answers, source text, provider responses,
+ tenant identifiers, or raw MCP session ids.
+- Keep provider selection outside the MCP client. LineageWeave accepts no
+ client-selected provider model.
+
+## End-to-end capacity check
+
+Use the repository's synthetic harness with explicit observation bounds:
+
+```bash
+LINEAGEWEAVE_VUS= \
+LINEAGEWEAVE_DURATION= \
+LINEAGEWEAVE_REQUEST_TIMEOUT= \
+make load-mcp
+```
+
+The output is deployment evidence, not a universal SLO. Set production quota
+values only from a representative run whose environment, concurrency,
+duration, job-state counts, and bottleneck observations are retained outside
+the repository without source records or identifiers.
+
+See the [operations manual](operations-manual.md) for deployment and recovery.
diff --git a/docs/manuals/operations-manual.md b/docs/manuals/operations-manual.md
new file mode 100644
index 000000000..eb39a72a2
--- /dev/null
+++ b/docs/manuals/operations-manual.md
@@ -0,0 +1,150 @@
+# LineageWeave operations manual
+
+This manual is for deployment operators. It separates customer-visible
+recovery actions from service ownership, authorization, and evidence handling.
+Use synthetic data for repository tests and demonstrations; never copy runtime
+records, credentials, prompts, answers, or identifiers into git artifacts.
+
+## Service ownership
+
+| Concern | Owner and operator action |
+| --- | --- |
+| Identity and access | Keyverse in production; bundled Keycloak only for standalone/local/dev/test. Configure one authority and verify its exact audience and claims. |
+| LLM, vision, embeddings, structured output | contextual-orchestrator. Restore its provider-neutral endpoint; do not select or hardcode a provider model in LineageWeave. |
+| Temporal and psychometric measurement | TEPP and fast-mlsirm. Accept only versioned, completed, provenance-bearing results. Keep the feature unavailable otherwise. |
+| Event reconstruction and product evidence | LineageWeave. Preserve source provenance, ABAC, durable job state, and cited evidence. |
+| Ranking and reference threading | RankWeave and ThreadWeave through their published contracts; do not duplicate their algorithms locally. |
+
+## Start and verify the canonical stack
+
+Compose declares the project name `lineageweave`. Credentials remain in
+`~/.env`; do not print or copy that file into the checkout.
+
+```bash
+make up
+make ps
+make smoke
+make seed # synthetic local data only
+curl --fail http://localhost:18420/healthz
+```
+
+The default stack includes the durable worker. `/healthz` proves only process
+liveness, so also confirm that `backend-worker` is progress-healthy before
+opening the frontend. In production, set the Keyverse issuer/audience values;
+do not combine central Keyverse and the bundled realm as simultaneous
+authorization authorities.
+
+An isolated test may use `docker compose -p ...`. After the
+test, run `docker compose -p down` without `-v` unless the
+approved procedure explicitly retires its data. Remove exited test containers
+after their evidence has been retained. Do not run a second long-lived copy of
+the canonical stack under a different project name.
+
+## Configure optional integrations
+
+- Set `ORCHESTRATOR_BASE_URL` and `ORCHESTRATOR_API_KEY` for the internal
+ LineageWeave-to-orchestrator connection. Provider credentials remain in the
+ orchestrator environment.
+- Set `TEPP_TRANSPORT_URL` and its runtime credential only when the accepted
+ TEPP producer contract is deployed. A configured URL is not proof of an
+ accepted result.
+- Enable the `mcp` Compose profile only after setting exact OAuth resource,
+ Host/Origin, request-size, and k6-evidenced quota values described in the
+ [MCP manual](mcp-manual.md).
+
+## Durable asynchronous work
+
+The API enqueues Ask and content-analysis work; workers perform provider calls
+outside pooled database transactions. Keep workers enabled during backfill.
+Stopping a worker does not turn queued work into a completed analysis.
+
+For an incident:
+
+1. Preserve the job id and inspect aggregate job-state counts without printing
+ source content or account identifiers.
+2. Confirm backend-worker progress health, Valkey availability, PostgreSQL
+ connectivity, and the owner service's readiness.
+3. Restore the failed dependency before retrying. Do not convert an unavailable
+ provider response into a negative classification.
+4. For one terminal content job, run
+ `uv run python scripts/requeue_failed_post_content.py --post-id `
+ from the governed operator environment. This preserves the original source
+ digest, orchestrator session lineage, and idempotency boundary. Do not edit
+ queue rows or publish a wake-up manually.
+5. Verify the affected aggregate returns to completed and that no partial
+ result became visible.
+
+One record uses the same bounded post-scoped orchestrator session lineage for
+its related analysis work. Treat those session values as correlation metadata:
+retain them in governed storage, do not expose or log them as customer content.
+
+## Dashboard and semantic evidence recovery
+
+- **Pending count grows:** verify worker progress, queue publication, and
+ owner-service readiness; do not add more HTTP workers as a substitute for
+ consumers.
+- **Failed count grows:** inspect safe failure categories and retry through the
+ durable queue after the root cause is fixed.
+- **Voice counts are unavailable:** confirm that current source and derived
+ assertions completed. Preserve multi-membership and disagreement; do not
+ coerce a record into one category.
+- **Product mention is missing, tied, or unavailable:** repair or review the
+ governed product catalog and rerun extraction. Do not bind by display-name
+ similarity alone.
+- **Project journey is unavailable:** verify an accepted TEPP result exists for
+ the exact snapshot and cutoff. Do not substitute chronological sorting.
+- **Related public source is absent:** verify publication eligibility and the
+ governed public-research service. Do not invent or manually insert a title,
+ URL, or excerpt.
+
+## Database checks
+
+Observe PostgreSQL before changing it. Record only aggregates:
+
+- active and waiting sessions by wait-event class;
+- transaction age and lock blockers;
+- queue-state totals and oldest queued age;
+- WAL growth/checkpoint statistics; and
+- query plans through the repository's bounded `EXPLAIN` procedure.
+
+Do not cancel a migration or disable WAL durability solely because it is slow.
+Use `scripts/explain_post_content_backfill.py` for the bounded backfill plan;
+it rolls back and reports aggregate timing, buffers, temporary blocks, WAL,
+node kinds, and relation scans without exposing rows. Tune only from measured
+evidence, then capture the root-cause fix in Compose/configuration and tests.
+
+## Load and responsiveness verification
+
+With the canonical synthetic stack healthy, declare the environment-specific
+concurrency, duration, and timeout:
+
+```bash
+LINEAGEWEAVE_VUS= \
+LINEAGEWEAVE_DURATION= \
+LINEAGEWEAVE_REQUEST_TIMEOUT= \
+make load-http
+
+LINEAGEWEAVE_VUS= \
+LINEAGEWEAVE_DURATION= \
+LINEAGEWEAVE_REQUEST_TIMEOUT= \
+make load-mcp
+```
+
+Retain aggregate request rates, latency distributions, functional-check
+failures, Ask job-state counts, CPU, memory, database waits, and worker backlog
+outside git. These observations do not establish a production SLO until the
+named deployment and representative workload approve one.
+
+## Shutdown and rollback
+
+```bash
+make down
+```
+
+Do not remove named volumes during ordinary shutdown. Apply migration rollback
+files only under the migration-specific reviewed recovery plan; application
+code must not compensate for a missing table. After recovery, repeat OIDC,
+authenticated API, worker-progress, Dashboard, Ask, and relevant k6 checks at
+the exact deployed revision.
+
+Customer actions are documented separately in the [user guide](user-guide.md).
diff --git a/docs/manuals/user-guide.md b/docs/manuals/user-guide.md
new file mode 100644
index 000000000..a3c693b38
--- /dev/null
+++ b/docs/manuals/user-guide.md
@@ -0,0 +1,107 @@
+# LineageWeave user guide
+
+This guide describes the actions available in the authenticated workspace.
+What you can see depends on your role and organizational access. If a count,
+record, or citation is absent, ask an administrator to confirm your access
+before drawing a conclusion from the absence.
+
+## Start with the Dashboard
+
+After signing in, use **Dashboard** to review the selected period.
+
+1. Set the inclusive start and end dates, then choose **Apply period**.
+2. Compare the record count with the Event count. One record can contain more
+ than one Event, so the two totals answer different questions.
+3. Open a case card or its evidence action to read the cited record.
+4. Review **pending analysis** and **failed analysis** separately. Ask an
+ administrator to retry failed work before treating a missing case as a
+ confirmed zero.
+
+Use the claim cards to trace the received claim, originating order,
+specification change, sales pool, and cause-confirmation evidence. Use the
+rebid and handover cards to review discussions, participants, your owner, and
+the decisions that followed. The external-information destination applies the
+same period and access rules while showing procurement and market evidence;
+there is no second board to reconcile.
+
+Project sections show the observed records and, when accepted journey evidence
+exists, the supported start, predecessor, branch, and transition. Open each
+milestone before acting: a lead, public notice, customer request, negotiated
+bid, discussion, or earlier project may precede the first order shown on
+screen.
+
+## Review Voice evidence
+
+The Dashboard counts all supported Voice memberships over the records you can
+see. A record may support several categories, so category totals can overlap.
+
+| Code | Meaning |
+| --- | --- |
+| VOC | Voice of Customer |
+| VOCC | Voice of Customer's Customer |
+| VOCO | Voice of Competitor |
+| VOM | Voice of Market |
+| VOP | Voice of Partner |
+| VOS | Voice of Supplier |
+| VOE | Voice of Employee |
+| VOB | Voice of Business |
+| VOR | Voice of Regulator |
+| VOI | Voice of Investor |
+| VOSO | Voice of Society |
+| VOPS | Voice of Process |
+
+Review multi-category records, source-versus-derived disagreements, and
+records without supporting evidence before using a category total. A record's
+Voice category does not by itself establish how every organization mentioned
+in that record relates to your organization.
+
+## Ask with evidence
+
+Open **Ask Agent**, enter a specific question, and optionally choose a
+knowledge cutoff. Submission returns immediately while the answer is prepared.
+Keep the workspace open or return later to read the durable job result.
+
+When the answer appears:
+
+1. Select a numbered citation to focus its event card.
+2. Open the cited record to read the complete authorized source.
+3. Open **Related public sources** to compare the persisted public original
+ and excerpt. A missing link means no eligible related source is available;
+ the product does not create a title or URL.
+4. Read limitations and the suggested next action before forwarding a report
+ or acting on an alert.
+
+Enable public verification only when the question contains a claim that needs
+comparison with public information. If verification is unavailable, ask an
+administrator to enable the governed public-research service and retry. A
+knowledge cutoff excludes later evidence rather than substituting today's
+record text.
+
+## Inspect a record
+
+Open a record from the Dashboard, Board, search, calendar, or an Ask citation.
+Use its evidence sections to:
+
+- compare the source body with derived paragraphs and image regions;
+- review product mentions at group, model, variant, or trade-item level;
+- ask the product-catalog steward to review a mention marked tied, missing, or
+ unavailable before using its relationship;
+- inspect similar prior issues and their cited actions; and
+- follow Event Lineage without treating ontology neighbors as parent records.
+
+Do not use an unavailable product, topic, journey, or measurement result as a
+negative finding. Open the cited evidence or request reprocessing first.
+
+## When a result is unavailable
+
+- **Analysis pending:** wait for completion, then refresh.
+- **Analysis failed:** ask an administrator to retry the failed job.
+- **Ask unavailable:** ask an administrator to restore the analysis service,
+ then submit again.
+- **No authorized evidence:** narrow the question or ask an administrator to
+ confirm your organizational access.
+- **Measurement unavailable:** continue with cited descriptive evidence; do
+ not interpret the missing measurement as zero.
+
+For setup and incident recovery, use the [operations manual](operations-manual.md).
+For an MCP client, use the [MCP manual](mcp-manual.md).
diff --git a/docs/product-requirements.md b/docs/product-requirements.md
index a5cb19838..a2f3a7e34 100644
--- a/docs/product-requirements.md
+++ b/docs/product-requirements.md
@@ -157,12 +157,16 @@ the retained revision and full/partial grounding state.
affiliation scope, Host, Origin, and bounded request body before a tool runs.
- Consume one distributed quota unit only for an admitted authenticated tool
call; preflight and rejected admission consume none.
+- Preserve each cited post's determinate persisted related-public-source links
+ in the shared answer, while rechecking publication eligibility and the
+ requested knowledge cutoff; never invent a missing title or URL.
- Require deployment-supplied, load-evidence-backed quota parameters and fail
closed when shared Valkey cannot decide.
Acceptance: MCP and REST produce the same scope snapshot, verification opt-in,
-knowledge cutoff, status, citations, and limitations; cross-account reads are
-404-equivalent; and exhaustion returns the bounded actual retry interval.
+knowledge cutoff, status, citations, related public sources, and limitations;
+cross-account reads are 404-equivalent; and exhaustion returns the bounded
+actual retry interval.
### PRD-FR-6 — Measurement boundary
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index 62007ddee..cb097aace 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -89,9 +89,32 @@ Security/operability: every aggregation applies `post_read` plus row-level
corporate-entity visibility before counting; source-body digests invalidate
stale inference; provider errors persist no positive/negative result; PII
remains authorized at the UI boundary and is excluded from telemetry. The
-tables use composite keys and bounded kind-first indexes; production hot-path
-acceptance still requires `EXPLAIN (ANALYZE, BUFFERS)` on an anonymized runtime
-snapshot.
+tables use composite keys and bounded kind-first indexes. Production hot-path
+acceptance uses `scripts/explain_post_content_backfill.py` on an anonymized
+runtime snapshot; the exact candidate SQL runs in a rolled-back transaction
+and emits aggregate plan/buffer metrics only. A deployment-specific
+capacity/SLO remains separate from this query-shape evidence.
+
+On an isolated exact-schema synthetic snapshot based on #716 `c01de078`
+(20,000 eligible posts and jobs, 9,927 ontology-backed project mentions, and
+4,951 current operations analyses), a consecutive rolled-back comparison
+returned the same 200-row priority page in 2,056.629 ms before and 1,327.868 ms
+after the change. Root shared-hit blocks fell from 275,642 to 100,514; both
+plans recorded zero shared reads and zero temporary reads/writes. The former
+plan made 20,000 correlated project probes and 9,927 correlated
+operations-analysis probes, while the semantics-equivalent two-tier query
+removes the corpus-wide priority `CASE` and its extra correlated priority
+subplans. The reproducible summary includes both relation plan-node counts and
+actual scan-loop totals so a single nested-loop node cannot be mislabeled as a
+single execution.
+The plan remains `Limit -> LockRows -> Sort`; `SKIP LOCKED` and the transaction
+boundary therefore remain intact, and the remaining tier runs only when the
+priority tier cannot fill the requested page. A separate remaining-tier
+observation returned 200 rows in 12,649.654 ms with 241,317 root shared-hit
+blocks and no reads or temporary spill; it is retained as the next
+distribution-specific optimization target, not hidden by the priority-path
+improvement. These observations establish query shape only, not a deployment
+capacity or latency SLO.
### Historical UI audit evidence
@@ -475,6 +498,7 @@ this file per §3.5 of the prior snapshot).
| Gap | Current evidence | Acceptance requirement |
| --- | --- | --- |
+| Customer and operator guidance | Current-stack user, MCP, and operations manuals now cover Dashboard evidence, all twelve Voice categories, product catalog review, durable Ask jobs and related public originals, canonical Compose/OIDC/session handling, worker recovery, k6 observation, and unavailable TEPP measurement. Contract tests bind the manuals to current tools, API type inventory, commands, and cross-links | Keep the manuals in the release link-check/test gate and repeat the documented authenticated synthetic recovery and k6 procedures at the protected release SHA; update guidance whenever a public tool, status, or recovery owner changes |
| Protected release | 11 open PRs at the 07:26 KST snapshot; the exact-head inventory in section 1 records their current evidence boundaries | Terminal exact-head checks, no unresolved threads, independent exact-head approvals, protected squash-merge SHA |
| Evidence-grounded operations workspace | Protected-main #614 delivers governed semantic Ask, live Similar VOC, disjoint pending/failed analysis metrics, full Storybook state inventory, and current desktop/mobile screenshot evidence. The current Dashboard stack adds a candidate `post_admin`-gated, 1--200-row durable semantic-backfill enqueue path that reuses PostgreSQL recovery, includes successful records completed before operations extraction, and never runs providers in HTTP; authorized-corpus acceptance remains unavailable | Land the candidate, then perform authenticated authorized-corpus acceptance with aggregate queued/published/recovery and derived-evidence counts while retaining fail-closed no-match behavior |
| Shared frontend gate | The ADR 0109 login repair is on protected `main`; eight older branches carried the defect and received the same verified repair this loop (#521–#560) | Keep every future branch cut from post-repair bases; re-verify with frontend lint/test/build before push |
@@ -485,7 +509,7 @@ this file per §3.5 of the prior snapshot).
| Semantic source rendering | ADR 0223 and migration 0221 give new paragraph, list, table, MathML formula, and caller-parsed conversation-turn units explicit persisted kinds without rewriting historical rows; image regions remain ordered normalized children under ADR 0091. This branch is candidate evidence, not protected-main delivery | Land the exact-head candidate, then prove an authorized semantic-only query retrieves each persisted unit kind and gather authenticated browser evidence that nesting, continuation alignment, formula units, and image regions retain source order |
| Event and project semantics | Multi-project mentions, project-bound actions, 5W1H, requester/processor, and semantic relations exist in ADR 0036/0052/0100/0111/0129 and active stacks | Aggregate authenticated evidence must show distinct projects and events, explicit requester/processor and real R&R, normalized relative time, and product/entity relations without promoting attendance or co-occurrence |
| Product semantic identity | ADR 0228 and migration 0228 define normalized product group/model/variant/trade-item identities, scoped GTIN/MPN keys, exact-span provenance, fail-closed unique/tie/missing/unavailable resolution, and foreign-key relations to existing project and operations facts. The worker candidate reuses the durable post-content queue and skips an unchanged authorized input digest. No authorized-corpus product counts or rendered acceptance evidence are recorded | Land the stack, add authorization-filtered Post/Dashboard relationship reads and SHACL projection, then verify aggregate-only backfill outcomes plus desktop/mobile Storybook screenshots without exposing identifying runtime rows |
-| Voice semantic taxonomy | ADR/migration 0230 preserve the five-value source post scheme separately from the six-value post-scoped organization relationship scheme, retain source/derived disagreement and multi-membership, and provide authorized overlap-aware aggregate filters. Candidate Storybook evidence is synthetic; no private-corpus derived assertion count is recorded | Land the stack, run bounded orchestrator backfill, and verify aggregate-only source/derived/disagreement/unavailable counts at one declared cutoff without exposing record identities |
+| Voice semantic taxonomy | ADRs 0244/0246 and migrations 0230/0235 preserve the twelve-value source-post scheme separately from the six-value post-scoped organization relationship scheme, retain source/derived disagreement and multi-membership, and provide authorized overlap-aware aggregate filters. The Dashboard API returns every persisted category dynamically; PR #736 exact `2f5d9ee8` still typed and labeled only `voc`/`vocc`/`voco`/`vom`/`vop`, so `vos`/`voe`/`vob`/`vor`/`voi`/`voso`/`vops` could not render. This stacked repair covers all twelve with locale and component tests. Candidate Storybook evidence remains synthetic; no private-corpus derived assertion count is recorded | Land the stack, run bounded orchestrator backfill, and verify aggregate-only source/derived/disagreement/unavailable counts at one declared cutoff without exposing record identities |
| Knowledge Graph readability | The black evidence-node root cause is an undefined-token fallback; the design-token repair and long-label/evidence-table coverage remain only on closed, unmerged #490, not protected `main` | Recreate the token repair on a current base and deliver it through protected `main`, then verify light/dark contrast, keyboard graph navigation, full labels, and evidence tables in the authenticated rendered surface |
| Source-code lookup UX | Source state/detail codes remain evidence-bearing machine values and current detail presentation is dense | Catalog-backed display labels with raw-code provenance, compact 5W1H/source-detail hierarchy, keyboard access, and no unsupported customer/project binding |
| Calendar / Naruon | #355 delivered the projection contract; v2.17.0 wires operator consumption without forwarding the end-user token. Naruon producer, provider/consumer fixtures, and protected merge remain open (#336) | Verify observed events against the published schema without invented events; keep commitments available when the channel is unwired |
@@ -516,7 +540,7 @@ give this delivery matrix:
| Closed-branch decision | Current-main classification | Smallest remaining delivery |
| --- | --- | --- |
-| ADR 0133 source-reference research | Partial foundation: protected `main` has the self-hosted SearXNG relation-verification client and fail-closed configuration, but it verifies an already extracted relation. It has no source-unit/image-region lead, cited-resource retrieval, claim judgment, or normalized research citation workflow | One post-scoped lead-to-citation slice that reuses the self-hosted SearXNG search boundary, adds public-target SSRF/redirect rejection for result retrieval, and judges through contextual-orchestrator with explicit unavailable outcomes |
+| ADR 0133 source-reference research | PR #714 ADR 0248 is stacked on the current Dashboard/Ask branch and adds the remaining lead-to-citation slice: public-only source-unit/image-region leads, SearXNG search, public-target SSRF/redirect-rejected retrieval, orchestrator `mode=verify`, 3NF `source_research_citation`, and the same persisted related-document links in REST/UI/report/MCP Ask delivery. It is open-PR evidence until protected merge. Distinct from ADR 0215, which still never fetches result URLs | Land ADR 0248 through independent exact-head approval; keep private posts fail-closed, recheck publication/cutoff eligibility at Ask delivery, and do not mix Global Ask snippet verification into this table |
| ADR 0134 token-backed exception messages | Partial: sanitized next-action failures exist, but no shared token-backed exception component or complete Storybook error inventory exists | Migrate one existing unavailable flow to one shared accessible alert and verify its success, unavailable, and retry states |
| ADR 0135 kind/status-exact analysis actions | Partial: protected `main` has kind-aware start/retry controls plus normative analysis-run, TEPP, cutoff-body, and channel-evidence contracts; it does not contain the closed branch's unified guidance component or its full kind × status interaction inventory | Test the current run-kind/status matrix first, then add only a proven missing state/control pair rather than copying the closed-branch function |
| ADR 0136 per-post Ask history | Partial: `post_chat_result` / `post_chat_citation`, the authorized post Chat API, and its linear exchange history are on protected `main`. Account-and-post-scoped sessions, ordered turns, list/select/new controls, and batched citation reauthorization are not | Define the 3NF account/post session boundary, bounded batch reauthorization, and one authorized list/load/write path before adding the conversation picker |
diff --git a/docs/screenshots/source-research-desktop.png b/docs/screenshots/source-research-desktop.png
new file mode 100644
index 000000000..0629702d7
Binary files /dev/null and b/docs/screenshots/source-research-desktop.png differ
diff --git a/docs/screenshots/source-research-mobile.png b/docs/screenshots/source-research-mobile.png
new file mode 100644
index 000000000..4fee91ffa
Binary files /dev/null and b/docs/screenshots/source-research-mobile.png differ
diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md
index deb96ae77..39182effd 100644
--- a/docs/storybook-inventory.md
+++ b/docs/storybook-inventory.md
@@ -7,7 +7,7 @@ operator-facing control you can click before changing product CSS.
|---|---|---|
| `Customer Master/Linking guidance` | Before linking a customer, compare the source identifier with related posts and organization evidence. `Desktop` and `Narrow` keep the same next action without exposing implementation terms. | `workspace-destination-intro`, `CustomerLinkingGuidance` |
| `Workspace/OperationsDashboard` | Compare Event and post counts, inspect external-information coverage, then open the cited source behind a claim, handover, repeat issue, or topic-context influence. `TopicInfluenceAccepted` preserves exact ties, multiple membership, time states, uncertainty, and source actions; `EvidenceReady` shows the producer-contract unavailable state. `NarrowViewport`, `ExternalInformationEmpty`, `RequiredFactMissing`, `AnalysisPendingAndMissingEvidence`, `AnalysisFailed`, `ConcurrentLoading`, `LoadError`, and `VoiceSummaryLoadError` cover mobile, scoped-empty, explicit evidence-absence, analysis-pending, retryable failure, one accessible announcement for parallel loading, whole-dashboard transport failure, and independently retryable voice-summary failure. | `--color-dashboard-*`, `OperationsDashboard`, `TopicContextInfluence` |
-| `Ask Agent/AnswerEvidenceTimeline` | Select an answer citation to focus its event card, select the card to return to the answer, then open its evidence or source post. `MissingObservedTime` keeps an absent event clock explicit and `NarrowViewport` verifies the single-column interaction. | `--color-accent-*`, `--radius-panel`, `--size-control-min`, `AskAnswerTimeline` |
+| `Ask Agent/AnswerEvidenceTimeline` | Select an answer citation to focus its event card, select the card to return to the answer, then open its evidence, source post, or persisted related public source. `MissingObservedTime` keeps an absent event clock explicit and `NarrowViewport` verifies the single-column interaction. | `--color-accent-*`, `--radius-panel`, `--size-control-min`, `AskAnswerTimeline` |
| `Ask Agent/Knowledge cutoff` | Ask with public verification enabled, then follow the displayed next action when no claim is eligible. `NoEligiblePublicClaim` and `NoEligiblePublicClaimNarrow` render the full result panel at desktop and mobile widths. | `ask-delivery`, `AskAgentPanel` |
| `Post/SimilarVocPanel` | Compare ontology/semantic similar VOC and prior action evidence, then open the source; unavailable states show no fabricated TEPP theta or weight. | `SimilarVocPanel.css`, `SimilarVocPanel` |
| `Evidence/CitationChip` | Click a cited title to open that source post. | `--color-chip-border`, `--radius-chip`, `CitationChip` |
@@ -19,14 +19,25 @@ operator-facing control you can click before changing product CSS.
| `Lineage/LineageDag` | Open a reconstructed connection to read its inferred channel scores and Allen interval relation, or open the current branch node; compare empty, single-branch, grouped/forked, mobile-scroll, ungrouped, and long-title states before changing graph CSS. On narrow viewports, swipe the named viewport or focus it and use arrow keys to inspect the full lineage. | `--color-accent-background`, `--radius-control`, `--surface`, `--border`, `--color-focus-border`, `--size-control-min`, `LineageDag` |
| `Chrome/PopupCloseButton` | Close the evidence panel or post popup. | `--space-close-inset`, `--font-size-close`, `PopupCloseButton` |
| `Workspace/WorkspaceCalendar` | Read observed Naruon events, or open a commitment to land on that post. Fail-closed copy stays `이 범위의 일정을 아직 받을 수 없습니다`. | `--color-chip-border`, `WorkspaceCalendar`, `EvidenceStatusMark` |
-| `Evidence/OntologyExplorer` | Distinguish Post, Person, Organization, and Team by shape and text, use the token-backed surface as a secondary cue, then open the exact-value table or cited evidence. Compare desktop, narrow, drawer, empty, truncated, denied, stale, and rejected states. | `--ontology-node-*-fill`, `OntologyExplorer` |
+| `Ask Agent/Public claim verification` | Compare supported, refuted, and not-enough-information states; open only the external evidence link, then review the separate internal citation before changing governed graph state. | `--space-panel-block`, `--space-control-gap`, `--color-border`, `--size-control-min`, `PublicClaimVerification` |
+| `Post/Source research` | Open the cited public resource, then compare it with the highlighted passage or image detail from this post. `SupportedAndUnavailable` and `PrivatePost` cover cited retrieval, fail-closed private egress, and the research action. | `--space-panel-block`, `--space-control-gap`, `--color-border`, `--size-control-min`, `SourceResearchPanel` |
+| `Ask Agent/Knowledge cutoff` | Exercise partial historical grounding, retained-revision provenance, later-live-change disclosure, and the narrow viewport before relying on a historical answer. | Native `datetime-local`, `--space-panel-block`, `--space-control-gap`, `--color-border`, `--size-control-min` |
| `Post/ProductEvidenceList` | Open the cited product span. If the identity is unresolved, review the product catalog before using the relationship. Compare catalog-linked and catalog-review-required states. | `--surface`, `--border`, `ProductEvidenceList` |
-| `Dashboard/VoiceTaxonomySummary` | Compare source and semantic classifications, note overlapping memberships, then review disagreements and records waiting for evidence. | `--surface`, `--border`, `VoiceTaxonomySummary` |
+| `Dashboard/VoiceTaxonomySummary` | Compare source and semantic classifications, note overlapping memberships, then review disagreements and records waiting for evidence; `KoreanMobile` verifies locale-complete customer copy in the narrow viewport. | `--surface`, `--border`, `VoiceTaxonomySummary` |
+| `Navigation/WorkspaceNav` | Reach every workspace destination and the language action; `MobileAllDestinations` keeps all actions visible without horizontal clipping. | `--gnb-height`, `--size-control-min`, `WorkspaceNav` |
Repeated web objects must use `frontend/src/styles/tokens.css` and a module
under `frontend/src/components/`. Do not add a second Node package manager;
Storybook is installed with the existing pnpm pin on Node 24.
+The `Post/Source research` candidate was rendered with synthetic evidence at
+1440×1000 and an iPhone 14 viewport. The governed captures are
+[`source-research-desktop.png`](screenshots/source-research-desktop.png) and
+[`source-research-mobile.png`](screenshots/source-research-mobile.png). Desktop
+and narrow inspection confirmed readable
+wrapping without horizontal overflow, a token-sized action control, visible
+link semantics, and customer-action copy without storage or provider names.
+
## References — APA 7th
Design Tokens Community Group. (2025). *Design Tokens Format Module 2025.10*
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
index 978eb2616..ec3b9863b 100644
--- a/frontend/Dockerfile
+++ b/frontend/Dockerfile
@@ -15,6 +15,12 @@ ENV VITE_KEYVERSE_ISSUER=${VITE_KEYVERSE_ISSUER} \
RUN pnpm run build
FROM nginx:1.27-alpine@sha256:65645c7bb6a0661892a8b03b89d0743208a18dd2f3f17a54ef4b76fb8e2f2a10
+ARG LINEAGEWEAVE_SOURCE_REVISION=unknown
+ARG VITE_KEYVERSE_ISSUER
+ARG VITE_BACKEND_BASE_URL
+LABEL org.opencontainers.image.revision=${LINEAGEWEAVE_SOURCE_REVISION} \
+ io.contextualwisdomlab.lineageweave.oidc-issuer=${VITE_KEYVERSE_ISSUER} \
+ io.contextualwisdomlab.lineageweave.backend-url=${VITE_BACKEND_BASE_URL}
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Official nginx binds :80 as root and writes its pid file to /run/nginx.pid
diff --git a/frontend/e2e/runtime-operations-dashboard.spec.ts b/frontend/e2e/runtime-operations-dashboard.spec.ts
new file mode 100644
index 000000000..e83570376
--- /dev/null
+++ b/frontend/e2e/runtime-operations-dashboard.spec.ts
@@ -0,0 +1,43 @@
+import { expect, test } from "@playwright/test";
+
+test("renders the authenticated operations Dashboard with grounded cases", async ({
+ page,
+}, testInfo) => {
+ const accessToken = process.env.LINEAGEWEAVE_ACCESS_TOKEN;
+ const issuer = process.env.LINEAGEWEAVE_OIDC_ISSUER;
+ const clientId = process.env.LINEAGEWEAVE_OIDC_CLIENT_ID;
+ const screenshotPath =
+ testInfo.project.name === "chromium-mobile"
+ ? process.env.SCREENSHOT_MOBILE_PATH
+ : process.env.SCREENSHOT_DESKTOP_PATH;
+ const requireGroundedCase = process.env.REQUIRE_GROUNDED_CASE !== "false";
+ if (!accessToken || !issuer || !clientId || !screenshotPath) {
+ throw new Error("runtime OIDC and screenshot environment is required");
+ }
+
+ await page.addInitScript(
+ ({ token, storageKey }) => {
+ const storage = (
+ globalThis as unknown as { localStorage: { setItem(key: string, value: string): void } }
+ ).localStorage;
+ storage.setItem(
+ storageKey,
+ JSON.stringify({
+ access_token: token,
+ token_type: "Bearer",
+ expires_at: Math.floor(Date.now() / 1000) + 300,
+ profile: { sub: "runtime-acceptance" },
+ scope: "openid",
+ }),
+ );
+ },
+ { token: accessToken, storageKey: `oidc.user:${issuer}:${clientId}` },
+ );
+
+ await page.goto("/");
+ await expect(page.getByRole("heading", { name: "운영 근거 Dashboard" })).toBeVisible();
+ if (requireGroundedCase) {
+ await expect(page.locator(".dashboard-case-card").first()).toBeVisible();
+ }
+ await page.screenshot({ path: screenshotPath, fullPage: true });
+});
diff --git a/frontend/package.json b/frontend/package.json
index 5acf284d7..bf3371abe 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
- "version": "2.17.0",
+ "version": "2.19.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts
index a3fb286f5..d98737b72 100644
--- a/frontend/playwright.config.ts
+++ b/frontend/playwright.config.ts
@@ -20,8 +20,12 @@ export default defineConfig({
},
projects: [
{
- name: "chromium",
+ name: "chromium-desktop",
use: { ...devices["Desktop Chrome"] },
},
+ {
+ name: "chromium-mobile",
+ use: { ...devices["Pixel 7"] },
+ },
],
});
diff --git a/frontend/src/App.css b/frontend/src/App.css
index 11409d336..50a5d782d 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -1344,16 +1344,25 @@
/* Phone Breakpoint (<768px) */
.workspace-gnb {
- overflow-x: auto;
- overscroll-behavior-inline: contain;
- gap: 0.75rem;
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ height: auto;
+ gap: 0;
padding: 0 1rem;
- scrollbar-width: thin;
}
- .workspace-gnb-item,
+ .workspace-gnb-item {
+ min-height: var(--size-control-min);
+ justify-content: center;
+ padding: 0 0.25rem;
+ text-align: center;
+ }
+
.workspace-gnb-tools {
- flex: 0 0 auto;
+ grid-column: 1 / -1;
+ min-height: var(--size-control-min);
+ margin-left: 0;
+ justify-content: flex-end;
}
.mobile-drawer-trigger {
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index a68b16078..692c464c4 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -109,6 +109,8 @@ describe("App, authenticated", () => {
pluralAffiliations?: boolean;
deferMe?: boolean;
deferPostOne?: boolean;
+ deferResearch?: boolean;
+ postTwoPrivate?: boolean;
meFailed?: boolean;
postBody?: string;
manyCustomerHints?: number;
@@ -121,7 +123,11 @@ describe("App, authenticated", () => {
askImageCitation?: boolean;
askDelivery?: boolean;
lineageIsolationReason?: "comparison_candidates_available" | "no_comparison_group";
- }): ReturnType & { releaseMe: () => void; releasePostOne: () => void } {
+ }): ReturnType & {
+ releaseMe: () => void;
+ releasePostOne: () => void;
+ releaseResearch: () => void;
+ } {
const statusLabel: Record = {
open: "Open",
in_progress: "In progress",
@@ -162,6 +168,13 @@ describe("App, authenticated", () => {
})
: Promise.resolve();
+ let releaseResearch = () => {};
+ const researchReady = options?.deferResearch
+ ? new Promise((resolve) => {
+ releaseResearch = resolve;
+ })
+ : Promise.resolve();
+
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
const method = init?.method ?? "GET";
@@ -1236,7 +1249,7 @@ describe("App, authenticated", () => {
post_title: "Linked post",
post_body: "The evidence panel should show exactly this text.",
voc_type_code: "voc",
- visibility_code: "public",
+ visibility_code: options?.postTwoPrivate ? "private" : "public",
created_at: "2026-01-02T00:00:00Z",
}),
);
@@ -1656,6 +1669,33 @@ describe("App, authenticated", () => {
}
return Promise.resolve(jsonResponse({ verified: [] }));
}
+ if (url.endsWith("/api/posts/post-1/research-citations") && method === "POST") {
+ return researchReady.then(() =>
+ jsonResponse({
+ post_id: "post-1",
+ visibility_code: "public",
+ citations: [
+ {
+ lead_kind_code: "research_lead_semantic_unit",
+ lead_source_unit_id: "unit-1",
+ lead_image_region_id: null,
+ lead_excerpt_text: "Demo Corp delayed Apollo.",
+ search_query_text: "Demo Corp delayed Apollo.",
+ evidence_url: "https://example.com/apollo",
+ evidence_title_text: "Public Apollo evidence",
+ evidence_excerpt_text: "The published notice describes the delay.",
+ judgment_code: "research_supported",
+ rationale_text: "The retrieved page matches this source unit.",
+ next_action_text:
+ "Open the cited public resource, then compare it with the highlighted passage or image detail from this post.",
+ },
+ ],
+ }),
+ );
+ }
+ if (url.endsWith("/api/posts/post-1/research-citations")) {
+ return Promise.resolve(jsonResponse({ post_id: "post-1", visibility_code: "public", citations: [] }));
+ }
if (url.endsWith("/api/posts/post-1/lineage")) {
return Promise.resolve(
jsonResponse({
@@ -1954,7 +1994,7 @@ describe("App, authenticated", () => {
return Promise.reject(new Error(`unexpected fetch: ${method} ${url}`));
});
vi.stubGlobal("fetch", fetchMock);
- return Object.assign(fetchMock, { releaseMe, releasePostOne });
+ return Object.assign(fetchMock, { releaseMe, releasePostOne, releaseResearch });
}
it("renders safe Ask Agent evidence under each cited post", async () => {
@@ -3019,6 +3059,50 @@ describe("App, authenticated", () => {
);
});
+ it("lets post_admin research public sources for a source unit", async () => {
+ const fetchMock = stubBackend({ admin: true });
+ render();
+
+ await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" }));
+ await userEvent.click(await screen.findByRole("button", { name: /research public sources/i }));
+
+ await waitFor(() =>
+ expect(fetchMock).toHaveBeenCalledWith(
+ expect.stringContaining("/api/posts/post-1/research-citations"),
+ expect.objectContaining({ method: "POST" }),
+ ),
+ );
+ expect(
+ await screen.findByRole("link", { name: "Public Apollo evidence" }),
+ ).toHaveAttribute("href", "https://example.com/apollo");
+ });
+
+ it("does not apply a completed research request after switching posts", async () => {
+ const fetchMock = stubBackend({ admin: true, deferResearch: true });
+ render();
+
+ await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" }));
+ await userEvent.click(await screen.findByRole("button", { name: /research public sources/i }));
+ await userEvent.click(screen.getAllByLabelText("Open post: Linked post")[0]);
+ await screen.findByText("The evidence panel should show exactly this text.");
+ fetchMock.releaseResearch();
+
+ await waitFor(() =>
+ expect(screen.queryByRole("link", { name: "Public Apollo evidence" })).not.toBeInTheDocument(),
+ );
+ });
+
+ it("does not offer public-source research for a private post", async () => {
+ stubBackend({ admin: true, postTwoPrivate: true });
+ render();
+
+ await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" }));
+ await userEvent.click(screen.getAllByLabelText("Open post: Linked post")[0]);
+ await screen.findByText("The evidence panel should show exactly this text.");
+
+ expect(screen.queryByRole("button", { name: /research public sources/i })).not.toBeInTheDocument();
+ });
+
it("lets post_admin extract Keymen from the popup", async () => {
const fetchMock = stubBackend({ admin: true });
render();
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index f2903d662..5bd340e28 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -51,6 +51,8 @@ import {
setPreferredLocale,
updateTicketStatus,
verifyPostRelations,
+ fetchPostResearchCitations,
+ researchPostSources,
type ActivityEvent,
type AskAgentResponse,
type AffiliateNode,
@@ -85,6 +87,7 @@ import {
type RelatedNodeType,
type VocEvidence,
type SimilarVocItem,
+ type SourceResearchCitation,
fetchTenantConfig,
} from "./api";
import { CitationChip } from "./components/CitationChip";
@@ -98,6 +101,7 @@ import { AskAnswerTimeline } from "./components/AskAnswerTimeline";
import { PublicClaimVerification } from "./components/PublicClaimVerification";
import { PopupCloseButton } from "./components/PopupCloseButton";
import { SimilarVocPanel } from "./components/SimilarVocPanel";
+import { SourceResearchPanel } from "./components/SourceResearchPanel";
import { WorkspaceNav, type WorkspaceDestination } from "./components/WorkspaceNav";
import { OperationsDashboard } from "./components/OperationsDashboard";
import { initialWorkspaceDestination } from "./gnbChrome";
@@ -1726,6 +1730,7 @@ const ACTIVITY_TYPE_LABELS: Record = {
commitment_derived: "Commitment derived",
keymen_extracted: "Keymen extracted",
relations_verified: "Relations verified",
+ source_research_checked: "Public sources reviewed",
post_evaluated: "Post evaluated",
chat_answered: "Chat answered",
};
@@ -1823,9 +1828,15 @@ function PostDetailPopup({
const [similarVocError, setSimilarVocError] = useState(null);
const [similarVocNextOffset, setSimilarVocNextOffset] = useState(null);
const [similarVocLoadingMore, setSimilarVocLoadingMore] = useState(false);
+ const [researchCitations, setResearchCitations] = useState([]);
+ const [researchUnavailable, setResearchUnavailable] = useState(null);
+ const [researching, setResearching] = useState(false);
+ const [researchError, setResearchError] = useState(null);
const similarVocLoadingMoreRef = useRef(false);
const similarVocScopeRef = useRef({ postId });
if (similarVocScopeRef.current.postId !== postId) similarVocScopeRef.current = { postId };
+ const researchScopeRef = useRef({ postId });
+ if (researchScopeRef.current.postId !== postId) researchScopeRef.current = { postId };
const [evaluation, setEvaluation] = useState(null);
const [focusPerson, setFocusPerson] = useState<{ personId: string; personName: string } | null>(null);
const [focusEntity, setFocusEntity] = useState<{ entityId: string; entityName: string } | null>(null);
@@ -1929,6 +1940,10 @@ function PostDetailPopup({
setSimilarVocNextOffset(null);
setSimilarVocLoadingMore(false);
similarVocLoadingMoreRef.current = false;
+ setResearchCitations([]);
+ setResearchUnavailable(null);
+ setResearching(false);
+ setResearchError(null);
setEvaluation(null);
setFocusPerson(null);
setFocusEntity(null);
@@ -1985,6 +2000,17 @@ function PostDetailPopup({
.then((r) => setAffiliateTrees(r.trees))
.catch(() => setAffiliateTrees([]));
fetchPostVocEvidence(accessToken, postId).then(setVocEvidence).catch(() => setVocEvidence(null));
+ fetchPostResearchCitations(accessToken, postId)
+ .then((result) => {
+ if (disposed) return;
+ setResearchCitations(result.citations);
+ setResearchUnavailable(result.unavailable_reason ?? null);
+ })
+ .catch(() => {
+ if (disposed) return;
+ setResearchCitations([]);
+ setResearchUnavailable(null);
+ });
fetchSimilarVoc(accessToken, postId)
.then((result) => {
if (disposed) return;
@@ -2532,6 +2558,33 @@ function PostDetailPopup({
}}
/>
+ {
+ const requestScope = researchScopeRef.current;
+ setResearching(true);
+ setResearchError(null);
+ researchPostSources(accessToken, postId)
+ .then((result) => {
+ if (researchScopeRef.current !== requestScope) return;
+ setResearchCitations(result.citations);
+ setResearchUnavailable(result.unavailable_reason ?? null);
+ })
+ .catch((err) => {
+ if (researchScopeRef.current === requestScope) {
+ setResearchError(searchUnavailableMessage(err));
+ }
+ })
+ .finally(() => {
+ if (researchScopeRef.current === requestScope) setResearching(false);
+ });
+ }}
+ />
+
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index 656927dd7..e3cc7122d 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -132,7 +132,7 @@ export interface VoiceTaxonomySummary {
disagreement: number;
counts_overlap: boolean;
category_memberships: Array<{
- voice_concept_code: "voc" | "vocc" | "voco" | "vom" | "vop";
+ voice_concept_code: "voc" | "vocc" | "voco" | "vom" | "vop" | "vos" | "voe" | "vob" | "vor" | "voi" | "voso" | "vops";
post_count: number;
eligible_percentage: number;
}>;
@@ -525,6 +525,17 @@ export interface CitedPostImage {
tags: string[];
}
+export interface AskSourceReference {
+ post_id: string;
+ lead_kind_code: string;
+ evidence_url: string;
+ evidence_title_text: string | null;
+ evidence_excerpt_text: string | null;
+ judgment_code: "research_supported" | "research_refuted";
+ next_action_text: string;
+ checked_at: string;
+}
+
export interface AskAgentResponse {
answer_text: string;
cited_post_ids: string[];
@@ -532,6 +543,7 @@ export interface AskAgentResponse {
cited_events?: CitedPostEvent[];
cited_post_evidence?: CitedPostEvidence[];
cited_post_images?: CitedPostImage[];
+ cited_source_references?: AskSourceReference[];
source_post_ids: string[];
external_verification_status?: string;
external_claims?: ExternalClaim[];
@@ -555,6 +567,14 @@ export interface AskAgentResponse {
api_path: string;
resource_uri: string;
evidence_facts: CitedPostEvidenceFact[];
+ source_references: Array<{
+ url: string;
+ title: string | null;
+ excerpt: string | null;
+ judgment_code: string;
+ lead_kind_code: string;
+ next_action: string;
+ }>;
}>;
};
alert: {
@@ -1140,6 +1160,42 @@ export function verifyPostRelations(
return backendFetch(`/api/posts/${postId}/verify-relations`, accessToken, { method: "POST" });
}
+export interface SourceResearchCitation {
+ lead_kind_code: string;
+ lead_source_unit_id: string | null;
+ lead_image_region_id: string | null;
+ lead_excerpt_text: string;
+ search_query_text: string;
+ evidence_url: string | null;
+ evidence_title_text: string | null;
+ evidence_excerpt_text: string | null;
+ judgment_code: string;
+ rationale_text: string;
+ next_action_text: string;
+ checked_at?: string;
+}
+
+export interface SourceResearchResponse {
+ post_id: string;
+ visibility_code: string;
+ citations: SourceResearchCitation[];
+ unavailable_reason?: string | null;
+}
+
+export function fetchPostResearchCitations(
+ accessToken: string,
+ postId: string,
+): Promise {
+ return backendFetch(`/api/posts/${postId}/research-citations`, accessToken);
+}
+
+export function researchPostSources(
+ accessToken: string,
+ postId: string,
+): Promise {
+ return backendFetch(`/api/posts/${postId}/research-citations`, accessToken, { method: "POST" });
+}
+
export interface EvaluationResponse {
criterion_code: string;
criterion_label: string | null;
diff --git a/frontend/src/components/AskAnswerTimeline.stories.tsx b/frontend/src/components/AskAnswerTimeline.stories.tsx
index 6078fc2fd..cc929a40b 100644
--- a/frontend/src/components/AskAnswerTimeline.stories.tsx
+++ b/frontend/src/components/AskAnswerTimeline.stories.tsx
@@ -29,6 +29,16 @@ const args: Story["args"] = {
{ post_id: "post-request", facts: [{ kind: "semantic_project", text: "project: Synthetic renewal" }] },
{ post_id: "post-discussion", facts: [{ kind: "semantic_role", text: "actor: Synthetic account owner" }] },
],
+ cited_source_references: [{
+ post_id: "post-request",
+ lead_kind_code: "research_lead_semantic_unit",
+ evidence_url: "https://example.com/public-source",
+ evidence_title_text: "Synthetic public source",
+ evidence_excerpt_text: "A public document records the revised request.",
+ judgment_code: "research_supported",
+ next_action_text: "Compare the public document with the cited post.",
+ checked_at: "2026-08-20T10:00:00Z",
+ }],
source_post_ids: ["post-request", "post-discussion"],
},
onOpenEvidence: () => undefined,
@@ -47,6 +57,7 @@ export const BidirectionalFocus: Story = {
await expect(card).toHaveFocus();
await userEvent.click(card);
await expect(citation).toHaveFocus();
+ await expect(canvas.getByRole("link", { name: "Synthetic public source" })).toBeVisible();
},
};
diff --git a/frontend/src/components/AskAnswerTimeline.test.tsx b/frontend/src/components/AskAnswerTimeline.test.tsx
index 1465ad9b7..4800e2417 100644
--- a/frontend/src/components/AskAnswerTimeline.test.tsx
+++ b/frontend/src/components/AskAnswerTimeline.test.tsx
@@ -31,6 +31,18 @@ const answer: AskAgentResponse = {
facts: [{ kind: "semantic_project", text: "project: Synthetic renewal" }],
},
],
+ cited_source_references: [
+ {
+ post_id: "post-later",
+ lead_kind_code: "research_lead_semantic_unit",
+ evidence_url: "https://example.com/source",
+ evidence_title_text: "Public source document",
+ evidence_excerpt_text: "A synthetic public excerpt.",
+ judgment_code: "research_supported",
+ next_action_text: "Compare this source with the cited post.",
+ checked_at: "2026-08-20T10:00:00Z",
+ },
+ ],
source_post_ids: ["post-later", "post-earlier"],
};
@@ -83,6 +95,22 @@ describe("AskAnswerTimeline", () => {
expect(onOpenPost).toHaveBeenCalledWith("post-later");
});
+ it("opens a persisted related public source from its cited event", () => {
+ render(
+ undefined}
+ onOpenPost={() => undefined}
+ />,
+ );
+
+ const link = screen.getByRole("link", { name: "Public source document" });
+ expect(link).toHaveAttribute("href", "https://example.com/source");
+ expect(link).toHaveAttribute("target", "_blank");
+ expect(screen.getByText(/A synthetic public excerpt\./)).toBeInTheDocument();
+ });
+
it("names absent time instead of borrowing a lineage timestamp", () => {
render(
(null);
@@ -123,6 +127,9 @@ export function AskAnswerTimeline({ question, answer, onOpenEvidence, onOpenPost
const images = answer.cited_post_images?.filter(
(image) => image.post_id === citation.postId,
) ?? [];
+ const sourceReferences = answer.cited_source_references?.filter(
+ (reference) => reference.post_id === citation.postId,
+ ) ?? [];
const selected = selectedPostId === citation.postId;
return (