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 d96acd867..be63c5b4e 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 0268) 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 21644e139..64c69b399 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -870,6 +870,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 0268). 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..073884f79
--- /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 0268, 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/Makefile b/Makefile
index 078fc4ed4..56e13515a 100644
--- a/Makefile
+++ b/Makefile
@@ -2,7 +2,7 @@
# Keep provider credentials outside the repository. Compose interpolation must
# read the same home env file as the orchestrator container's env_file.
-COMPOSE := docker compose --env-file "$$HOME/.env"
+COMPOSE := COMPOSE_FILE=docker-compose.yml docker compose --env-file "$$HOME/.env"
up:
$(COMPOSE) up -d
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 0fea9a591..a4cf71b24 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", "").strip(),
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..9a32cd5d7 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"
@@ -380,6 +381,7 @@ def can_see(row: asyncpg.Record) -> bool:
"cited_events": [],
"source_post_ids": [source.post_id for source in sources],
"cited_post_evidence": [],
+ "cited_source_references": [],
"lineage_graph": {"nodes": [], "edges": [], "truncated": False},
"cited_post_images": [],
"external_verification_status": verification_status,
@@ -438,13 +440,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 +469,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 298904bcc..e27f0317a 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -177,6 +177,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,
@@ -269,6 +273,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,
@@ -380,6 +390,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()
@@ -2919,6 +2950,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 21b1adfbf..751fc3c40 100644
--- a/backend/app/mcp_server.py
+++ b/backend/app/mcp_server.py
@@ -250,7 +250,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/operations_dashboard.py b/backend/app/operations_dashboard.py
index c91a12748..9d9db2d59 100644
--- a/backend/app/operations_dashboard.py
+++ b/backend/app/operations_dashboard.py
@@ -489,6 +489,9 @@ async def fetch_operations_dashboard(
)
return {
"period_label": _period_label(period_start, period_end),
+ "period_start": period_start.isoformat() if period_start else None,
+ "period_end": period_end.isoformat() if period_end else None,
+ "period_time_axis_code": "event_occurred_at",
"total_post_count": total,
"total_event_count": int(metrics["total_event_count"]),
"external_post_count": external,
diff --git a/backend/app/post_content_queue.py b/backend/app/post_content_queue.py
index df0ea529a..cec4b5d28 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 "")
@@ -566,6 +599,63 @@ async def requeue_failed_post_content_job(
return PostContentJobRequest(post_id, digest, QUEUED, True)
+async def requeue_failed_post_content_jobs(
+ pool: asyncpg.Pool,
+ client: redis.Redis | None,
+ *,
+ limit: int,
+) -> dict[str, int]:
+ """Requeue one bounded, ledger-backed page of terminal jobs.
+
+ The operator explicitly chooses this recovery path. PostgreSQL commits the
+ reset before Valkey wake-ups are published, so a transport failure remains
+ recoverable from the durable ``queued`` rows.
+ """
+ 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
+ f"""
+ select post.post_id, post.post_body
+ from post_content_ingestion_job job
+ join source_post post on post.post_id = job.post_id
+ where job.status_code = $1
+ and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')}
+ order by job.updated_at, post.post_id
+ limit $2
+ for update of job skip locked
+ """,
+ FAILED,
+ limit,
+ )
+ for row in rows:
+ requests.append(
+ await requeue_failed_post_content_job(
+ conn,
+ str(row["post_id"]),
+ str(row["post_body"] or ""),
+ )
+ )
+
+ published = 0
+ for request in requests:
+ if await publish_post_content_event(
+ client,
+ post_id=request.post_id,
+ source_body_digest=request.source_body_sha256,
+ ):
+ published += 1
+ return {
+ "selected_posts": len(requests),
+ "queued_posts": len(requests),
+ "published_events": published,
+ "recovery_pending": len(requests) - published,
+ }
+
+
async def record_post_content_backfill_success(
conn: asyncpg.Connection,
post_id: str,
diff --git a/backend/app/post_content_worker.py b/backend/app/post_content_worker.py
index 1b340b5ac..f01ef119c 100644
--- a/backend/app/post_content_worker.py
+++ b/backend/app/post_content_worker.py
@@ -22,6 +22,7 @@
STALE_RUNNING_INTERVAL,
SUCCEEDED,
defer_post_content_job,
+ enqueue_post_content_backfill,
ensure_post_content_job,
post_content_is_complete,
republish_queued_post_content_jobs,
@@ -65,6 +66,7 @@
_logger = logging.getLogger(__name__)
_RECOVERY_INTERVAL_SECONDS = 30.0
+_RECOVERY_ENQUEUE_LIMIT = 200
_BROKER_RECOVERY_DELAY_SECONDS = 1.0
_INCOMPLETE_FAILURE_CODE = "post_content_ingestion_incomplete"
_ATTEMPT_LIMIT_FAILURE_CODE = "post_content_ingestion_attempt_limit"
@@ -814,6 +816,47 @@ async def consume_post_content_stream_once(
return last_id
+async def _recover_post_content_jobs(
+ client: redis.Redis,
+ pool: asyncpg.Pool,
+) -> None:
+ """Persist the next bounded candidate page and republish queued wake-ups."""
+ settings = load_settings()
+ require_orchestrator_evidence = bool(
+ settings.orchestrator_base_url and settings.orchestrator_api_key
+ )
+ try:
+ await enqueue_post_content_backfill(
+ pool,
+ client,
+ limit=_RECOVERY_ENQUEUE_LIMIT,
+ require_embedding=require_orchestrator_evidence,
+ require_structure=require_orchestrator_evidence,
+ )
+ except Exception as exc: # noqa: BLE001 - the next recovery cycle must remain alive.
+ _logger.warning(
+ "post-content candidate recovery failed; retrying next cycle (error_type=%s)",
+ type(exc).__name__,
+ )
+ record_server_failure(
+ "post_content_candidate_recovery",
+ exc,
+ outcome="provider_unavailable",
+ )
+ try:
+ await republish_queued_post_content_jobs(client, pool)
+ except Exception as exc: # noqa: BLE001 - broker recovery is independent of selection.
+ _logger.warning(
+ "post-content wake-up recovery failed; retrying next cycle (error_type=%s)",
+ type(exc).__name__,
+ )
+ record_server_failure(
+ "post_content_wakeup_recovery",
+ exc,
+ outcome="provider_unavailable",
+ )
+
+
async def run_post_content_worker(
client: redis.Redis,
pool: asyncpg.Pool,
@@ -828,7 +871,7 @@ async def run_post_content_worker(
while True:
now = time.monotonic()
if now - last_recovery >= _RECOVERY_INTERVAL_SECONDS:
- await republish_queued_post_content_jobs(client, pool)
+ await _recover_post_content_jobs(client, pool)
last_recovery = now
try:
last_id = await consume_post_content_stream_once(
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/tests/test_api.py b/backend/tests/test_api.py
index 3be9bde9c..45f5740f6 100644
--- a/backend/tests/test_api.py
+++ b/backend/tests/test_api.py
@@ -202,6 +202,11 @@
/ "migrations"
/ "0218_global_ask_public_verification.sql"
)
+_SOURCE_RESEARCH_CITATION_MIGRATION = (
+ Path(__file__).resolve().parents[2]
+ / "migrations"
+ / "0236_source_research_citation.sql"
+)
_GLOBAL_ASK_KNOWLEDGE_CUTOFF_MIGRATION = (
Path(__file__).resolve().parents[2]
/ "migrations"
@@ -473,6 +478,7 @@ def seeded_db(demo_analyst_token):
conn.autocommit = False
cur.execute(_GLOBAL_ASK_KNOWLEDGE_CUTOFF_MIGRATION.read_text())
cur.execute(_GLOBAL_ASK_PUBLIC_VERIFICATION_MIGRATION.read_text())
+ cur.execute(_SOURCE_RESEARCH_CITATION_MIGRATION.read_text())
cur.execute(_EVENT_OCCURRED_AT_MIGRATION.read_text())
for migration_path in _LATE_REPLAYABLE_MIGRATIONS:
cur.execute(migration_path.read_text())
@@ -6318,9 +6324,11 @@ def test_seed_period_report_includes_fixture_event_lineage_posts(
def test_seed_period_report_member_click_lands_on_decorated_fixture(
client, demo_analyst_token, seeded_db
) -> None:
- """The first W02 report member must already have Event Lineage,
- Keyman, and evaluation -- otherwise the buyer click opens a dummy
- high/low band row.
+ """The first W02 report member has buyer evidence without fake lineage.
+
+ Event Lineage remains absent until accepted owner weights exist; that
+ missing calibrated channel must not prevent the synthetic post, Keyman,
+ evaluation, and report surfaces from being seeded.
"""
from lineageweave.fixtures import fixture_thread_cast, fixture_titles_in_iso_week
from scripts.seed_demo_data import (
@@ -6379,11 +6387,6 @@ def test_seed_period_report_member_click_lands_on_decorated_fixture(
a100 = next(report for report in threads.json()["reports"] if report["grouping_key"] == "A-100")
post_id = a100["members"][0]["post_id"]
- lineage = client.get(f"/api/posts/{post_id}/lineage", headers=headers)
- assert lineage.status_code == 200, lineage.text
- body = lineage.json()
- assert body["direct"] or body["indirect"]
-
keymen = client.get(f"/api/posts/{post_id}/keymen", headers=headers)
assert keymen.status_code == 200, keymen.text
names = {person["person_name"] for person in keymen.json()["keymen"]}
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 d3749fdfb..c91a2bf08 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/0098-valkey-backed-post-content-ingestion.md b/docs/adr/0098-valkey-backed-post-content-ingestion.md
index 95376b72a..2acc86cf9 100644
--- a/docs/adr/0098-valkey-backed-post-content-ingestion.md
+++ b/docs/adr/0098-valkey-backed-post-content-ingestion.md
@@ -96,7 +96,19 @@ Operational backfill MUST use `scripts/queue_post_content_backfill.py` or
`POST /api/post-content/backfill`; both call the same producer. The HTTP
entry point requires `post_admin`, accepts only a 1--200 row page, and returns
HTTP 202 after committing the ledger and attempting wake-ups; it never runs a
-provider in the request. The CLI has the same bound and no whole-corpus mode.
+provider in the request. Each worker recovery cycle also persists one bounded
+page before republishing queued wake-ups. Active and terminal jobs remain
+excluded, so successive cycles make durable corpus progress without duplicate
+work or an unbounded HTTP request. Candidate selection and broker recovery are
+independent: either failure is recorded and retried on the next cycle without
+stopping the worker.
+
+The CLI retains the same per-query bound. `--all-pages` repeats that governed
+producer until the current candidate set is empty; progress remains visible in
+the normalized job ledger after every page. Terminal failures are never reset
+implicitly. An operator may combine `--retry-failed --all-pages` only after the
+failed dependency has been restored; each failed page uses the existing
+explicit retry transition and commits before its wake-ups.
The producer applies `SOURCE_POST_ELIGIBILITY_SQL`, locks source rows with
`SKIP LOCKED`, selects only new or incomplete-succeeded jobs, rechecks the
shared completeness predicate, and records the existing job state in
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/0227-observed-postgresql-runtime-tuning.md b/docs/adr/0227-observed-postgresql-runtime-tuning.md
index 20ecf7819..d11e2dcd9 100644
--- a/docs/adr/0227-observed-postgresql-runtime-tuning.md
+++ b/docs/adr/0227-observed-postgresql-runtime-tuning.md
@@ -27,6 +27,7 @@ operator-declared observation duration and records:
- PostgreSQL version and statistics-reset instants;
- `pg_stat_wal` and checkpoint deltas;
- current durability and tuning settings;
+- the default and current transaction isolation levels;
- `wal_segment_size` and the existing `checkpoint_timeout`;
- container memory limit, data-filesystem free bytes, and current `pg_wal`
bytes.
@@ -60,6 +61,12 @@ or storage latency/IOPS evidence that the WAL/checkpoint observation does not
provide. A CPU-bound index scan is explicitly not storage-concurrency evidence.
`fsync`, `full_page_writes`, and `synchronous_commit` must all remain enabled.
+Transaction isolation is a correctness invariant, not a WAL-throughput knob.
+The planner records both `default_transaction_isolation` and the observation
+session's `transaction_isolation`, rejects a mismatch or a change across the
+measurement/restart boundary, and never chooses a stronger or weaker level
+from WAL statistics. Any isolation-policy change requires a separate approved
+decision and concurrency evidence.
The generated environment file is consumed only by the explicit
`docker-compose.postgres-tuned.yml` overlay during a controlled PostgreSQL
restart. The base Compose file remains the rollback path: remove the overlay
diff --git a/docs/adr/0239-external-email-project-lineage-contract.md b/docs/adr/0239-external-email-project-lineage-contract.md
index 04d9070ff..5da519287 100644
--- a/docs/adr/0239-external-email-project-lineage-contract.md
+++ b/docs/adr/0239-external-email-project-lineage-contract.md
@@ -18,15 +18,13 @@ LineageWeave publishes contract version `1.0.0` through:
The initial implementation is a store-agnostic Python package boundary. It performs no database, mailbox, provider, or network operation. A later service or Naruon plugin adapter must preserve the same JSON Schema and truth boundaries.
-Inferred edges additionally require a provenance-bearing
-`ChannelWeightEstimate` produced by the repository's fast-mlsirm measurement
-boundary. The estimate is an injected execution dependency, not caller JSON:
-the external evidence contract cannot assert its own fusion weights. When no
-estimate is available, the adapter still returns caller-observed edges and an
-explicit `channel_weights_unavailable` limitation, but produces no inferred
-edge. The LLM channel is active only when the estimate explicitly includes an
-`llm` item; an available model without such measurement remains unavailable
-for this run.
+Inferred edges remain unavailable until the measurement owner publishes an
+accepted, independently anchored fitted artifact. The external evidence
+contract cannot assert its own fusion weights, and LineageWeave does not fit,
+normalize, simulate, or interpret them in Python. The adapter returns
+caller-observed edges and an explicit `channel_weights_unavailable`
+limitation, but produces no inferred edge. Requesting the optional LLM channel
+therefore reports it unavailable and never activates a provider call.
The caller supplies opaque evidence references, bounded text labels, occurrence and availability clocks, an optional secondary key, an optional project reference, and an optional caller-observed parent relation. Explicit observed parent relations replace an inferred parent for the same child and must form an acyclic graph. Reconstructed continuation remains `inferred`. Project groupings remain `proposed`.
@@ -48,7 +46,7 @@ Evidence becoming available after the cutoff is excluded even when it describes
- RFC reply/thread evidence stays distinguishable from semantic lineage.
- Caller-observed children are never disclosed to an optional model merely to calculate an inferred edge that would be discarded.
- The optional LLM channel is explicit as `not_requested`, `unavailable`, or `completed`; missing output is never zero.
-- Missing or malformed psychometric weight provenance yields no inferred edge; no default, equal, or caller-authored weight is substituted.
+- Until an accepted owner artifact exists, no inferred edge is emitted; no default, equal, simulated, local, or caller-authored weight is substituted.
- Canonical serialization and SHA-256 digesting are deterministic for a given request or result. Repeatability of model-backed scores additionally requires a pinned LineageWeave release, adjudicator implementation, provider/model revision, and model-side determinism policy.
- Explicit parent cycles and analysis work above the caller-approved pair budget fail closed before inference.
- Project evidence can inform Naruon without mutating authoritative project/task/provider state.
diff --git a/docs/adr/0268-post-scoped-source-reference-research.md b/docs/adr/0268-post-scoped-source-reference-research.md
new file mode 100644
index 000000000..3052d0a4a
--- /dev/null
+++ b/docs/adr/0268-post-scoped-source-reference-research.md
@@ -0,0 +1,98 @@
+# ADR 0268: 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/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..22d525158
--- /dev/null
+++ b/docs/manuals/operations-manual.md
@@ -0,0 +1,155 @@
+# 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. The enabled worker admits the next bounded incomplete page every recovery
+ cycle. For an operator-controlled catch-up, run
+ `scripts/queue_post_content_backfill.py --all-pages`; after restoring a
+ terminal dependency, add `--retry-failed`. Both modes persist each page
+ before publishing wake-ups and report aggregate counts only.
+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-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index 841173341..98a38e15a 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -1,14 +1,15 @@
# Product & Technical Gap Baseline
> Current rebuild overlay: 2026-08-28 KST. Protected `main` is
-> `fc13acaa20adca11968238e398d4aafcf62b6cee`; draft PR #640 is being
-> rebuilt from that exact base while retaining its prior
-> `5fd50962c43717dbd73c9ee73aa5bf106507118a` history. The open queue has
-> 13 PRs: #781 and #778 were clean at their then-current heads; #782, #780,
-> #774, #772, #771, and #770 still required base or check resolution; draft
-> PRs #702, #679, #672, #667, and #640 remained dirty. Checks and reviews
-> from the pre-rebuild #640 head do not transfer to this candidate. Exact-head
-> Compose, browser, load, and backfill acceptance remains pending. This
+> `ff7431bd1851c03e737808d22c6a2d43968582f9`; PR #640 is a ready-for-review
+> current-main semantic rebuild at `f0bc98eef238b7a03d4227ab909c8de296041f36`.
+> PR #778 remains remotely at `3d38f48dd3ca7e939b60f80f34ca61260c377818`;
+> its locally tested restack candidate before this documentation-only update
+> was `572ef39bf0f31882a8b1cb920f69b66d38176dab`. The open queue has 14 PRs:
+> #783, #782, #781, #780, #778, #774, #772, #771, #770, #702, #679, #672,
+> #667, and #640; #702/#679/#672/#667 remain drafts. Local candidate tests do
+> not transfer to the remote PR head or protected `main`. Exact-head Compose,
+> browser, load, and backfill acceptance remains pending. This
> overlay supersedes every older queue count below while the dated historical
> snapshots remain supporting evidence only.
>
@@ -497,10 +498,16 @@ public history. Do not reproduce or hint at its value. Historical remediation
requires the ADR 0001 incident process and security/privacy-owner coordination;
never force-push or delete evidence ad hoc.
-The Grok durable hourly loop and the central thin GitHub Actions caller
-ContextualWisdomLab/.github#1259 (minute 4, `pr-review-fix-scheduler.yml`)
-both target this repository. Do not add a LineageWeave-local duplicate
-workflow. ContextualWisdomLab/.github#1258 merged at exact head `897819c4` to
+The former central caller PRs ContextualWisdomLab/.github#1259 and #1288 both
+closed without merge and therefore are not scheduler evidence. Open
+ContextualWisdomLab/.github#1380 is a bounded hourly PR review-and-repair caller;
+it does not discover or implement product gaps. LineageWeave still has no
+local, manual opt-in entrypoint for commercial product-development work. The
+central commercial coordinator's maintainer mutation credential was
+unavailable or unverified at the last bounded runtime and failed before
+repository inventory, so autonomous hourly product development remains an
+explicit unverified gap; no credential is inferred or added here.
+ContextualWisdomLab/.github#1258 merged at exact head `897819c4` to
repair the pnpm/coverage-evidence workflow; newly created exact PR heads must
still prove the runtime behavior because merged workflow source alone is not
check evidence.
@@ -733,7 +740,7 @@ of leverage; open connector PRs there when the defect is upstream:
6. **ThreadWeave** — tree assembly.
7. **Naruon** — calendar and email/project lineage projection (#336, #338, #355).
8. **DiskSage / wardnet** — storage and network policy as needed.
-9. **ContextualWisdomLab/.github** — required review workflows (OpenCode, Strix, Noema) and the LineageWeave hourly caller (#1259). If stacked PRs miss central review or coverage-evidence fails on pnpm 9 (`--trust-lockfile` is pnpm 11.3) or a missing Vitest coverage provider, fix the org workflow (#1258), not a local bypass.
+9. **ContextualWisdomLab/.github** — required review workflows (OpenCode, Strix, Noema) and bounded hourly PR review/repair (#1380 candidate). This does not replace a commercial product-gap coordinator. If stacked PRs miss central review or coverage-evidence fails on pnpm 9 (`--trust-lockfile` is pnpm 11.3) or a missing Vitest coverage provider, fix the org workflow (#1258), not a local bypass.
## 8. Public ontology publication boundary
@@ -773,8 +780,11 @@ each: check reviews → repair → re-verify Checks → merge → continue. Chec
review latency are never blockers — keep working while they settle.
1. Revalidate Strix after merged ContextualWisdomLab/.github#1320, reconcile
- open .github#1263, and land the atomic hourly LineageWeave caller in open
- .github#1288 only through their protected gates.
+ open .github#1263, and verify open .github#1380 only as bounded hourly PR
+ review/repair through its protected gates. Keep commercial product-gap
+ development unavailable until a local manual opt-in entrypoint and the
+ central coordinator's maintainer mutation credential are independently
+ verified; closed-unmerged #1259/#1288 provide no delivery evidence.
2. Process main-targeted PRs #629, #631, #632, #639, #640, #643, #644, #657,
#658, #659, #660, and #663 only after each exact head shows terminal green
required checks plus current-head independent approval. Treat #666's
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 99da3972b..1413a4241 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` |
| `Post/Recorded perspectives` | Read the imported primary and every evidence-connected additional Voice with its recorded truth state instead of flattening them into one compound category. `CombinedEvidence`, `RejectedEvidence`, and `NarrowViewport` cover desktop, rejected-evidence, and narrow layouts. | `VoicePerspectiveList`, `ticket-list`, `post-meta` |
@@ -23,7 +23,6 @@ operator-facing control you can click before changing product CSS.
| `Chrome/StatusNotice` | Read success, unavailable, or retry copy, then take the named next action. Success and unavailable are a named region (not live `role=status`); Retry is `role=alert` and only on the retry kind. Calendar's missing Naruon projection uses unavailable. | `--badge-status-success-*`, `--badge-status-pending-*`, `--badge-status-danger-*`, `StatusNotice` |
| `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` |
@@ -35,6 +34,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/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..7b25531d8
--- /dev/null
+++ b/frontend/e2e/runtime-operations-dashboard.spec.ts
@@ -0,0 +1,69 @@
+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("/");
+ const language = page.locator(".language-switcher select");
+ await language.selectOption("en");
+ await expect(page.locator("html")).toHaveAttribute("lang", "en");
+ await expect(page.getByRole("heading", { name: "Operations evidence dashboard" })).toBeVisible();
+ await expect(page.getByRole("heading", { name: "Topic model influence over time" })).toBeVisible();
+ await expect(page.getByRole("heading", { name: "Voice evidence overview" })).toBeVisible();
+ const navigation = page.getByRole("navigation", { name: "Workspace navigation" });
+ for (const label of ["Dashboard", "External information", "Board", "Customer master", "Calendar", "Ask Agent"]) {
+ await expect(navigation.getByRole("button", { name: label, exact: true })).toBeVisible();
+ }
+ await expect(page.getByText("운영 근거 대시보드")).toHaveCount(0);
+ for (const koreanLabel of ["전체 기간 · Event 발생일", "클레임 원인 규명", "재입찰 · 인수인계", "발주 공고 · 시장 동향", "반복 이슈"]) {
+ await expect(page.getByText(koreanLabel, { exact: true })).toHaveCount(0);
+ }
+ if (requireGroundedCase) {
+ await expect(page.locator(".dashboard-case-card").first()).toBeVisible();
+ }
+ await page.screenshot({ path: screenshotPath, fullPage: true });
+
+ await language.selectOption("ko");
+ await expect(page.locator("html")).toHaveAttribute("lang", "ko");
+ await expect(page.getByRole("heading", { name: "운영 근거 대시보드" })).toBeVisible();
+ await expect(page.getByRole("heading", { name: "시간 흐름별 주제 영향도" })).toBeVisible();
+ await expect(page.getByRole("heading", { name: "글 유형 근거 현황" })).toBeVisible();
+ const koreanNavigation = page.getByRole("navigation", { name: "워크스페이스 메뉴" });
+ for (const label of ["대시보드", "외부 정보", "게시판", "고객 마스터", "캘린더", "에이전트에게 질문"]) {
+ await expect(koreanNavigation.getByRole("button", { name: label, exact: true })).toBeVisible();
+ }
+ await expect(page.getByText("Operations evidence dashboard")).toHaveCount(0);
+ await expect(page.getByText("전체 기간 · 사건 발생일", { exact: true })).toBeVisible();
+ await expect(page.getByText("클레임 원인 규명", { exact: true }).first()).toBeVisible();
+});
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 9116efbb3..482e1a723 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -1486,6 +1486,8 @@
.dashboard-metrics dt { color: var(--color-text); font-size: 0.875rem; }
.dashboard-metrics dd { margin: 0.25rem 0 0; font-size: 1.5rem; font-weight: 700; }
+.dashboard-count-unit { white-space: nowrap; }
+
.dashboard-case-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(22rem, 100%), 1fr));
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index 7c47905a2..382edcb30 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -162,6 +162,8 @@ describe("App, authenticated", () => {
askImageCitation?: boolean;
askDelivery?: boolean;
lineageIsolationReason?: "comparison_candidates_available" | "no_comparison_group";
+ privateResearch?: boolean;
+ researchGetFailure?: boolean;
}): ReturnType & { releaseMe: () => void; releasePostOne: () => void } {
const statusLabel: Record = {
open: "Open",
@@ -1259,7 +1261,7 @@ describe("App, authenticated", () => {
post_body: options?.postBody ?? "The full body text.",
voc_type_code: "voc",
voc_type_label: "Voice of Customer",
- visibility_code: "public",
+ visibility_code: options?.privateResearch ? "private" : "public",
visibility_label: "Public",
project_evidence: [
{
@@ -1741,6 +1743,38 @@ describe("App, authenticated", () => {
}
return Promise.resolve(jsonResponse({ verified: [] }));
}
+ if (url.endsWith("/api/posts/post-1/research-citations")) {
+ if (method !== "POST" && options?.researchGetFailure) {
+ return Promise.resolve(new Response("unavailable", { status: 503 }));
+ }
+ return Promise.resolve(
+ jsonResponse({
+ post_id: "post-1",
+ visibility_code: "public",
+ citations:
+ method === "POST"
+ ? [
+ {
+ lead_kind_code: "research_lead_source_unit",
+ lead_source_unit_id: "unit-synthetic",
+ lead_image_region_id: null,
+ lead_excerpt_text: "Synthetic highlighted passage",
+ search_query_text: "synthetic evidence query",
+ evidence_url: "https://evidence.example/source",
+ evidence_title_text: "Synthetic cited source",
+ evidence_excerpt_text: "Synthetic public evidence excerpt",
+ judgment_code: "research_supported",
+ rationale_text: "The cited source supports the highlighted passage.",
+ next_action_text: "Open the cited source and compare the passage.",
+ },
+ ]
+ : [],
+ unavailable_reason: options?.privateResearch
+ ? "Public-source research is unavailable for this post."
+ : null,
+ }),
+ );
+ }
if (url.endsWith("/api/posts/post-1/lineage")) {
return Promise.resolve(
jsonResponse({
@@ -2210,7 +2244,7 @@ describe("App, authenticated", () => {
stubBackend();
render();
expect(await screen.findByRole("button", { name: "View post: Public post" })).toBeInTheDocument();
- await userEvent.click(screen.getByRole("button", { name: "고객 마스터" }));
+ await userEvent.click(screen.getByRole("button", { name: "Customer master" }));
expect(await screen.findByText("Demo Corp")).toBeInTheDocument();
expect(screen.getByText("DEMO-CORP-01 · Company")).toBeInTheDocument();
@@ -2230,7 +2264,7 @@ describe("App, authenticated", () => {
stubBackend({ customerEntityHierarchy: true });
render();
expect(await screen.findByRole("button", { name: "View post: Public post" })).toBeInTheDocument();
- await userEvent.click(screen.getByRole("button", { name: "고객 마스터" }));
+ await userEvent.click(screen.getByRole("button", { name: "Customer master" }));
expect(await screen.findByText("Demo Group")).toBeInTheDocument();
const subsidiaryRow = screen.getByText("Demo Corp").closest("li");
@@ -2250,7 +2284,7 @@ describe("App, authenticated", () => {
stubBackend();
render();
expect(await screen.findByRole("button", { name: "View post: Public post" })).toBeInTheDocument();
- await userEvent.click(screen.getByRole("button", { name: "고객 마스터" }));
+ await userEvent.click(screen.getByRole("button", { name: "Customer master" }));
const entityButton = (await screen.findByText("DEMO-CORP-01 · Company")).closest("button");
expect(entityButton).not.toBeNull();
@@ -2276,7 +2310,7 @@ describe("App, authenticated", () => {
stubBackend();
render();
expect(await screen.findByRole("button", { name: "View post: Public post" })).toBeInTheDocument();
- await userEvent.click(screen.getByRole("button", { name: "고객 마스터" }));
+ await userEvent.click(screen.getByRole("button", { name: "Customer master" }));
expect(await screen.findByText("Northridge Grid")).toBeInTheDocument();
expect(screen.getByText("Voice of Customer (1), Voice of Competitor (1)")).toBeInTheDocument();
@@ -2298,7 +2332,7 @@ describe("App, authenticated", () => {
stubBackend({ admin: true, manyCustomerHints: 1 });
render();
expect(await screen.findByRole("button", { name: "View post: Public post" })).toBeInTheDocument();
- await userEvent.click(screen.getByRole("button", { name: "고객 마스터" }));
+ await userEvent.click(screen.getByRole("button", { name: "Customer master" }));
expect(await screen.findByText("CUST-0")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Resolve" }));
@@ -2310,7 +2344,7 @@ describe("App, authenticated", () => {
stubBackend({ manyCustomerHints: 1 });
render();
expect(await screen.findByRole("button", { name: "View post: Public post" })).toBeInTheDocument();
- await userEvent.click(screen.getByRole("button", { name: "고객 마스터" }));
+ await userEvent.click(screen.getByRole("button", { name: "Customer master" }));
expect(await screen.findByText("CUST-0")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Resolve" })).not.toBeInTheDocument();
@@ -2326,7 +2360,7 @@ describe("App, authenticated", () => {
stubBackend({ hintRelatedPosts: true });
render();
expect(await screen.findByRole("button", { name: "View post: Public post" })).toBeInTheDocument();
- await userEvent.click(screen.getByRole("button", { name: "고객 마스터" }));
+ await userEvent.click(screen.getByRole("button", { name: "Customer master" }));
const customerSection = await screen.findByRole("region", { name: "Observed customer evidence" });
expect(within(customerSection).getByText("Related posts (1)").closest("details")).toHaveClass(
@@ -2352,7 +2386,7 @@ describe("App, authenticated", () => {
stubBackend({ manyCustomerHints: 45 });
render();
expect(await screen.findByRole("button", { name: "View post: Public post" })).toBeInTheDocument();
- await userEvent.click(screen.getByRole("button", { name: "고객 마스터" }));
+ await userEvent.click(screen.getByRole("button", { name: "Customer master" }));
expect(await screen.findByText("CUST-0")).toBeInTheDocument();
expect(screen.getByText(/Showing the first 30 of 45 observed customer identifiers/)).toBeInTheDocument();
@@ -3122,6 +3156,51 @@ describe("App, authenticated", () => {
);
});
+ it("lets a post administrator research and open a cited public source", 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" }));
+
+ await waitFor(() =>
+ expect(fetchMock).toHaveBeenCalledWith(
+ expect.stringContaining("/api/posts/post-1/research-citations"),
+ expect.objectContaining({ method: "POST" }),
+ ),
+ );
+ expect(await screen.findByRole("link", { name: "Synthetic cited source" })).toHaveAttribute(
+ "href",
+ "https://evidence.example/source",
+ );
+ });
+
+ it("keeps public-source research unavailable for a private post administrator", async () => {
+ stubBackend({ admin: true, privateResearch: true });
+ render();
+
+ await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" }));
+ expect(await screen.findByText("Public-source research is unavailable for this post.")).toBeVisible();
+ await waitFor(() =>
+ expect(screen.queryByRole("button", { name: "Research public sources" })).toBeNull(),
+ );
+ });
+
+ it("keeps the public research retry available after a citation-load failure", async () => {
+ const fetchMock = stubBackend({ admin: true, researchGetFailure: 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" }));
+ await waitFor(() =>
+ expect(fetchMock).toHaveBeenCalledWith(
+ expect.stringContaining("/api/posts/post-1/research-citations"),
+ expect.objectContaining({ method: "POST" }),
+ ),
+ );
+ expect(await screen.findByRole("link", { name: "Synthetic cited source" })).toBeVisible();
+ });
+
it("lets post_admin extract Keymen from the popup", async () => {
const fetchMock = stubBackend({ admin: true });
render();
@@ -4364,16 +4443,16 @@ describe("App, authenticated", () => {
const nav = await screen.findByRole("navigation", { name: "Workspace navigation" });
expect(nav).toBeInTheDocument();
- expect(screen.getByRole("button", { name: "게시판" })).toHaveAttribute("aria-current", "page");
+ expect(screen.getByRole("button", { name: "Board" })).toHaveAttribute("aria-current", "page");
expect(within(nav).getAllByRole("button").map((button) => button.textContent)).toEqual([
"Dashboard",
- "외부 정보",
- "게시판",
- "고객 마스터",
- "달력",
+ "External information",
+ "Board",
+ "Customer master",
+ "Calendar",
"Ask Agent",
]);
- expect(nav.textContent).not.toMatch(/Buyer|Cubee|\bBoard\b|Customer master/i);
+ expect(nav.textContent).not.toMatch(/Buyer|Cubee/i);
expect(within(nav).queryByRole("button", { name: /Admin|관리자/i })).not.toBeInTheDocument();
expect(screen.queryByText("Advanced review tools")).not.toBeInTheDocument();
});
@@ -4382,8 +4461,8 @@ describe("App, authenticated", () => {
stubBackend();
render();
- await userEvent.click(await screen.findByRole("button", { name: "달력" }));
- expect(screen.getByRole("heading", { name: "달력" })).toBeInTheDocument();
+ await userEvent.click(await screen.findByRole("button", { name: "Calendar" }));
+ expect(screen.getByRole("heading", { name: "Calendar" })).toBeInTheDocument();
expect(screen.getByText("이 범위의 일정을 아직 받을 수 없습니다")).toBeInTheDocument();
expect(
screen.getByRole("region", { name: /^Unavailable:/ }),
@@ -4394,7 +4473,7 @@ describe("App, authenticated", () => {
await userEvent.click(
screen.getByRole("button", { name: /open commitment for: public post/i }),
);
- expect(await screen.findByRole("button", { name: "게시판" })).toHaveAttribute(
+ expect(await screen.findByRole("button", { name: "Board" })).toHaveAttribute(
"aria-current",
"page",
);
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index fa3b399f8..e702e3638 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -32,6 +32,7 @@ import {
fetchPostEvaluation,
fetchPostKeymen,
fetchPostLineage,
+ fetchPostResearchCitations,
fetchPostFiveW1H,
fetchPostSummary,
fetchPostTickets,
@@ -47,6 +48,7 @@ import {
fetchRelatedTeam,
rebuildLineage,
rebuildPeriodReports,
+ researchPostSources,
setPostBookmark,
setPreferredLocale,
updateTicketStatus,
@@ -67,6 +69,7 @@ import {
type LineageGraph,
type Keyman,
type SourceAuthorContext,
+ type SourceResearchCitation,
type PostAiSummary,
type PostFiveW1H,
type PostDetail,
@@ -96,6 +99,7 @@ import { CutoffKnownBody } from "./components/CutoffKnownBody";
import { LineageEntityPicker } from "./components/LineageEntityPicker";
import { PopupCloseButton } from "./components/PopupCloseButton";
import { TeppAcceptedReceipt } from "./components/TeppAcceptedReceipt";
+import { SourceResearchPanel } from "./components/SourceResearchPanel";
import { WorkspaceNav, type WorkspaceDestination } from "./components/WorkspaceNav";
import { OccupationRatingProfile } from "./components/OccupationRatingProfile";
import { AskAnswerTimeline } from "./components/AskAnswerTimeline";
@@ -1981,6 +1985,11 @@ function PostDetailPopup({
const [keymen, setKeymen] = useState(null);
const [sourceAuthorContext, setSourceAuthorContext] = useState(null);
const [counterparties, setCounterparties] = useState(null);
+ const [researchCitations, setResearchCitations] = useState([]);
+ const [researchUnavailable, setResearchUnavailable] = useState(null);
+ const [researchError, setResearchError] = useState(null);
+ const [researching, setResearching] = useState(false);
+ const researchRequestRef = useRef(0);
const [lineage, setLineage] = useState(null);
const [affiliateTrees, setAffiliateTrees] = useState(null);
const [vocEvidence, setVocEvidence] = useState(null);
@@ -2073,6 +2082,23 @@ function PostDetailPopup({
.catch(() => setCounterparties([]));
}
+ async function handleResearchSources() {
+ const requestId = ++researchRequestRef.current;
+ setResearching(true);
+ setResearchError(null);
+ try {
+ const result = await researchPostSources(accessToken, postId);
+ if (requestId !== researchRequestRef.current) return;
+ setResearchCitations(result.citations);
+ setResearchUnavailable(result.unavailable_reason ?? null);
+ } catch {
+ if (requestId !== researchRequestRef.current) return;
+ setResearchError(t("Public research could not be completed. Narrow the evidence and try again."));
+ } finally {
+ if (requestId === researchRequestRef.current) setResearching(false);
+ }
+ }
+
useEffect(() => {
setPost(null);
setStructureUnits([]);
@@ -2086,6 +2112,11 @@ function PostDetailPopup({
setKeymen(null);
setSourceAuthorContext(null);
setCounterparties(null);
+ setResearchCitations([]);
+ setResearchUnavailable(null);
+ setResearchError(null);
+ setResearching(false);
+ const researchRequestId = ++researchRequestRef.current;
setLineage(null);
setAffiliateTrees(null);
setVocEvidence(null);
@@ -2145,6 +2176,16 @@ function PostDetailPopup({
fetchPostCounterparties(accessToken, postId)
.then((r) => setCounterparties(r.counterparties))
.catch(() => setCounterparties([]));
+ fetchPostResearchCitations(accessToken, postId)
+ .then((result) => {
+ if (researchRequestId !== researchRequestRef.current) return;
+ setResearchCitations(result.citations);
+ setResearchUnavailable(result.unavailable_reason ?? null);
+ })
+ .catch(() => {
+ if (researchRequestId !== researchRequestRef.current) return;
+ setResearchUnavailable(t("No public research citations yet."));
+ });
fetchPostLineage(accessToken, postId).then(setLineage).catch(() => setLineage(null));
fetchPostAffiliateTree(accessToken, postId)
.then((r) => setAffiliateTrees(r.trees))
@@ -2839,6 +2880,15 @@ function PostDetailPopup({
/>
)}
+
+
@@ -5370,7 +5420,7 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean
{
setPostToOpen(postId);
setDestination("board");
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index 478b76fc9..3c11b404b 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -572,6 +572,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[];
@@ -579,6 +590,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[];
@@ -1323,6 +1335,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 (