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..2d9efad27 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -51,6 +51,13 @@ All notable changes to this project are documented here. Format follows
### Added
+- 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/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/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/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/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 e8be399cf..ad92e78e6 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -539,7 +539,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 a98d45225..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,7 +19,9 @@ 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; `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` |
@@ -28,6 +30,14 @@ 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/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/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 93c391ff2..e3cc7122d 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -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 (
@@ -174,6 +181,29 @@ export function AskAnswerTimeline({ question, answer, onOpenEvidence, onOpenPost
{image.tags.length ? ` — ${t("Image tags")}: ${image.tags.join(", ")}` : ""}
))}
+ {sourceReferences.length ? (
+
+ {t("Related public sources")}
+
+
+ ) : null}