diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9ed005692..f766ef1af 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,18 @@ All notable changes to this project are documented here. Format follows
## [2.23.1] - 2026-08-22
+### Added
+
+- Registered the `analysis_run_topic_lineage` analysis-run kind (migrations
+ 0131/0132, ADR 0147) and the exact LineageWeave consumer for TEPP's bounded
+ `tepp.trsl_topic_lineage.v1` artifact (TEPP ADR 0012). It submits through
+ `tepp_client`, verifies completion, digest, snapshot, cutoff, counts, and
+ predecessor/successor edges, and fails closed until that artifact is valid.
+ Project History derives its displayed topic counts only from authorized
+ artifact edges; the evidence DAG remains navigation evidence, not a fallback.
+ `make seed` now also writes a Demo Corp topic-lineage run alongside the
+ existing lineage/TEPP/period-report rows.
+
### Changed
- Related-node chips now show authorized business context: a unique
diff --git a/CLAUDE.md b/CLAUDE.md
index 677d16396..1d29356d0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -122,3 +122,21 @@ current and moves focus to the Keyman heading once Keyman rows have
settled (ADR 0100). The report-member auto-land chain to related nodes
and Ask is not used for GNB origins. A home-list open does not gain that
focus. Do not invent a theta.
+
+## Analysis-run topic lineage (ADR 0147)
+
+`make seed` also writes a Demo Corp topic-lineage run on the same shared
+snapshot, alongside the lineage, TEPP, and period-report rows. The
+topic-lineage kind (`analysis_run_topic_lineage`, migration 0131) submits
+through the same `tepp_client` boundary as TEPP (ADR 0022), requesting
+TEPP's `trsl_tm_cpu_f64_v1` Temporal Relational Shared-Latent Topic
+Measurement (TRSL-TM) result instead of calibrated psychometric measurement.
+A missing transport or an unused accepted envelope is Failed
+(`tepp_not_available` / `tepp_result_not_persisted`), the same as TEPP. Do
+not invent a topic identity or a local topic model of any kind. `POST
+/api/analysis-runs` still 422s this kind — Create
+does not invent a Pending topic-lineage row; connect a TEPP transport from
+a Failed topic-lineage row and re-run through
+`POST /api/analysis-runs/{id}/start`, exactly like TEPP. A Succeeded
+envelope persists into `analysis_run_topic_lineage_result` (migration 0132)
+only after its exact `tepp.trsl_topic_lineage.v1` artifact validates.
diff --git a/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py
index fb76ad821..5f5ff0b1a 100644
--- a/backend/app/analysis_run_ingestion.py
+++ b/backend/app/analysis_run_ingestion.py
@@ -34,11 +34,13 @@
_LINEAGE_RUN_KIND = "analysis_run_lineage"
_TEPP_RUN_KIND = "analysis_run_tepp"
_REPORT_RUN_KIND = "analysis_run_report"
+_TOPIC_LINEAGE_RUN_KIND = "analysis_run_topic_lineage"
_CORPORATE_SCOPE = "analysis_scope_corporate_entity"
_CAPTURE_CONTRACT_VERSION = "analysis-run-capture-v1"
_KIND_SCHEMA_VERSION = {
"analysis_run_lineage": "lineage-run-v1",
"analysis_run_tepp": "tepp-run-v1",
+ "analysis_run_topic_lineage": "topic-lineage-run-v1",
}
_RUN_LIST_SQL = f"""
@@ -611,11 +613,12 @@ def __init__(self, status_code: int, detail: str) -> None:
def _require_lineage_create_kind(run_kind_code: str) -> None:
- """Reject TEPP and report writes so this path cannot fake those products.
+ """Reject TEPP, topic-lineage, and report writes so this path cannot fake those products.
- TEPP stays a ``tepp_client`` wire path. Period reports stay on the
- Reports panel rebuild. A Pending TEPP row that never called the
- transport is a fabricated measurement request.
+ TEPP and topic-lineage stay ``tepp_client`` wire paths (ADR 0022 /
+ ADR 0147). Period reports stay on the Reports panel rebuild. A Pending
+ TEPP or topic-lineage row that never called the transport is a
+ fabricated measurement request.
"""
if run_kind_code == _TEPP_RUN_KIND:
raise AnalysisRunCreateError(
@@ -623,6 +626,12 @@ def _require_lineage_create_kind(run_kind_code: str) -> None:
"Connect a TEPP transport from a Failed TEPP row; this endpoint "
"does not invent a measurement.",
)
+ if run_kind_code == _TOPIC_LINEAGE_RUN_KIND:
+ raise AnalysisRunCreateError(
+ 422,
+ "Connect a TEPP transport from a Failed topic-lineage row; this "
+ "endpoint does not invent a topic model.",
+ )
if run_kind_code == _REPORT_RUN_KIND:
raise AnalysisRunCreateError(
422,
diff --git a/backend/app/analysis_run_start.py b/backend/app/analysis_run_start.py
index 180a660cf..e5c3f37a8 100644
--- a/backend/app/analysis_run_start.py
+++ b/backend/app/analysis_run_start.py
@@ -33,10 +33,17 @@
from lineageweave.lineage_persistence import lineage_edge_specs
from lineageweave.models import Edge
from lineageweave.tepp_client import AnalysisRunRequest, TeppClient, TeppNotAvailable
+from lineageweave.topic_lineage_artifact import (
+ TOPIC_LINEAGE_MODEL_CONTRACT_VERSION,
+ TOPIC_LINEAGE_OUTPUT_PROFILE,
+ TopicLineageUnavailable,
+ parse_topic_lineage_envelope,
+)
_LINEAGE_KIND = "analysis_run_lineage"
_TEPP_KIND = "analysis_run_tepp"
_REPORT_KIND = "analysis_run_report"
+_TOPIC_LINEAGE_KIND = "analysis_run_topic_lineage"
_PENDING = "analysis_status_pending"
_RUNNING = "analysis_status_running"
_SUCCEEDED = "analysis_status_succeeded"
@@ -69,11 +76,11 @@ def reconstruction_result_digest(edges: list[Edge]) -> str:
def start_kind_rejection(run_kind_code: str) -> AnalysisRunStartError | None:
"""Return a 422 when start cannot run this kind.
- Lineage reconstructs the frozen bag. TEPP submits through
- ``tepp_client`` and never invents a theta. Period-report stays on
- its own rebuild path.
+ Lineage reconstructs the frozen bag. TEPP and topic-lineage submit
+ through ``tepp_client`` and never invent a theta or a topic (ADR 0022 /
+ ADR 0147). Period-report stays on its own rebuild path.
"""
- if run_kind_code in {_LINEAGE_KIND, _TEPP_KIND}:
+ if run_kind_code in {_LINEAGE_KIND, _TEPP_KIND, _TOPIC_LINEAGE_KIND}:
return None
if run_kind_code == _REPORT_KIND:
return AnalysisRunStartError(
@@ -130,6 +137,34 @@ def tepp_run_request(
)
+def topic_lineage_run_request(
+ *,
+ idempotency_key: str,
+ snapshot_sha256: str,
+ knowledge_cutoff: datetime,
+ corporate_entity_id: str,
+) -> AnalysisRunRequest:
+ """Build TEPP's published request for a topic-lineage run (ADR 0147).
+
+ Same wire shape as :func:`tepp_run_request` -- TEPP's
+ ``AnalysisRunRequest`` already carries no post body or fabricated
+ label -- only the model contract and output profile differ, selecting
+ the bounded TRSL topic-lineage artifact instead of calibrated
+ psychometric measurement.
+ """
+ cutoff = knowledge_cutoff
+ if cutoff.tzinfo is None:
+ cutoff = cutoff.replace(tzinfo=UTC)
+ return AnalysisRunRequest(
+ idempotency_key=idempotency_key,
+ tenant_workspace_id=str(corporate_entity_id),
+ snapshot_id=snapshot_sha256,
+ knowledge_cutoff=cutoff.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
+ model_contract_version=TOPIC_LINEAGE_MODEL_CONTRACT_VERSION,
+ output_profile=TOPIC_LINEAGE_OUTPUT_PROFILE,
+ )
+
+
def _tepp_submission(
client: TeppClient,
request: AnalysisRunRequest,
@@ -165,6 +200,37 @@ def tepp_submit_outcome(
return status_code, failure_code
+def _topic_lineage_submission(
+ client: TeppClient,
+ request: AnalysisRunRequest,
+) -> tuple[str, str, dict[str, Any] | None]:
+ """Require TEPP's exact digest-bound topic-lineage artifact."""
+
+ try:
+ response = client.submit_analysis_run(request)
+ except TeppNotAvailable:
+ return _FAILED, "tepp_not_available", None
+ try:
+ parse_topic_lineage_envelope(
+ response,
+ expected_snapshot_id=request.snapshot_id,
+ expected_knowledge_cutoff=request.knowledge_cutoff,
+ )
+ except TopicLineageUnavailable:
+ return _FAILED, "tepp_topic_contract_unavailable", None
+ return _SUCCEEDED, "", response
+
+
+def topic_lineage_submit_outcome(
+ client: TeppClient,
+ request: AnalysisRunRequest,
+) -> tuple[str, str]:
+ """Return the strict topic-lineage submission outcome."""
+
+ status_code, failure_code, _ = _topic_lineage_submission(client, request)
+ return status_code, failure_code
+
+
async def _persist_tepp_result(
conn: asyncpg.Connection,
*,
@@ -196,6 +262,40 @@ async def _persist_tepp_result(
return True
+async def _persist_topic_lineage_result(
+ conn: asyncpg.Connection,
+ *,
+ analysis_run_id: str,
+ envelope: dict[str, Any],
+) -> bool:
+ """Persist only TEPP's validated artifact envelope (ADR 0147)."""
+
+ try:
+ artifact = parse_topic_lineage_envelope(envelope)
+ except TopicLineageUnavailable:
+ return False
+ remote_run_id = artifact["run_id"]
+ result_json = json.dumps(envelope, separators=(",", ":"), sort_keys=True)
+ result_sha256 = hashlib.sha256(result_json.encode("utf-8")).hexdigest()
+ try:
+ async with conn.transaction():
+ await conn.execute(
+ """
+ insert into analysis_run_topic_lineage_result
+ (analysis_run_id, remote_run_id, result_json, result_sha256)
+ values ($1, $2, $3::jsonb, $4)
+ on conflict (analysis_run_id) do nothing
+ """,
+ analysis_run_id,
+ remote_run_id,
+ result_json,
+ result_sha256,
+ )
+ except (asyncpg.PostgresError, TypeError, ValueError):
+ return False
+ return True
+
+
def start_write_conflict_error() -> AnalysisRunStartError:
"""Next action when a concurrent start already wrote this run."""
return AnalysisRunStartError(
@@ -628,6 +728,13 @@ async def deliver_queued_analysis_run(
locked=outbox,
tepp_client=tepp_client or TeppClient(),
)
+ elif outbox["work_kind_code"] == _TOPIC_LINEAGE_KIND:
+ await _deliver_topic_lineage_measurement(
+ conn,
+ analysis_run_id=analysis_run_id,
+ locked=outbox,
+ tepp_client=tepp_client or TeppClient(),
+ )
else:
await _deliver_lineage_reconstruction(
conn,
@@ -782,3 +889,45 @@ async def _deliver_tepp_measurement(
finished,
failure_code,
)
+
+
+async def _deliver_topic_lineage_measurement(
+ conn: asyncpg.Connection,
+ *,
+ analysis_run_id: str,
+ locked: asyncpg.Record,
+ tepp_client: TeppClient,
+) -> None:
+ """Submit the frozen snapshot through ``tepp_client`` for topic-lineage.
+
+ Mirrors :func:`_deliver_tepp_measurement` (ADR 0022) with the
+ topic-lineage model contract (ADR 0147). Never persists a locally
+ computed topic identity or substitutes evidence-DAG counts.
+ """
+ now = datetime.now(UTC)
+ request = topic_lineage_run_request(
+ idempotency_key=str(locked["idempotency_key"]),
+ snapshot_sha256=str(locked["snapshot_sha256"]),
+ knowledge_cutoff=locked["knowledge_cutoff"],
+ corporate_entity_id=str(locked["corporate_entity_id"]),
+ )
+ status_code, failure_code, envelope = _topic_lineage_submission(tepp_client, request)
+ if status_code == _SUCCEEDED and envelope is not None:
+ if not await _persist_topic_lineage_result(
+ conn,
+ analysis_run_id=analysis_run_id,
+ envelope=envelope,
+ ):
+ status_code = _FAILED
+ failure_code = "tepp_result_not_persisted"
+ finished = datetime.now(UTC)
+ if finished < now:
+ finished = now
+ await _append_status(
+ conn,
+ analysis_run_id,
+ await _next_status_ordinal(conn, analysis_run_id),
+ status_code,
+ finished,
+ failure_code,
+ )
diff --git a/backend/app/project_history.py b/backend/app/project_history.py
index 1ac74422d..e7920cb2a 100644
--- a/backend/app/project_history.py
+++ b/backend/app/project_history.py
@@ -2,6 +2,8 @@
from __future__ import annotations
+import hashlib
+import json
from collections.abc import Mapping, Sequence
from datetime import datetime
from typing import Any, Protocol
@@ -15,6 +17,11 @@
build_project_history_projection,
normalize_project_key,
)
+from lineageweave.topic_lineage_artifact import (
+ TopicLineageUnavailable,
+ parse_topic_lineage_envelope,
+ project_topic_lineage_projection,
+)
PROJECT_HISTORY_DEFAULT_LIMIT = 64
PROJECT_HISTORY_MAXIMUM_LIMIT = 128
@@ -131,7 +138,7 @@ async def fetch(self, query: str, *args: object) -> Sequence[Mapping[str, Any]]:
'semantic_project_key'::text,
{_MENTION_KEY} as identity_key,
{_MENTION_KEY} as matched_value,
- mention.confidence,
+ mention.mention_confidence,
mention.ontology_iri,
'post_project_mention.project_key'::text
from post_project_mention mention
@@ -143,7 +150,7 @@ async def fetch(self, query: str, *args: object) -> Sequence[Mapping[str, Any]]:
'semantic_project_name'::text,
coalesce({_MENTION_KEY}, {_MENTION_NAME}) as identity_key,
{_MENTION_NAME} as matched_value,
- mention.confidence,
+ mention.mention_confidence,
mention.ontology_iri,
'post_project_mention.project_name'::text
from post_project_mention mention
@@ -178,7 +185,7 @@ async def fetch(self, query: str, *args: object) -> Sequence[Mapping[str, Any]]:
union all
select role.post_id,
role.actor_name,
- role.responsibility,
+ role.responsibility_text as responsibility,
role.actor_type_code,
role.affiliated_organization_name,
role.cataloged_person_id,
@@ -197,6 +204,26 @@ async def fetch(self, query: str, *args: object) -> Sequence[Mapping[str, Any]]:
and edge.child_post_id = any($1::uuid[])
order by edge.child_post_id, edge.parent_post_id
"""
+_TOPIC_LINEAGE_SQL = """
+select distinct on (scope.corporate_entity_id)
+ result.result_json,
+ result.result_sha256,
+ result.remote_run_id,
+ snapshot.snapshot_sha256,
+ run.knowledge_cutoff
+ from analysis_run_topic_lineage_result result
+ join analysis_run run on run.analysis_run_id = result.analysis_run_id
+ join analysis_run_scope scope on scope.analysis_run_id = run.analysis_run_id
+ join analysis_source_snapshot snapshot
+ on snapshot.analysis_source_snapshot_id = run.analysis_source_snapshot_id
+ join analysis_run_current_status status
+ on status.analysis_run_id = run.analysis_run_id
+ where run.run_kind_code = 'analysis_run_topic_lineage'
+ and status.status_code = 'analysis_status_succeeded'
+ and scope.corporate_entity_id::text = any($1::text[])
+ and run.knowledge_cutoff <= $2
+ order by scope.corporate_entity_id, run.requested_at desc, run.analysis_run_id desc
+"""
_INDEX_SQL = f"""
with query_timeout as materialized (
select set_config(
@@ -408,6 +435,12 @@ async def fetch_project_history_projection(
visible_ids=visible_ids,
normalized_key=normalized_key,
)
+ topic_lineage = await _fetch_topic_lineage_projection(
+ conn,
+ visible_ids=visible_ids,
+ corporate_entity_ids=corporate_entity_ids,
+ knowledge_cutoff=knowledge_cutoff,
+ )
projection = build_project_history_projection(
project_key=project_key,
focus_event_id=canonical_focus_id,
@@ -415,6 +448,7 @@ async def fetch_project_history_projection(
match_rows=match_rows,
role_rows=role_rows,
edge_rows=edge_rows,
+ topic_lineage=topic_lineage,
truncated=truncated,
)
projection["knowledge_cutoff"] = _as_utc(knowledge_cutoff)
@@ -434,3 +468,40 @@ async def _fetch_project_children(
roles = list(await conn.fetch(_ROLE_SQL, list(visible_ids)))
edges = list(await conn.fetch(_EDGE_SQL, list(visible_ids)))
return matches, roles, edges
+
+
+async def _fetch_topic_lineage_projection(
+ conn: ProjectHistoryConnection,
+ *,
+ visible_ids: Sequence[str],
+ corporate_entity_ids: Sequence[str],
+ knowledge_cutoff: datetime,
+) -> dict[str, Any]:
+ """Project only intact TEPP artifacts onto already-authorized posts."""
+
+ rows = await conn.fetch(
+ _TOPIC_LINEAGE_SQL,
+ list(corporate_entity_ids),
+ knowledge_cutoff,
+ )
+ artifacts: list[Mapping[str, Any]] = []
+ for row in rows:
+ envelope = row["result_json"]
+ try:
+ decoded = json.loads(envelope) if isinstance(envelope, str) else envelope
+ stored = json.dumps(decoded, separators=(",", ":"), sort_keys=True)
+ except (json.JSONDecodeError, TypeError, ValueError):
+ continue
+ if hashlib.sha256(stored.encode("utf-8")).hexdigest() != row["result_sha256"]:
+ continue
+ try:
+ artifact = parse_topic_lineage_envelope(
+ envelope,
+ expected_snapshot_id=str(row["snapshot_sha256"]),
+ expected_knowledge_cutoff=_as_utc(row["knowledge_cutoff"]),
+ expected_remote_run_id=str(row["remote_run_id"]),
+ )
+ except TopicLineageUnavailable:
+ continue
+ artifacts.append(artifact)
+ return project_topic_lineage_projection(artifacts, visible_ids)
diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py
index e68e51103..e333d12f0 100644
--- a/backend/tests/test_api.py
+++ b/backend/tests/test_api.py
@@ -138,6 +138,16 @@
/ "migrations"
/ "0104_two_word_database_identifiers.sql"
)
+_TOPIC_LINEAGE_KIND_MIGRATION = (
+ Path(__file__).resolve().parents[2]
+ / "migrations"
+ / "0131_analysis_run_topic_lineage_kind.sql"
+)
+_TOPIC_LINEAGE_RESULT_MIGRATION = (
+ Path(__file__).resolve().parents[2]
+ / "migrations"
+ / "0132_analysis_run_topic_lineage_result.sql"
+)
def _postgres_available() -> bool:
@@ -262,6 +272,8 @@ def seeded_db(demo_analyst_token):
cur.execute(_PROJECT_BOUND_EVENT_MIGRATION.read_text())
cur.execute(_TENANT_SETTINGS_MIGRATION.read_text())
cur.execute(_IDENTIFIER_MIGRATION.read_text())
+ cur.execute(_TOPIC_LINEAGE_KIND_MIGRATION.read_text())
+ cur.execute(_TOPIC_LINEAGE_RESULT_MIGRATION.read_text())
cur.execute(
"insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values "
"('corporate_entity_level', 'group', 'Group'), "
diff --git a/docker/postgres-init/migrate.sh b/docker/postgres-init/migrate.sh
index 712107b27..e4961aea5 100644
--- a/docker/postgres-init/migrate.sh
+++ b/docker/postgres-init/migrate.sh
@@ -19,7 +19,7 @@ for migration in /opt/lineageweave/migrations/*.sql; do
case "$migration_name" in
0012_*|0013_*|0014_*|0015_*|0016_*|0017_*|0018_*|0019_*|0020_*|0021_*|0022_*|0023_*|0024_*|0025_*|0026_*|0027_*|0028_*|0029_*|0030_*|0031_*|0032_*|0033_*|0034_*|0035_*|0036_*|0037_*|0038_*|0039_*|0040_*|0041_*|0042_*|0043_*|0044_*|0045_*|0046_*|0047_*|0048_*|0049_*|0050_*) ;;
0051_*|0052_*|0053_*|0054_*) ;;
- 0060_*|0100_*|0101_*|0102_*) ;;
+ 0060_*|0100_*|0101_*|0102_*|0103_*|0104_*|0105_*|0106_*|0107_*|0108_*|0109_*|0110_*|0111_*|0112_*|0113_*|0114_*|0130_*|0131_*|0132_*) ;;
*) continue ;;
esac
printf 'Applying %s\n' "$migration_name"
diff --git a/docs/adr/0147-tepp-topic-lineage-project-history.md b/docs/adr/0147-tepp-topic-lineage-project-history.md
new file mode 100644
index 000000000..8fbe26a60
--- /dev/null
+++ b/docs/adr/0147-tepp-topic-lineage-project-history.md
@@ -0,0 +1,83 @@
+# ADR 0147 — TEPP topic-lineage evidence in Project History
+
+**Decision status:** Accepted on this active product branch; not protected-main truth
+**Implementation maturity:** active-PR
+**Date:** 2026-08-23
+**Depends on:** ADR 0022, ADR 0084, ADR 0113, ADR 0127, ADR 0136, and TEPP ADR 0012
+**Figma File ID:** `SBpgot7uTvMxEaxUwvoc0S`
+**Figma frames:** `308:2`, `309:2`, `309:50`, `310:2`
+
+## Context
+
+Project History already exposes one authorized, cutoff-safe timeline from a
+post, post-scoped Ask, and Global Ask. Its `connected_post_count` and
+`lineage_count` currently describe weak components in LineageWeave's fused
+evidence DAG. Those values are useful navigation evidence, but they are not
+topic-model lineages and cannot satisfy the TEPP TRSL-TM requirement.
+
+TEPP now defines a bounded CPU `f64` reference estimator and the completed
+`tepp.trsl_topic_lineage.v1` artifact. The artifact carries explicit fitted
+predecessor/successor associations, artifact-local topic indices,
+connectable-post and lineage counts, snapshot/cutoff binding, and the fixed
+claim boundary `fitted_topic_association_not_causation`.
+
+## Decision
+
+1. LineageWeave requests topic lineage with TEPP model contract
+ `trsl_tm_cpu_f64_v1` and output profile `trsl_topic_lineage_v1`. It does not
+ fit, select, rename, or repair topics locally.
+2. A completed transport response is accepted only when its `result` is an
+ exact, bounded `tepp.trsl_topic_lineage.v1` object. Identifiers, RFC 3339
+ cutoff, finite values, edge endpoints, topic indices, duplicate edges,
+ top-level counts, and the non-causal inference status are revalidated before
+ persistence and again before display.
+3. Project History filters validated TEPP sequence edges to the already
+ authorized, cutoff-safe project post IDs. It recomputes the displayed
+ connectable-post count and lineage count from those filtered edges. Topic
+ identity is the pair `(TEPP run id, artifact-local topic index)` so indices
+ from different runs cannot collapse.
+4. The existing fused `post_lineage_edge` remains the source for navigable
+ prior-history paths. It does not supply fallback topic counts. When no
+ validated, authorized artifact contributes an edge, topic counts are
+ unavailable rather than zero or substituted with DAG components.
+5. The shared `ProjectHistoryTimeline` remains the only reader component for
+ dedicated Project History, each post, post-scoped Ask, and Global Ask. It
+ labels validated numbers as fitted topic association, never causation, and
+ renders an explicit unavailable state otherwise. No second timeline or DAG
+ overlay is introduced in this increment.
+6. CHRONOS/TDT prediction status, topic birth/split/merge, accelerated backend
+ parity, production `K` selection, and causal claims remain outside this
+ artifact and require their own upstream contract and ADR change.
+
+## Consequences
+
+The count shown beside a project history has one scientific source and one
+authorization boundary. A missing TEPP transport, accepted-only receipt,
+non-converged estimator, invalid artifact, stale cutoff, or unrelated snapshot
+cannot silently become a topic result. Existing temporal history and source
+navigation remain readable while topic evidence is unavailable.
+
+## Verification
+
+- strict artifact parser round-trip, tamper, size, count, and scope tests;
+- analysis-run request and completed-envelope failure tests;
+- PostgreSQL projection tests proving unauthorized and out-of-project endpoints
+ do not affect counts;
+- shared React component tests and Storybook scenes for validated and
+ unavailable states at desktop and phone widths;
+- backend and frontend full checks, then exact-head hosted checks and an
+ independent review before protected merge.
+
+## References
+
+Blei, D. M., & Lafferty, J. D. (2006). Dynamic topic models. In *Proceedings
+of the 23rd International Conference on Machine Learning* (pp. 113–120).
+Association for Computing Machinery. https://doi.org/10.1145/1143844.1143859
+
+Roberts, M. E., Stewart, B. M., & Tingley, D. (2019). stm: An R package for
+structural topic models. *Journal of Statistical Software, 91*(2), 1–40.
+https://doi.org/10.18637/jss.v091.i02
+
+ContextualWisdomLab. (2026). *ADR 0012: Temporal relational shared-latent
+topic measurement* [ADR].
+https://github.com/ContextualWisdomLab/TEPP/blob/main/docs/adr/0012-temporal-relational-shared-latent-topic-measurement.md
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index 6cbc26312..bb76f4466 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -129,6 +129,7 @@ claims that an unmerged PR or historical runtime observation is live behavior.
| FR-12 | A hierarchy-enrichment timeout leaves the source-grounded summary readable and the actor unbound; it never creates a guessed catalog identity. | ADR 0101, ADR 0010, ADR 0026 | Commit `1c260f20` contains the boundary, ADR, and focused test; independent review, protected-main merge, and fresh runtime evidence remain pending |
| FR-13 | Customer Master projects authorized corporate entities as a Group → Company → Plant tree. Real organization containment uses W3C ORG while Group/Company/Plant remain separate SKOS level concepts. Missing-parent, self-parent, and cyclic edges remain visible as unresolved roots; the UI owns nested `group` elements from their parent `treeitem`, supports Arrow/Home/End and Enter/Space operation, and opens source-backed evidence outside the tree. | ADR 0124, ADR 0004, ADR 0010 | Ontology/SHACL interoperability tests, `customerMasterTree.ts`, `CustomerMasterTree.tsx`, component tests, Storybook, and code commit `21074cf80cbfab3001bf18b6e1a618f75f4bed24` |
| FR-14 | Global Ask presents a dedicated evidence workspace: semantic form submission, IME-safe keyboard behavior, explicit empty/loading/error/answer states, separated timeline and cited evidence, answer focus, responsive phone/tablet/PC layout, and the existing authorized cited-post → Event Lineage handoff. | ADR 0137, ADR 0002, ADR 0032, ADR 0090 | `AskAgentWorkspace.tsx`, focused component/token tests, Storybook state inventory, and existing App navigation regressions on #353 |
+| FR-15 | Project History reports connectable posts and topic lineages only from a validated TEPP `tepp.trsl_topic_lineage.v1` artifact filtered to the already authorized project post set. Missing or invalid topic evidence is unavailable, never replaced by evidence-DAG components. | ADR 0147, TEPP ADR 0012 | Active product branch: TEPP reference estimator/artifact execution plus LineageWeave strict parser, analysis-run persistence, shared Project History projection, component tests, and Storybook states; protected merge and authenticated runtime evidence remain open |
## TRD
@@ -333,20 +334,21 @@ runtime note into a shipped/live claim.
## Project-history lineage counts (2026-08-23)
-- The shared timeline now distinguishes displayed authorized project posts from
- posts incident to a persisted forward `post_lineage_edge`, and reports the
- number of weakly connected evidence-lineage components in that bounded set.
-- Isolated project posts remain visible but do not inflate the lineage count.
- These counts describe LineageWeave's evidence-fusion DAG; they are not TEPP
- topic identities and do not close the TRSL-TM topic-lineage dependency.
+- The shared timeline reports connectable posts and topic lineages only from
+ digest-, snapshot-, cutoff-, and schema-validated TEPP
+ `tepp.trsl_topic_lineage.v1` predecessor/successor edges whose endpoints are
+ already in the authorized project post set.
+- Missing, invalid, stale, or out-of-project artifacts render topic counts as
+ unavailable. `post_lineage_edge` still supplies prior-history navigation but
+ cannot substitute weak-component counts for TEPP topic identity.
- The same projection supplies the dedicated Project history destination, each
post's project-evidence action, post-scoped Ask, and Global Ask, so those
surfaces cannot drift into competing count definitions.
-- Stacked delivery evidence is PR #487 on PR #258. Local evidence is the full
- 993-test backend suite, focused Project History UI tests, the complete App test
- file, lint, production build, Storybook build, and rendered-story inspection.
- Protected merge, authenticated runtime, and the TEPP topic-identity result
- contract remain open and must not be represented as protected-main behavior.
+- Active-branch evidence includes exact 100% statement/branch coverage for the
+ strict artifact consumer and PostgreSQL projection, the real Project History
+ API integration path, complete frontend tests/lint/build, and Storybook build.
+ Protected merge and authenticated rendered-runtime inspection remain open and
+ must not be represented as protected-main behavior.
## Ask-to-project-history integration (2026-08-21)
diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md
index caa724ded..14da86bf0 100644
--- a/docs/storybook-inventory.md
+++ b/docs/storybook-inventory.md
@@ -9,7 +9,7 @@ reader-facing control you can click before changing product CSS.
| `Evidence/CitationChip` | Click a cited title to open that source post. | `--color-chip-border`, `--radius-chip`, `CitationChip` |
| `AnalysisRun/CutoffKnownBody` | Read the cutoff-known sentence, then compare it with the live body below. | `--color-accent-border`, `--space-panel-block`, `--radius-panel`, `CutoffKnownBody` |
| `Analysis/LineageEntityPicker` | Choose which corp to reconstruct, then click Request a lineage reconstruction. | `--space-control-gap`, `--size-control-min`, `--radius-control`, `LineageEntityPicker` |
-| `Evidence/ProjectHistoryTimeline` | Compare all project posts with connected-post and evidence-lineage counts, then open the selected source. | shared spacing, status, focus, and timeline tokens; `ProjectHistoryTimeline` |
+| `Buyer/Project History Timeline` | Compare authorized project posts with validated TEPP topic-lineage counts or the explicit unavailable state, then open the selected source. | shared spacing, status, focus, and timeline tokens; `ProjectHistoryTimeline` |
| `Chrome/PopupCloseButton` | Close the evidence panel or post popup. | `--space-close-inset`, `--font-size-close`, `PopupCloseButton` |
| `Evidence/LineageDag` | Inspect a branching Event Lineage, then open a record or read its evidence trail. | `--color-primary`, `--color-accent-orange`, `LineageDag` |
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index d8a5d7693..294109141 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -2565,6 +2565,8 @@ function analysisRunNextAction(run: AnalysisRun): string | null {
return "Open this run, then start reconstruction. Reconstruction has not started yet.";
case "analysis_run_tepp":
return "Open this run to confirm which posts TEPP will measure. Measurement has not started yet — this is not a calibrated result.";
+ case "analysis_run_topic_lineage":
+ return "Open this run to confirm which posts TEPP will thread into topic lineage. Topic-lineage analysis has not started yet — this is not a calibrated topic result.";
case "analysis_run_report":
return "Open this run to confirm which posts the period report will use. The report has not been built yet.";
default: {
@@ -2576,6 +2578,8 @@ function analysisRunNextAction(run: AnalysisRun): string | null {
switch (run.run_kind_code) {
case "analysis_run_tepp":
return "Open this run to see why it failed, then connect the measurement service and re-run.";
+ case "analysis_run_topic_lineage":
+ return "Open this run to see why it failed, then connect the topic-lineage service and re-run.";
case "analysis_run_lineage":
return "Open this run to see why it failed, then retry reconstruction from a current snapshot.";
case "analysis_run_report":
@@ -2608,6 +2612,11 @@ function analysisRunEmptyPostsHint(run: AnalysisRun): string {
"No posts were available at this cutoff for TEPP to measure. " +
"Open a later run, or ask an administrator to capture a newer snapshot."
);
+ case "analysis_run_topic_lineage":
+ return (
+ "No posts were available at this cutoff for topic-lineage analysis. " +
+ "Open a later run, or ask an administrator to capture a newer snapshot."
+ );
case "analysis_run_lineage":
return (
"No posts were available at this cutoff for reconstruction. " +
@@ -2626,31 +2635,35 @@ function analysisRunEmptyPostsHint(run: AnalysisRun): string {
}
/**
- * Corpus copy for a TEPP run that already has cutoff posts.
+ * Corpus copy for a TEPP or topic-lineage run that already has cutoff posts.
*
* Those titles are the measurement bag, not a reconstruction result.
- * Pending or running must not claim a calibrated measurement.
+ * Pending or running must not claim a calibrated measurement or topic.
*/
function analysisRunCorpusHint(run: AnalysisRun): string | null {
- if (run.run_kind_code !== "analysis_run_tepp") return null;
+ const isTopicLineage = run.run_kind_code === "analysis_run_topic_lineage";
+ if (run.run_kind_code !== "analysis_run_tepp" && !isTopicLineage) return null;
+ const service = isTopicLineage ? "topic-lineage" : "TEPP";
+ const verb = isTopicLineage ? "thread" : "measure";
+ const verbPast = isTopicLineage ? "threaded" : "measured";
switch (run.status_code) {
case "analysis_status_failed":
return (
- "These posts are the cutoff corpus TEPP would measure. Connect a TEPP " +
+ `These posts are the cutoff corpus ${service} would ${verb}. Connect a ${service} ` +
"transport, then re-run, to replace Failed with a calibrated result."
);
case "analysis_status_succeeded":
- return "These posts are the cutoff corpus this TEPP run measured.";
+ return `These posts are the cutoff corpus this ${service} run ${verbPast}.`;
case "analysis_status_pending":
case "analysis_status_running":
- return "These posts are the cutoff corpus TEPP will measure once this run finishes.";
+ return `These posts are the cutoff corpus ${service} will ${verb} once this run finishes.`;
case "analysis_status_cancelled":
return (
- "These posts are the cutoff corpus this TEPP run would have measured. " +
+ `These posts are the cutoff corpus this ${service} run would have ${verbPast}. ` +
"The run was cancelled before a calibrated result."
);
case null:
- return "These posts are the cutoff corpus attached to this TEPP run.";
+ return `These posts are the cutoff corpus attached to this ${service} run.`;
default: {
const unexpected: never = run.status_code;
return unexpected;
@@ -2767,21 +2780,31 @@ function AnalysisRunReproducibilityDigests({
*/
function analysisRunCanStart(run: AnalysisRun): boolean {
return (
- (run.run_kind_code === "analysis_run_lineage" || run.run_kind_code === "analysis_run_tepp") &&
+ (run.run_kind_code === "analysis_run_lineage" ||
+ run.run_kind_code === "analysis_run_tepp" ||
+ run.run_kind_code === "analysis_run_topic_lineage") &&
(run.status_code === "analysis_status_pending" ||
run.status_code === "analysis_status_running")
);
}
function analysisRunStartLabel(run: AnalysisRun): string {
- return run.run_kind_code === "analysis_run_tepp"
- ? "Start TEPP measurement"
- : "Start reconstruction";
+ if (run.run_kind_code === "analysis_run_tepp") {
+ return "Start TEPP measurement";
+ }
+ if (run.run_kind_code === "analysis_run_topic_lineage") {
+ return "Start topic lineage";
+ }
+ return "Start reconstruction";
}
-/** Failed TEPP is terminal. Create cannot invent a Pending TEPP row. */
+/** Failed TEPP/topic-lineage is terminal. Create cannot invent a Pending row. */
function analysisRunCanRequestTeppRetry(run: AnalysisRun): boolean {
- return run.run_kind_code === "analysis_run_tepp" && run.status_code === "analysis_status_failed";
+ return (
+ (run.run_kind_code === "analysis_run_tepp" ||
+ run.run_kind_code === "analysis_run_topic_lineage") &&
+ run.status_code === "analysis_status_failed"
+ );
}
const REPORT_PERIOD_KEY = /^\d{4}-W\d{2}$/;
@@ -3063,14 +3086,19 @@ function AnalysisRunsPanel({
{starting
? selected.run_kind_code === "analysis_run_tepp"
? "Submitting the TEPP request..."
- : "Reconstructing the cutoff bag..."
+ : selected.run_kind_code === "analysis_run_topic_lineage"
+ ? "Submitting the topic-lineage request..."
+ : "Reconstructing the cutoff bag..."
: analysisRunStartLabel(selected)}
)}
{analysisRunCanRequestTeppRetry(selected) && (
- Connect a TEPP transport from this Failed row. Request a lineage
- reconstruction does not invent a measurement.
+ {selected.run_kind_code === "analysis_run_topic_lineage"
+ ? "Connect a topic-lineage transport from this Failed row. Request a " +
+ "lineage reconstruction does not invent a topic model."
+ : "Connect a TEPP transport from this Failed row. Request a lineage " +
+ "reconstruction does not invent a measurement."}
)}
{analysisRunReportPeriod(selected) && onSelectReportPeriod && (
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index 0f88d0ff2..33056672e 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -1029,11 +1029,12 @@ export interface AnalysisRunCount {
count_value: number;
}
-/** Registry kinds from `analysis_run.run_kind_code` (migration 0018). */
+/** Registry kinds from `analysis_run.run_kind_code` (migration 0018, extended 0131). */
export type AnalysisRunKindCode =
| "analysis_run_lineage"
| "analysis_run_report"
- | "analysis_run_tepp";
+ | "analysis_run_tepp"
+ | "analysis_run_topic_lineage";
/** Registry statuses from `analysis_run_status_event.status_code`. */
export type AnalysisRunStatusCode =
diff --git a/frontend/src/components/AskProjectHistoryLinks.test.tsx b/frontend/src/components/AskProjectHistoryLinks.test.tsx
index c5973a316..4e3a91f14 100644
--- a/frontend/src/components/AskProjectHistoryLinks.test.tsx
+++ b/frontend/src/components/AskProjectHistoryLinks.test.tsx
@@ -23,8 +23,8 @@ const projection: ProjectHistoryProjection = {
knowledge_cutoff: "2026-08-20T12:00:00Z",
evidence_boundary_code: "authorized_visible_source_posts",
event_count: 1,
- connected_post_count: 0,
- lineage_count: 0,
+ connected_post_count: null,
+ lineage_count: null,
distinct_actor_count: 0,
distinct_observed_actor_count: 0,
truncated: false,
diff --git a/frontend/src/components/ProjectHistoryTimeline.stories.tsx b/frontend/src/components/ProjectHistoryTimeline.stories.tsx
index 8f33fc14a..90a26aee0 100644
--- a/frontend/src/components/ProjectHistoryTimeline.stories.tsx
+++ b/frontend/src/components/ProjectHistoryTimeline.stories.tsx
@@ -62,6 +62,15 @@ const projection: ProjectHistoryProjection = {
event_count: 5,
connected_post_count: 5,
lineage_count: 1,
+ topic_lineage: {
+ status: "validated",
+ schema_version: "tepp.trsl_topic_lineage.v1",
+ inference_status: "fitted_topic_association_not_causation",
+ artifact_count: 1,
+ connected_post_count: 5,
+ lineage_count: 1,
+ sequence_edges: [],
+ },
distinct_actor_count: 3,
distinct_observed_actor_count: 2,
truncated: false,
@@ -150,3 +159,22 @@ export const ResponsibilityEvidenceGap: Story = {
},
},
};
+
+export const TopicLineageUnavailable: Story = {
+ args: {
+ projection: {
+ ...projection,
+ connected_post_count: null,
+ lineage_count: null,
+ topic_lineage: {
+ status: "unavailable",
+ schema_version: null,
+ inference_status: null,
+ artifact_count: 0,
+ connected_post_count: null,
+ lineage_count: null,
+ sequence_edges: [],
+ },
+ },
+ },
+};
diff --git a/frontend/src/components/ProjectHistoryTimeline.tepp.test.tsx b/frontend/src/components/ProjectHistoryTimeline.tepp.test.tsx
index 0a9fdaef3..5f95c90e4 100644
--- a/frontend/src/components/ProjectHistoryTimeline.tepp.test.tsx
+++ b/frontend/src/components/ProjectHistoryTimeline.tepp.test.tsx
@@ -14,8 +14,17 @@ const projection = {
knowledge_cutoff: "2026-08-20T12:00:00Z",
evidence_boundary_code: "authorized_visible_source_posts",
event_count: 1,
- connected_post_count: 0,
- lineage_count: 0,
+ connected_post_count: null,
+ lineage_count: null,
+ topic_lineage: {
+ status: "unavailable",
+ schema_version: null,
+ inference_status: null,
+ artifact_count: 0,
+ connected_post_count: null,
+ lineage_count: null,
+ sequence_edges: [],
+ },
distinct_actor_count: 0,
distinct_observed_actor_count: 0,
truncated: false,
@@ -64,6 +73,7 @@ describe("ProjectHistoryTimeline TEPP integration", () => {
expect(screen.getByRole("heading", { name: /TEPP temporal validation/i })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: /Project event timeline/i })).toBeInTheDocument();
+ expect(screen.getByText(/TEPP topic-lineage counts unavailable/i)).toBeInTheDocument();
expect(screen.getAllByRole("tab")).toHaveLength(1);
});
});
diff --git a/frontend/src/components/ProjectHistoryTimeline.test.tsx b/frontend/src/components/ProjectHistoryTimeline.test.tsx
index 3ec742bb5..fc32b8d2b 100644
--- a/frontend/src/components/ProjectHistoryTimeline.test.tsx
+++ b/frontend/src/components/ProjectHistoryTimeline.test.tsx
@@ -16,6 +16,15 @@ const projection: ProjectHistoryProjection = {
event_count: 3,
connected_post_count: 3,
lineage_count: 1,
+ topic_lineage: {
+ status: "validated",
+ schema_version: "tepp.trsl_topic_lineage.v1",
+ inference_status: "fitted_topic_association_not_causation",
+ artifact_count: 1,
+ connected_post_count: 3,
+ lineage_count: 1,
+ sequence_edges: [],
+ },
distinct_actor_count: 2,
distinct_observed_actor_count: 1,
truncated: false,
diff --git a/frontend/src/components/ProjectHistoryTimeline.tsx b/frontend/src/components/ProjectHistoryTimeline.tsx
index 940028103..11e3512be 100644
--- a/frontend/src/components/ProjectHistoryTimeline.tsx
+++ b/frontend/src/components/ProjectHistoryTimeline.tsx
@@ -52,6 +52,10 @@ export function ProjectHistoryTimeline({
const selectedResponsibilities =
selectedEvent?.responsibility_evidence ?? selectedEvent?.observed_responsibilities ?? [];
const actorCount = projection.distinct_actor_count ?? projection.distinct_observed_actor_count;
+ const topicCountsAvailable =
+ projection.topic_lineage?.status === "validated" &&
+ projection.connected_post_count !== null &&
+ projection.lineage_count !== null;
const selectedIndex = selectedEvent
? projection.events.findIndex((event) => event.event_id === selectedEvent.event_id)
: -1;
@@ -101,12 +105,16 @@ export function ProjectHistoryTimeline({
{projectHistoryText(locale, "heading")}
- {projectHistoryText(locale, "summaryCounts", {
- events: projection.event_count,
- connected: projection.connected_post_count,
- lineages: projection.lineage_count,
- actors: actorCount,
- })}
+ {projectHistoryText(
+ locale,
+ topicCountsAvailable ? "summaryCounts" : "summaryCountsUnavailable",
+ {
+ events: projection.event_count,
+ connected: projection.connected_post_count ?? 0,
+ lineages: projection.lineage_count ?? 0,
+ actors: actorCount,
+ },
+ )}
diff --git a/frontend/src/projectHistory.ts b/frontend/src/projectHistory.ts
index 1219baf4b..ab73645c3 100644
--- a/frontend/src/projectHistory.ts
+++ b/frontend/src/projectHistory.ts
@@ -109,8 +109,23 @@ export interface ProjectHistoryProjection {
knowledge_cutoff?: string;
evidence_boundary_code?: "authorized_visible_source_posts" | string;
event_count: number;
- connected_post_count: number;
- lineage_count: number;
+ connected_post_count: number | null;
+ lineage_count: number | null;
+ topic_lineage?: {
+ status: "validated" | "unavailable";
+ schema_version: "tepp.trsl_topic_lineage.v1" | null;
+ inference_status: "fitted_topic_association_not_causation" | null;
+ artifact_count: number;
+ connected_post_count: number | null;
+ lineage_count: number | null;
+ sequence_edges: Array<{
+ artifact_run_id: string;
+ predecessor_post_id: string;
+ successor_post_id: string;
+ topic_index: number;
+ association_strength: number;
+ }>;
+ };
distinct_actor_count?: number;
distinct_observed_actor_count: number;
truncated: boolean;
@@ -202,6 +217,7 @@ const MESSAGE_KEYS = [
"evidenceBoundary",
"heading",
"summaryCounts",
+ "summaryCountsUnavailable",
"sourcePostTime",
"documentTime",
"truncated",
@@ -256,6 +272,7 @@ const EN: Record = {
evidenceBoundary: "Only source posts that pass the current permission, visibility, publication, and cutoff gates are included.",
heading: "Project event timeline",
summaryCounts: "{events} shown project posts · {connected} connected · lineage count {lineages} · {actors} actors in evidence",
+ summaryCountsUnavailable: "{events} shown project posts · TEPP topic-lineage counts unavailable · {actors} actors in evidence",
sourcePostTime: "Dates use source-post creation time because a separate event clock is not recorded.",
documentTime: "Dates use the document time recorded by the source.",
truncated: "This bounded timeline is truncated. The selected event remains included.",
@@ -307,6 +324,7 @@ const MESSAGES: Record> = {
evidenceBoundary: "현재 권한·공개 범위·게시 상태·기준 시각을 통과한 원천 게시물만 포함합니다.",
heading: "프로젝트 이벤트 타임라인",
summaryCounts: "표시 중 프로젝트 글 {events}개 · 연결된 글 {connected}개 · Lineage {lineages}개 · 근거 담당자 {actors}명",
+ summaryCountsUnavailable: "표시 중 프로젝트 글 {events}개 · TEPP Topic Lineage 수치 이용 불가 · 근거 담당자 {actors}명",
sourcePostTime: "별도 사건 시각이 없어 날짜는 원천 게시물 생성 시각을 사용합니다.",
documentTime: "날짜는 기록된 문서 시각을 사용합니다.",
truncated: "이 제한된 타임라인은 일부만 표시합니다. 선택한 이벤트는 계속 포함됩니다.",
@@ -355,6 +373,7 @@ const MESSAGES: Record> = {
evidenceBoundary: "仅包含通过当前权限、可见性、发布状态和截止时间检查的源帖子。",
heading: "项目事件时间线",
summaryCounts: "当前显示 {events} 条项目记录 · {connected} 条已连接 · {lineages} 条 Lineage · 依据中出现 {actors} 名责任人",
+ summaryCountsUnavailable: "当前显示 {events} 条项目记录 · TEPP 主题谱系计数不可用 · 依据中出现 {actors} 名责任人",
sourcePostTime: "未记录独立事件时钟,因此日期采用源帖子创建时间。",
documentTime: "日期采用记录的文档时间。",
truncated: "此有界时间线已截断,但所选事件仍保留。",
@@ -403,6 +422,7 @@ const MESSAGES: Record> = {
evidenceBoundary: "現在の権限・可視性・公開状態・基準時刻を通過した原資料だけを含みます。",
heading: "プロジェクトイベントのタイムライン",
summaryCounts: "表示中のプロジェクト投稿 {events}件 · 接続済み {connected}件 · Lineage {lineages}件 · 根拠内の担当者 {actors}名",
+ summaryCountsUnavailable: "表示中のプロジェクト投稿 {events}件 · TEPP Topic Lineage 件数は利用不可 · 根拠内の担当者 {actors}名",
sourcePostTime: "独立したイベント時刻がないため、原資料の作成時刻を使用します。",
documentTime: "日付は記録された文書時刻を使用します。",
truncated: "この上限付きタイムラインは省略されていますが、選択イベントは保持されます。",
@@ -451,6 +471,7 @@ const MESSAGES: Record> = {
evidenceBoundary: "Chỉ bao gồm bài nguồn vượt qua quyền, khả năng hiển thị, trạng thái xuất bản và mốc thời gian hiện tại.",
heading: "Dòng thời gian sự kiện dự án",
summaryCounts: "Đang hiển thị {events} bài dự án · {connected} bài đã kết nối · {lineages} lineage · {actors} người trong bằng chứng",
+ summaryCountsUnavailable: "Đang hiển thị {events} bài dự án · số liệu TEPP topic-lineage không khả dụng · {actors} người trong bằng chứng",
sourcePostTime: "Không có đồng hồ sự kiện riêng, nên dùng thời gian tạo bài nguồn.",
documentTime: "Ngày sử dụng thời gian tài liệu được ghi nhận.",
truncated: "Dòng thời gian có giới hạn này đã bị rút gọn nhưng vẫn giữ sự kiện đang chọn.",
diff --git a/lineageweave/project_history.py b/lineageweave/project_history.py
index c485b585d..35bacc90a 100644
--- a/lineageweave/project_history.py
+++ b/lineageweave/project_history.py
@@ -315,6 +315,7 @@ def build_project_history_projection(
match_rows: Sequence[Mapping[str, Any]],
role_rows: Sequence[Mapping[str, Any]],
edge_rows: Sequence[Mapping[str, Any]],
+ topic_lineage: Mapping[str, Any] | None = None,
truncated: bool = False,
maximum_depth: int = PROJECT_HISTORY_MAX_DEPTH,
maximum_paths_per_event: int = PROJECT_HISTORY_MAX_PATHS_PER_EVENT,
@@ -454,7 +455,21 @@ def build_project_history_projection(
maximum_depth=maximum_depth,
maximum_paths_per_event=maximum_paths_per_event,
)
- connected_post_count, lineage_count = _lineage_counts(ordered_ids, edge_rows)
+ evidence_connected_post_count, evidence_lineage_count = _lineage_counts(
+ ordered_ids, edge_rows
+ )
+ topic_lineage = dict(
+ topic_lineage
+ or {
+ "status": "unavailable",
+ "schema_version": None,
+ "inference_status": None,
+ "artifact_count": 0,
+ "connected_post_count": None,
+ "lineage_count": None,
+ "sequence_edges": [],
+ }
+ )
events: list[dict[str, Any]] = []
previous_actor_keys: Sequence[str] | None = None
@@ -514,8 +529,11 @@ def build_project_history_projection(
"focus_event_id": effective_focus,
"time_basis_code": PROJECT_HISTORY_TIME_BASIS,
"event_count": len(events),
- "connected_post_count": connected_post_count,
- "lineage_count": lineage_count,
+ "connected_post_count": topic_lineage["connected_post_count"],
+ "lineage_count": topic_lineage["lineage_count"],
+ "topic_lineage": topic_lineage,
+ "evidence_connected_post_count": evidence_connected_post_count,
+ "evidence_lineage_count": evidence_lineage_count,
"distinct_actor_count": len(distinct_actor_keys),
"distinct_observed_actor_count": len(distinct_observed_actor_keys),
"truncated": bool(truncated),
diff --git a/lineageweave/topic_lineage_artifact.py b/lineageweave/topic_lineage_artifact.py
new file mode 100644
index 000000000..44023480c
--- /dev/null
+++ b/lineageweave/topic_lineage_artifact.py
@@ -0,0 +1,303 @@
+"""Strict LineageWeave consumer for TEPP topic-lineage artifacts."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping, Sequence
+from datetime import UTC, datetime
+import hashlib
+import json
+from typing import Any
+from uuid import UUID
+
+TOPIC_LINEAGE_ARTIFACT_SCHEMA_VERSION = "tepp.trsl_topic_lineage.v1"
+TOPIC_LINEAGE_MODEL_CONTRACT_VERSION = "trsl_tm_cpu_f64_v1"
+TOPIC_LINEAGE_OUTPUT_PROFILE = "trsl_topic_lineage_v1"
+TOPIC_LINEAGE_INFERENCE_STATUS = "fitted_topic_association_not_causation"
+TOPIC_LINEAGE_ARTIFACT_BYTE_LIMIT = 256 * 1024
+TOPIC_LINEAGE_EDGE_LIMIT = 100_000
+_U64_MAXIMUM = 2**64 - 1
+_ARTIFACT_FIELDS = frozenset(
+ {
+ "schema_version",
+ "run_id",
+ "snapshot_id",
+ "knowledge_cutoff",
+ "selected_seed",
+ "iterations",
+ "objective",
+ "topic_count",
+ "evidence_count",
+ "connected_post_count",
+ "lineage_count",
+ "sequence_edges",
+ "inference_status",
+ }
+)
+_EDGE_FIELDS = frozenset(
+ {
+ "predecessor_document_id",
+ "successor_document_id",
+ "topic_index",
+ "association_strength",
+ }
+)
+
+
+class TopicLineageUnavailable(ValueError):
+ """TEPP topic-lineage evidence was absent or violated its contract."""
+
+
+def _text(value: Any, name: str, maximum: int = 256) -> str:
+ """Return bounded non-empty text without control characters."""
+
+ if not isinstance(value, str) or value != value.strip():
+ raise TopicLineageUnavailable(f"{name} must be canonical text")
+ if (
+ not value
+ or len(value.encode("utf-8")) > maximum
+ or any(ord(character) < 0x20 or ord(character) == 0x7F for character in value)
+ ):
+ raise TopicLineageUnavailable(f"{name} is outside its bound")
+ return value
+
+
+def _u64(value: Any, name: str) -> int:
+ """Return one unsigned 64-bit integer without accepting booleans."""
+
+ if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= _U64_MAXIMUM:
+ raise TopicLineageUnavailable(f"{name} must be an unsigned 64-bit integer")
+ return value
+
+
+def _rfc3339_utc(value: Any, name: str) -> str:
+ """Return one offset-aware timestamp in canonical UTC form."""
+
+ raw = _text(value, name, 64)
+ try:
+ parsed = datetime.fromisoformat(raw[:-1] + "+00:00" if raw.endswith("Z") else raw)
+ except ValueError as exc:
+ raise TopicLineageUnavailable(f"{name} must be RFC 3339") from exc
+ if parsed.tzinfo is None or parsed.utcoffset() is None:
+ raise TopicLineageUnavailable(f"{name} must include an offset")
+ return parsed.astimezone(UTC).isoformat().replace("+00:00", "Z")
+
+
+def _uuid(value: Any, name: str) -> str:
+ """Return one lowercase canonical UUID."""
+
+ raw = _text(value, name, 36)
+ try:
+ parsed = UUID(raw)
+ except ValueError as exc:
+ raise TopicLineageUnavailable(f"{name} must be a UUID") from exc
+ if str(parsed) != raw:
+ raise TopicLineageUnavailable(f"{name} must be a canonical UUID")
+ return raw
+
+
+def _json_object(value: Any, *, maximum_bytes: int) -> Mapping[str, Any]:
+ """Decode one bounded JSON object or validate an in-memory mapping."""
+
+ if isinstance(value, str):
+ if len(value.encode("utf-8")) > maximum_bytes:
+ raise TopicLineageUnavailable("topic-lineage JSON exceeds its byte limit")
+ try:
+ value = json.loads(value)
+ except json.JSONDecodeError as exc:
+ raise TopicLineageUnavailable("topic-lineage JSON is invalid") from exc
+ try:
+ encoded = json.dumps(
+ value,
+ ensure_ascii=False,
+ separators=(",", ":"),
+ allow_nan=False,
+ ).encode("utf-8")
+ except (TypeError, ValueError) as exc:
+ raise TopicLineageUnavailable("topic-lineage value is not JSON") from exc
+ if len(encoded) > maximum_bytes:
+ raise TopicLineageUnavailable("topic-lineage JSON exceeds its byte limit")
+ if not isinstance(value, Mapping):
+ raise TopicLineageUnavailable("topic-lineage JSON must be an object")
+ return value
+
+
+def parse_topic_lineage_artifact(value: Any) -> dict[str, Any]:
+ """Validate and canonicalize one exact TEPP topic-lineage artifact."""
+
+ artifact = _json_object(value, maximum_bytes=TOPIC_LINEAGE_ARTIFACT_BYTE_LIMIT)
+ if frozenset(artifact) != _ARTIFACT_FIELDS:
+ raise TopicLineageUnavailable("topic-lineage artifact fields are invalid")
+ schema_version = _text(artifact["schema_version"], "schema_version", 64)
+ if schema_version != TOPIC_LINEAGE_ARTIFACT_SCHEMA_VERSION:
+ raise TopicLineageUnavailable("topic-lineage artifact schema is unsupported")
+ run_id = _text(artifact["run_id"], "run_id")
+ snapshot_id = _text(artifact["snapshot_id"], "snapshot_id")
+ knowledge_cutoff = _rfc3339_utc(artifact["knowledge_cutoff"], "knowledge_cutoff")
+ selected_seed = _u64(artifact["selected_seed"], "selected_seed")
+ iterations = _u64(artifact["iterations"], "iterations")
+ if iterations == 0:
+ raise TopicLineageUnavailable("iterations must be positive")
+ objective = artifact["objective"]
+ if isinstance(objective, bool) or not isinstance(objective, (int, float)):
+ raise TopicLineageUnavailable("objective must be numeric")
+ try:
+ objective = float(objective)
+ except OverflowError as exc:
+ raise TopicLineageUnavailable("objective must be finite") from exc
+ topic_count = _u64(artifact["topic_count"], "topic_count")
+ evidence_count = _u64(artifact["evidence_count"], "evidence_count")
+ connected_post_count = _u64(artifact["connected_post_count"], "connected_post_count")
+ lineage_count = _u64(artifact["lineage_count"], "lineage_count")
+ if topic_count < 2 or evidence_count < 2:
+ raise TopicLineageUnavailable("topic and evidence counts must be at least two")
+ if connected_post_count > evidence_count or lineage_count > topic_count:
+ raise TopicLineageUnavailable("topic-lineage counts exceed their dimensions")
+ raw_edges = artifact["sequence_edges"]
+ if not isinstance(raw_edges, list) or len(raw_edges) > TOPIC_LINEAGE_EDGE_LIMIT:
+ raise TopicLineageUnavailable("sequence_edges is outside its bound")
+ pairs: set[tuple[str, str]] = set()
+ connected: set[str] = set()
+ lineages: set[int] = set()
+ edges: list[dict[str, Any]] = []
+ for raw_edge in raw_edges:
+ if not isinstance(raw_edge, Mapping) or frozenset(raw_edge) != _EDGE_FIELDS:
+ raise TopicLineageUnavailable("topic-lineage edge fields are invalid")
+ predecessor = _uuid(raw_edge["predecessor_document_id"], "predecessor_document_id")
+ successor = _uuid(raw_edge["successor_document_id"], "successor_document_id")
+ topic_index = _u64(raw_edge["topic_index"], "topic_index")
+ strength = raw_edge["association_strength"]
+ if isinstance(strength, bool) or not isinstance(strength, (int, float)):
+ raise TopicLineageUnavailable("association_strength must be numeric")
+ try:
+ strength = float(strength)
+ except OverflowError as exc:
+ raise TopicLineageUnavailable("association_strength must be finite") from exc
+ pair = (predecessor, successor)
+ if (
+ predecessor == successor
+ or topic_index >= topic_count
+ or not 0.0 < strength <= 1.0
+ or pair in pairs
+ ):
+ raise TopicLineageUnavailable("topic-lineage edge is invalid")
+ pairs.add(pair)
+ connected.update(pair)
+ lineages.add(topic_index)
+ edges.append(
+ {
+ "predecessor_document_id": predecessor,
+ "successor_document_id": successor,
+ "topic_index": topic_index,
+ "association_strength": strength,
+ }
+ )
+ inference_status = _text(artifact["inference_status"], "inference_status", 64)
+ if inference_status != TOPIC_LINEAGE_INFERENCE_STATUS:
+ raise TopicLineageUnavailable("topic-lineage inference status is unsupported")
+ if connected_post_count != len(connected) or lineage_count != len(lineages):
+ raise TopicLineageUnavailable("topic-lineage counts do not match the edges")
+ return {
+ "schema_version": schema_version,
+ "run_id": run_id,
+ "snapshot_id": snapshot_id,
+ "knowledge_cutoff": knowledge_cutoff,
+ "selected_seed": selected_seed,
+ "iterations": iterations,
+ "objective": objective,
+ "topic_count": topic_count,
+ "evidence_count": evidence_count,
+ "connected_post_count": connected_post_count,
+ "lineage_count": lineage_count,
+ "sequence_edges": edges,
+ "inference_status": inference_status,
+ }
+
+
+def topic_lineage_artifact_sha256(value: Any) -> str:
+ """Return TEPP's SHA-256 over canonical artifact field order."""
+
+ artifact = parse_topic_lineage_artifact(value)
+ wire = json.dumps(artifact, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
+ return hashlib.sha256(wire).hexdigest()
+
+
+def parse_topic_lineage_envelope(
+ value: Any,
+ *,
+ expected_snapshot_id: str | None = None,
+ expected_knowledge_cutoff: str | None = None,
+ expected_remote_run_id: str | None = None,
+) -> dict[str, Any]:
+ """Validate a completed, digest-bound transport envelope and its artifact."""
+
+ envelope = _json_object(value, maximum_bytes=TOPIC_LINEAGE_ARTIFACT_BYTE_LIMIT * 2)
+ if envelope.get("status") not in {"completed", "succeeded"}:
+ raise TopicLineageUnavailable("topic-lineage run is not completed")
+ remote_run_id = envelope.get("analysis_run_id") or envelope.get("run_id")
+ remote_run_id = _text(remote_run_id, "remote_run_id")
+ artifact = parse_topic_lineage_artifact(envelope.get("result"))
+ if envelope.get("result_schema_version") != TOPIC_LINEAGE_ARTIFACT_SCHEMA_VERSION:
+ raise TopicLineageUnavailable("topic-lineage result schema is unsupported")
+ digest = _text(envelope.get("result_sha256"), "result_sha256", 64)
+ if digest != topic_lineage_artifact_sha256(artifact):
+ raise TopicLineageUnavailable("topic-lineage result digest does not match")
+ if artifact["run_id"] != remote_run_id:
+ raise TopicLineageUnavailable("topic-lineage run identity does not match")
+ if expected_remote_run_id is not None and remote_run_id != expected_remote_run_id:
+ raise TopicLineageUnavailable("topic-lineage persisted run identity does not match")
+ if expected_snapshot_id is not None and artifact["snapshot_id"] != expected_snapshot_id:
+ raise TopicLineageUnavailable("topic-lineage snapshot identity does not match")
+ if expected_knowledge_cutoff is not None and artifact["knowledge_cutoff"] != _rfc3339_utc(
+ expected_knowledge_cutoff, "expected_knowledge_cutoff"
+ ):
+ raise TopicLineageUnavailable("topic-lineage knowledge cutoff does not match")
+ return artifact
+
+
+def project_topic_lineage_projection(
+ artifacts: Sequence[Mapping[str, Any]], visible_post_ids: Sequence[str]
+) -> dict[str, Any]:
+ """Filter validated TEPP edges to one authorized Project History post set."""
+
+ visible = set(visible_post_ids)
+ connected: set[str] = set()
+ lineages: set[tuple[str, int]] = set()
+ edges: list[dict[str, Any]] = []
+ contributing_runs: set[str] = set()
+ for value in artifacts:
+ artifact = parse_topic_lineage_artifact(value)
+ for edge in artifact["sequence_edges"]:
+ predecessor = edge["predecessor_document_id"]
+ successor = edge["successor_document_id"]
+ if predecessor not in visible or successor not in visible:
+ continue
+ connected.update((predecessor, successor))
+ lineages.add((artifact["run_id"], edge["topic_index"]))
+ contributing_runs.add(artifact["run_id"])
+ edges.append(
+ {
+ "artifact_run_id": artifact["run_id"],
+ "predecessor_post_id": predecessor,
+ "successor_post_id": successor,
+ "topic_index": edge["topic_index"],
+ "association_strength": edge["association_strength"],
+ }
+ )
+ edges.sort(
+ key=lambda edge: (
+ edge["predecessor_post_id"],
+ edge["successor_post_id"],
+ edge["artifact_run_id"],
+ edge["topic_index"],
+ )
+ )
+ available = bool(edges)
+ return {
+ "status": "validated" if available else "unavailable",
+ "schema_version": TOPIC_LINEAGE_ARTIFACT_SCHEMA_VERSION if available else None,
+ "inference_status": TOPIC_LINEAGE_INFERENCE_STATUS if available else None,
+ "artifact_count": len(contributing_runs),
+ "connected_post_count": len(connected) if available else None,
+ "lineage_count": len(lineages) if available else None,
+ "sequence_edges": edges,
+ }
diff --git a/migrations/0131_analysis_run_topic_lineage_kind.sql b/migrations/0131_analysis_run_topic_lineage_kind.sql
new file mode 100644
index 000000000..319dd4c88
--- /dev/null
+++ b/migrations/0131_analysis_run_topic_lineage_kind.sql
@@ -0,0 +1,45 @@
+-- Adds the topic-lineage analysis-run kind (ADR 0147).
+--
+-- Requesting/starting this kind submits through the same tepp_client
+-- boundary as analysis_run_tepp (ADR 0022) -- it never computes a topic
+-- identity or predecessor/successor association locally. This
+-- migration only registers the kind vocabulary and widens the existing
+-- kind check constraints; it stores no post body and no fabricated
+-- measurement.
+
+begin;
+
+insert into common_lookup_value
+ (lookup_category, lookup_code, lookup_label, display_order)
+values
+ ('analysis_run_kind', 'analysis_run_topic_lineage', 'Topic lineage', 3)
+on conflict (lookup_code) do nothing;
+
+alter table analysis_run
+ drop constraint if exists analysis_run_kind_check;
+alter table analysis_run
+ add constraint analysis_run_kind_check
+ check (run_kind_code in (
+ 'analysis_run_lineage',
+ 'analysis_run_report',
+ 'analysis_run_tepp',
+ 'analysis_run_topic_lineage'
+ ));
+
+do $$
+begin
+ if to_regclass('public.analysis_run_outbox') is not null then
+ alter table analysis_run_outbox
+ drop constraint if exists analysis_run_outbox_kind_check;
+ alter table analysis_run_outbox
+ add constraint analysis_run_outbox_kind_check
+ check (work_kind_code in (
+ 'analysis_run_lineage',
+ 'analysis_run_tepp',
+ 'analysis_run_topic_lineage'
+ ));
+ end if;
+end
+$$;
+
+commit;
diff --git a/migrations/0132_analysis_run_topic_lineage_result.sql b/migrations/0132_analysis_run_topic_lineage_result.sql
new file mode 100644
index 000000000..34ad99cb1
--- /dev/null
+++ b/migrations/0132_analysis_run_topic_lineage_result.sql
@@ -0,0 +1,14 @@
+-- Persist only a provider-authoritative completed TEPP topic-lineage
+-- `tepp.trsl_topic_lineage.v1` envelope (ADR 0147). LineageWeave never
+-- computes or substitutes a topic model; result_json retains the exact
+-- digest-bound TEPP artifact envelope.
+create table if not exists analysis_run_topic_lineage_result (
+ analysis_run_id uuid primary key references analysis_run(analysis_run_id) on delete cascade,
+ remote_run_id text not null check (btrim(remote_run_id) <> ''),
+ result_json jsonb not null,
+ result_sha256 text not null check (result_sha256 ~ '^[0-9a-f]{64}$'),
+ persisted_at timestamptz not null default now()
+);
+
+create index if not exists analysis_run_topic_lineage_result_remote_idx
+ on analysis_run_topic_lineage_result (remote_run_id);
diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py
index 3c84ba8b8..760a1439d 100644
--- a/scripts/seed_demo_data.py
+++ b/scripts/seed_demo_data.py
@@ -33,6 +33,10 @@
from lineageweave.http_client import get_json_list, post_form
from lineageweave.post_summary import ACTOR_TYPE_PERSON, POST_SUMMARY_CONTRACT_VERSION
from lineageweave.tepp_client import AnalysisRunRequest, TeppClient, TeppNotAvailable
+from lineageweave.topic_lineage_artifact import (
+ TOPIC_LINEAGE_MODEL_CONTRACT_VERSION,
+ TOPIC_LINEAGE_OUTPUT_PROFILE,
+)
REALM = "lineageweave-demo"
DEFAULT_POSTGRES_DSN = "postgresql://lineageweave:lineageweave_dev_only@localhost:15432/lineageweave"
@@ -45,6 +49,7 @@
DEMO_SOURCE_CONTRACT_VERSION = "demo-source-contract-v1"
DEMO_LINEAGE_IDEMPOTENCY_KEY = "demo-lineage-seed-2026-w02"
DEMO_TEPP_IDEMPOTENCY_KEY = "demo-tepp-seed-2026-w02"
+DEMO_TOPIC_LINEAGE_IDEMPOTENCY_KEY = "demo-topic-lineage-seed-2026-w02"
DEMO_REPORT_IDEMPOTENCY_KEY = "demo-report-seed-2026-w02"
# (post_title, ticket_title, due_date) -- Event Lineage fixtures a report
@@ -439,6 +444,11 @@ def seed(
account_ids["demo.analyst"],
corporate_entity_id,
)
+ _seed_demo_topic_lineage_run(
+ cur,
+ account_ids["demo.analyst"],
+ corporate_entity_id,
+ )
_seed_demo_report_run(
cur,
account_ids["demo.analyst"],
@@ -1670,6 +1680,113 @@ def _seed_demo_tepp_run(cur, requested_by_account_id, corporate_entity_id) -> No
_seed_demo_run_outbox(cur, run_id)
+def topic_lineage_seed_request() -> AnalysisRunRequest:
+ """Build the Demo Corp topic-lineage request against the shared snapshot digest.
+
+ Same wire shape as :func:`tepp_seed_request` (ADR 0147); only the model
+ contract and output profile select the bounded topic-lineage artifact.
+ """
+ return AnalysisRunRequest(
+ idempotency_key=DEMO_TOPIC_LINEAGE_IDEMPOTENCY_KEY,
+ tenant_workspace_id="demo-workspace",
+ snapshot_id=demo_source_snapshot_sha256(),
+ knowledge_cutoff="2026-01-12T12:00:00Z",
+ model_contract_version=TOPIC_LINEAGE_MODEL_CONTRACT_VERSION,
+ output_profile=TOPIC_LINEAGE_OUTPUT_PROFILE,
+ )
+
+
+def topic_lineage_seed_outcome(client: TeppClient | None = None) -> tuple[str, str | None]:
+ """Ask TEPP through the published client. A missing transport is Failed.
+
+ Never invents a topic identity or predecessor/successor association.
+ ``tepp_not_available`` means the channel was dropped, not an abstained
+ measurement. A live envelope is also not yet a persistable result in
+ this seed, so the run is not stamped Succeeded.
+ """
+ request = topic_lineage_seed_request()
+ try:
+ (client or TeppClient()).submit_analysis_run(request)
+ except TeppNotAvailable:
+ return "analysis_status_failed", "tepp_not_available"
+ return "analysis_status_failed", "tepp_result_not_persisted"
+
+
+def _seed_demo_topic_lineage_run(cur, requested_by_account_id, corporate_entity_id) -> None:
+ """Insert one Demo-Corp topic-lineage run so the kind is visible without a live TEPP.
+
+ Mirrors :func:`_seed_demo_tepp_run` (ADR 0147). Default transport is
+ unavailable, so the run ends Failed / ``tepp_not_available`` -- never
+ a fabricated topic model.
+ """
+ snapshot_id = _ensure_demo_source_snapshot(cur)
+ _ensure_demo_source_counts(cur, snapshot_id)
+ _ensure_demo_source_snapshot_members(cur, snapshot_id, corporate_entity_id)
+ cur.execute(
+ """
+ select analysis_run_id from analysis_run
+ where requested_by_account_id = %s
+ and idempotency_key = %s
+ """,
+ (requested_by_account_id, DEMO_TOPIC_LINEAGE_IDEMPOTENCY_KEY),
+ )
+ run_row = cur.fetchone()
+ if run_row is None:
+ cur.execute(
+ """
+ insert into analysis_run
+ (analysis_source_snapshot_id, run_kind_code, idempotency_key,
+ requested_by_account_id, knowledge_cutoff,
+ configuration_schema_version, configuration_sha256,
+ code_revision_sha, requested_at)
+ values (%s, 'analysis_run_topic_lineage', %s,
+ %s, '2026-01-12T12:00:00Z', 'topic-lineage-run-v1', %s, %s,
+ '2026-01-12T12:34:00Z')
+ returning analysis_run_id
+ """,
+ (
+ snapshot_id,
+ DEMO_TOPIC_LINEAGE_IDEMPOTENCY_KEY,
+ requested_by_account_id,
+ "d" * 64,
+ "e" * 40,
+ ),
+ )
+ run_id = cur.fetchone()[0]
+ else:
+ run_id = run_row[0]
+ cur.execute(
+ """
+ insert into analysis_run_scope
+ (analysis_run_id, scope_kind_code, corporate_entity_id)
+ values (%s, 'analysis_scope_corporate_entity', %s)
+ on conflict (analysis_run_id) do nothing
+ """,
+ (run_id, corporate_entity_id),
+ )
+ final_status, failure_code = topic_lineage_seed_outcome()
+ events = [
+ (1, "analysis_status_pending", "2026-01-12T12:35:00Z", None),
+ (2, "analysis_status_running", "2026-01-12T12:36:00Z", None),
+ (3, final_status, "2026-01-12T12:37:00Z", failure_code),
+ ]
+ cur.execute(
+ "select 1 from analysis_run_status_event where analysis_run_id = %s limit 1",
+ (run_id,),
+ )
+ if cur.fetchone() is None:
+ for ordinal, status, occurred, fail in events:
+ cur.execute(
+ """
+ insert into analysis_run_status_event
+ (analysis_run_id, status_ordinal, status_code, occurred_at, failure_code)
+ values (%s, %s, %s, %s, %s)
+ """,
+ (run_id, ordinal, status, occurred, fail),
+ )
+ _seed_demo_run_outbox(cur, run_id)
+
+
def _seed_demo_report_run(cur, requested_by_account_id, corporate_entity_id) -> None:
"""Record the already-built Demo Corp period report on the shared snapshot.
@@ -1780,7 +1897,7 @@ def _seed_demo_run_outbox(cur, analysis_run_id) -> None:
snapshot_sha256=snapshot_sha256,
knowledge_cutoff=knowledge_cutoff,
)
- if work_kind_code == "analysis_run_tepp":
+ if work_kind_code in ("analysis_run_tepp", "analysis_run_topic_lineage"):
claimed = datetime(2026, 1, 12, 12, 36, tzinfo=timezone.utc)
delivered = datetime(2026, 1, 12, 12, 37, tzinfo=timezone.utc)
else:
diff --git a/tests/test_analysis_run_create.py b/tests/test_analysis_run_create.py
index 664ecc830..ef9dc9f71 100644
--- a/tests/test_analysis_run_create.py
+++ b/tests/test_analysis_run_create.py
@@ -145,6 +145,10 @@ def test_create_rejects_tepp_and_report_kinds_without_a_fake_score() -> None:
_require_lineage_create_kind("analysis_run_tepp")
assert tepp.value.status_code == 422
assert "invent a measurement" in tepp.value.detail
+ with pytest.raises(AnalysisRunCreateError) as topic_lineage:
+ _require_lineage_create_kind("analysis_run_topic_lineage")
+ assert topic_lineage.value.status_code == 422
+ assert "invent a topic model" in topic_lineage.value.detail
with pytest.raises(AnalysisRunCreateError) as report:
_require_lineage_create_kind("analysis_run_report")
assert report.value.status_code == 422
diff --git a/tests/test_analysis_run_start.py b/tests/test_analysis_run_start.py
index c2b1b6065..7796ff919 100644
--- a/tests/test_analysis_run_start.py
+++ b/tests/test_analysis_run_start.py
@@ -14,11 +14,14 @@
start_write_conflict_error,
tepp_run_request,
tepp_submit_outcome,
+ topic_lineage_run_request,
+ topic_lineage_submit_outcome,
)
from backend.app.lineage_ingestion import records_from_source_posts
from lineageweave.fixtures import sample_records
from lineageweave.lineage_persistence import lineage_edge_specs
from lineageweave.tepp_client import AnalysisRunRequest, TeppClient, TeppNotAvailable
+from lineageweave.topic_lineage_artifact import topic_lineage_artifact_sha256
def test_reconstruction_digest_is_stable_and_ignores_edge_order() -> None:
@@ -94,7 +97,7 @@ def test_reconstructed_edge_hides_unaffiliated_private_titles() -> None:
def test_period_report_start_is_unprocessable_and_tepp_is_allowed() -> None:
- """Period-report stays 422. TEPP start is allowed so tepp_client can run."""
+ """Period-report stays 422. TEPP/topic-lineage start is allowed so tepp_client can run."""
report = start_kind_rejection("analysis_run_report")
assert report is not None
assert report.status_code == 422
@@ -102,6 +105,7 @@ def test_period_report_start_is_unprocessable_and_tepp_is_allowed() -> None:
assert "period report" in report.detail
assert start_kind_rejection("analysis_run_lineage") is None
assert start_kind_rejection("analysis_run_tepp") is None
+ assert start_kind_rejection("analysis_run_topic_lineage") is None
def _tepp_request() -> AnalysisRunRequest:
@@ -145,6 +149,88 @@ def __init__(self) -> None:
assert failure == "tepp_result_not_persisted"
+def _topic_lineage_request() -> AnalysisRunRequest:
+ return topic_lineage_run_request(
+ idempotency_key="run-topic-lineage-2026-w07",
+ snapshot_sha256="ab" * 32,
+ knowledge_cutoff=datetime(2026, 1, 12, 12, 0, tzinfo=timezone.utc),
+ corporate_entity_id="11111111-1111-1111-1111-111111111111",
+ )
+
+
+def test_topic_lineage_run_request_is_the_published_wire_shape() -> None:
+ """Start builds TEPP's seven-field request for topic lineage (ADR 0147)."""
+ request = _topic_lineage_request()
+ payload = request.to_json()
+ assert payload["contract_version"] == 1
+ assert payload["idempotency_key"] == "run-topic-lineage-2026-w07"
+ assert payload["snapshot_id"] == "ab" * 32
+ assert payload["knowledge_cutoff"] == "2026-01-12T12:00:00Z"
+ assert payload["model_contract_version"] == "trsl_tm_cpu_f64_v1"
+ assert payload["output_profile"] == "trsl_topic_lineage_v1"
+ assert "theta" not in str(payload).casefold()
+ assert "chronos" not in str(payload).casefold()
+
+
+def test_topic_lineage_submit_outcome_drops_a_missing_transport() -> None:
+ """A missing TEPP transport is Failed, never a fabricated topic model."""
+ status, failure = topic_lineage_submit_outcome(TeppClient(), _topic_lineage_request())
+ assert status == "analysis_status_failed"
+ assert failure == "tepp_not_available"
+
+
+def test_topic_lineage_submit_outcome_does_not_persist_an_empty_envelope() -> None:
+ """An accepted envelope is not yet a persistable topic-lineage result."""
+
+ class _Accepting(TeppClient):
+ def __init__(self) -> None:
+ super().__init__(transport=lambda _payload: {"status": "accepted"})
+
+ status, failure = topic_lineage_submit_outcome(_Accepting(), _topic_lineage_request())
+ assert status == "analysis_status_failed"
+ assert failure == "tepp_topic_contract_unavailable"
+
+
+def test_topic_lineage_submit_outcome_accepts_only_the_bound_tepp_artifact() -> None:
+ """The exact completed artifact can cross the analysis-run boundary."""
+
+ artifact = {
+ "schema_version": "tepp.trsl_topic_lineage.v1",
+ "run_id": "tepp-run-1",
+ "snapshot_id": "ab" * 32,
+ "knowledge_cutoff": "2026-01-12T12:00:00Z",
+ "selected_seed": 7,
+ "iterations": 4,
+ "objective": 1.25,
+ "topic_count": 2,
+ "evidence_count": 2,
+ "connected_post_count": 2,
+ "lineage_count": 1,
+ "sequence_edges": [
+ {
+ "predecessor_document_id": "00000000-0000-0000-0000-000000000001",
+ "successor_document_id": "00000000-0000-0000-0000-000000000002",
+ "topic_index": 0,
+ "association_strength": 0.8,
+ }
+ ],
+ "inference_status": "fitted_topic_association_not_causation",
+ }
+ envelope = {
+ "status": "completed",
+ "run_id": artifact["run_id"],
+ "result_schema_version": artifact["schema_version"],
+ "result_sha256": topic_lineage_artifact_sha256(artifact),
+ "result": artifact,
+ }
+ client = TeppClient(transport=lambda _payload: envelope)
+
+ assert topic_lineage_submit_outcome(client, _topic_lineage_request()) == (
+ "analysis_status_succeeded",
+ "",
+ )
+
+
def test_configured_tepp_client_stays_unavailable_without_http() -> None:
"""Empty or non-http URLs keep the default dropped channel."""
assert isinstance(configured_tepp_client(""), TeppClient)
diff --git a/tests/test_migration_replay.py b/tests/test_migration_replay.py
index 6bccf9d41..9550bb8d7 100644
--- a/tests/test_migration_replay.py
+++ b/tests/test_migration_replay.py
@@ -78,3 +78,51 @@ def test_migrate_sh_replays_project_history_lookup_indexes() -> None:
):
assert f"create index if not exists {index_name}" in forward
assert f"drop index if exists {index_name}" in rollback
+def test_migrate_sh_replays_source_commercial_context_migration_on_existing_volumes() -> None:
+ """Existing Compose volumes must receive the source context columns."""
+ script = (
+ Path(__file__).resolve().parents[1]
+ / "docker"
+ / "postgres-init"
+ / "migrate.sh"
+ ).read_text(encoding="utf-8")
+
+ assert "0130_*" in script
+
+
+def test_migrate_sh_replays_topic_lineage_migrations_on_existing_volumes() -> None:
+ """Existing Compose volumes must receive the topic-lineage kind and result table."""
+ script = (
+ Path(__file__).resolve().parents[1]
+ / "docker"
+ / "postgres-init"
+ / "migrate.sh"
+ ).read_text(encoding="utf-8")
+
+ assert "0131_*" in script
+ assert "0132_*" in script
+
+
+def test_topic_lineage_kind_migration_is_idempotent_for_replay() -> None:
+ """The kind-widening migration must not fail after a second apply."""
+ migration = (
+ Path(__file__).resolve().parents[1]
+ / "migrations"
+ / "0131_analysis_run_topic_lineage_kind.sql"
+ ).read_text(encoding="utf-8")
+
+ assert "on conflict (lookup_code) do nothing" in migration
+ assert "drop constraint if exists analysis_run_kind_check" in migration
+ assert "analysis_run_topic_lineage" in migration
+
+
+def test_topic_lineage_result_migration_is_idempotent_for_replay() -> None:
+ """The result-table migration must not fail after a second apply."""
+ migration = (
+ Path(__file__).resolve().parents[1]
+ / "migrations"
+ / "0132_analysis_run_topic_lineage_result.sql"
+ ).read_text(encoding="utf-8")
+
+ assert "create table if not exists analysis_run_topic_lineage_result" in migration
+ assert "create index if not exists" in migration
diff --git a/tests/test_project_history.py b/tests/test_project_history.py
index 693bc6544..9be07c4c7 100644
--- a/tests/test_project_history.py
+++ b/tests/test_project_history.py
@@ -4,15 +4,19 @@
import asyncio
from datetime import datetime, timezone
+import hashlib
+import json
import pytest
from backend.app.project_history import (
ProjectHistoryNotFound,
ProjectHistoryConnection,
+ _fetch_topic_lineage_projection,
fetch_project_history_index,
fetch_project_history_projection,
)
+from lineageweave.topic_lineage_artifact import topic_lineage_artifact_sha256
from lineageweave.project_history import (
build_project_history_projection,
classify_project_event,
@@ -75,6 +79,113 @@ async def fetch(self, query: str, *args: object):
return []
+def test_topic_lineage_repository_filters_to_authorized_post_ids() -> None:
+ """Persisted TEPP evidence is digest-bound before visible projection."""
+
+ artifact = {
+ "schema_version": "tepp.trsl_topic_lineage.v1",
+ "run_id": "tepp-run-1",
+ "snapshot_id": "ab" * 32,
+ "knowledge_cutoff": "2026-01-12T12:00:00Z",
+ "selected_seed": 7,
+ "iterations": 4,
+ "objective": 1.25,
+ "topic_count": 2,
+ "evidence_count": 2,
+ "connected_post_count": 2,
+ "lineage_count": 1,
+ "sequence_edges": [
+ {
+ "predecessor_document_id": "00000000-0000-0000-0000-000000000001",
+ "successor_document_id": "00000000-0000-0000-0000-000000000002",
+ "topic_index": 0,
+ "association_strength": 0.8,
+ }
+ ],
+ "inference_status": "fitted_topic_association_not_causation",
+ }
+ envelope = {
+ "status": "completed",
+ "run_id": "tepp-run-1",
+ "result_schema_version": artifact["schema_version"],
+ "result_sha256": topic_lineage_artifact_sha256(artifact),
+ "result": artifact,
+ }
+ stored = json.dumps(envelope, separators=(",", ":"), sort_keys=True)
+ invalid_contract = {**envelope, "result_schema_version": "unknown"}
+ invalid_contract_stored = json.dumps(
+ invalid_contract, separators=(",", ":"), sort_keys=True
+ )
+
+ class Connection:
+ async def fetch(self, query: str, *args: object):
+ assert "analysis_run_topic_lineage_result" in query
+ return [
+ {
+ "result_json": "{not-json",
+ "result_sha256": "0" * 64,
+ "remote_run_id": "tepp-run-1",
+ "snapshot_sha256": "ab" * 32,
+ "knowledge_cutoff": datetime(2026, 1, 12, 12, tzinfo=timezone.utc),
+ },
+ {
+ "result_json": stored,
+ "result_sha256": "0" * 64,
+ "remote_run_id": "tepp-run-1",
+ "snapshot_sha256": "ab" * 32,
+ "knowledge_cutoff": datetime(2026, 1, 12, 12, tzinfo=timezone.utc),
+ },
+ {
+ "result_json": invalid_contract_stored,
+ "result_sha256": hashlib.sha256(invalid_contract_stored.encode()).hexdigest(),
+ "remote_run_id": "tepp-run-1",
+ "snapshot_sha256": "ab" * 32,
+ "knowledge_cutoff": datetime(2026, 1, 12, 12, tzinfo=timezone.utc),
+ },
+ {
+ "result_json": stored,
+ "result_sha256": hashlib.sha256(stored.encode()).hexdigest(),
+ "remote_run_id": "tepp-run-1",
+ "snapshot_sha256": "ab" * 32,
+ "knowledge_cutoff": datetime(2026, 1, 12, 12, tzinfo=timezone.utc),
+ }
+ ]
+
+ projection = asyncio.run(
+ _fetch_topic_lineage_projection(
+ Connection(),
+ visible_ids=[
+ "00000000-0000-0000-0000-000000000001",
+ "00000000-0000-0000-0000-000000000002",
+ ],
+ corporate_entity_ids=["11111111-1111-1111-1111-111111111111"],
+ knowledge_cutoff=datetime(2026, 8, 20, tzinfo=timezone.utc),
+ )
+ )
+
+ assert projection["connected_post_count"] == 2
+ assert projection["lineage_count"] == 1
+
+
+def test_project_history_keeps_an_already_visible_focus_without_a_second_lookup() -> None:
+ """A visible focus stays in the authorized page without a focus query."""
+
+ event = _event_row()
+ connection = _ProjectionConnection([event])
+ result = asyncio.run(
+ fetch_project_history_projection(
+ connection,
+ project_key="P-100",
+ focus_post_id=str(event["post_id"]),
+ knowledge_cutoff=datetime(2026, 8, 20, tzinfo=timezone.utc),
+ corporate_entity_ids=[],
+ )
+ )
+
+ assert result["focus_event_id"] == event["post_id"]
+ assert all("post_id = $4::uuid" not in query for query, _ in connection.calls)
+
+
def test_project_identity_is_exact_but_unicode_compatible() -> None:
"""Compatibility forms may normalize; fuzzy project binding may not."""
@@ -206,8 +317,11 @@ def test_project_history_counts_connected_posts_and_distinct_lineages() -> None:
)
assert projection["event_count"] == 6
- assert projection["connected_post_count"] == 5
- assert projection["lineage_count"] == 2
+ assert projection["connected_post_count"] is None
+ assert projection["lineage_count"] is None
+ assert projection["evidence_connected_post_count"] == 5
+ assert projection["evidence_lineage_count"] == 2
+ assert projection["topic_lineage"]["status"] == "unavailable"
def test_matching_observed_project_code_keeps_its_distinct_display_name() -> None:
@@ -454,7 +568,7 @@ def test_project_history_projection_keeps_focus_and_authorization_bounds() -> No
assert result["truncated"] is True
assert result["focus_event_id"] == "00000000-0000-0000-0000-000000000099"
assert result["knowledge_cutoff"] == "2026-08-20T00:00:00Z"
- assert len(connection.calls) == 5
+ assert len(connection.calls) == 6
assert connection.calls[0][1][-1] == 3
diff --git a/tests/test_topic_lineage_artifact.py b/tests/test_topic_lineage_artifact.py
new file mode 100644
index 000000000..5a922a9df
--- /dev/null
+++ b/tests/test_topic_lineage_artifact.py
@@ -0,0 +1,211 @@
+"""Exact TEPP topic-lineage artifact consumer contracts."""
+
+from copy import deepcopy
+
+import pytest
+
+from lineageweave.topic_lineage_artifact import (
+ TopicLineageUnavailable,
+ parse_topic_lineage_artifact,
+ parse_topic_lineage_envelope,
+ project_topic_lineage_projection,
+ topic_lineage_artifact_sha256,
+)
+
+
+def _artifact(run_id: str = "tepp-run-1") -> dict[str, object]:
+ """Return one synthetic, non-identifying TEPP artifact."""
+
+ return {
+ "schema_version": "tepp.trsl_topic_lineage.v1",
+ "run_id": run_id,
+ "snapshot_id": "ab" * 32,
+ "knowledge_cutoff": "2026-01-12T12:00:00Z",
+ "selected_seed": 7,
+ "iterations": 4,
+ "objective": 1.25,
+ "topic_count": 2,
+ "evidence_count": 3,
+ "connected_post_count": 3,
+ "lineage_count": 2,
+ "sequence_edges": [
+ {
+ "predecessor_document_id": "00000000-0000-0000-0000-000000000001",
+ "successor_document_id": "00000000-0000-0000-0000-000000000002",
+ "topic_index": 0,
+ "association_strength": 0.8,
+ },
+ {
+ "predecessor_document_id": "00000000-0000-0000-0000-000000000002",
+ "successor_document_id": "00000000-0000-0000-0000-000000000003",
+ "topic_index": 1,
+ "association_strength": 0.7,
+ },
+ ],
+ "inference_status": "fitted_topic_association_not_causation",
+ }
+
+
+def _envelope(artifact: dict[str, object] | None = None) -> dict[str, object]:
+ """Wrap one artifact in TEPP's completed digest-bound envelope."""
+
+ result = artifact or _artifact()
+ return {
+ "status": "completed",
+ "run_id": result["run_id"],
+ "result_schema_version": result["schema_version"],
+ "result_sha256": topic_lineage_artifact_sha256(result),
+ "result": result,
+ }
+
+
+def test_exact_envelope_and_authorized_projection() -> None:
+ """Only validated edges whose endpoints are visible contribute counts."""
+
+ artifact = parse_topic_lineage_envelope(
+ _envelope(),
+ expected_snapshot_id="ab" * 32,
+ expected_knowledge_cutoff="2026-01-12T12:00:00+00:00",
+ expected_remote_run_id="tepp-run-1",
+ )
+ projection = project_topic_lineage_projection(
+ [artifact],
+ [
+ "00000000-0000-0000-0000-000000000001",
+ "00000000-0000-0000-0000-000000000002",
+ ],
+ )
+
+ assert projection["status"] == "validated"
+ assert projection["connected_post_count"] == 2
+ assert projection["lineage_count"] == 1
+ assert projection["artifact_count"] == 1
+ assert len(projection["sequence_edges"]) == 1
+
+
+def test_projection_keeps_run_scoped_topic_identity_and_unavailable_state() -> None:
+ """Equal topic indexes from separate runs remain separate lineages."""
+
+ second = deepcopy(_artifact("tepp-run-2"))
+ second["sequence_edges"] = [deepcopy(_artifact()["sequence_edges"][0])]
+ second["connected_post_count"] = 2
+ second["lineage_count"] = 1
+ second["evidence_count"] = 2
+ visible = [
+ "00000000-0000-0000-0000-000000000001",
+ "00000000-0000-0000-0000-000000000002",
+ ]
+
+ projection = project_topic_lineage_projection([_artifact(), second], visible)
+ assert projection["lineage_count"] == 2
+ assert projection["artifact_count"] == 2
+ assert project_topic_lineage_projection([_artifact()], [visible[0]])["status"] == "unavailable"
+
+
+@pytest.mark.parametrize(
+ ("mutate", "message"),
+ [
+ (lambda value: value.update(run_id=None), "canonical text"),
+ (lambda value: value.update(run_id=""), "outside"),
+ (lambda value: value.update(selected_seed=True), "unsigned"),
+ (lambda value: value.update(knowledge_cutoff="bad-date"), "RFC 3339"),
+ (lambda value: value.update(knowledge_cutoff="2026-01-12T12:00:00"), "offset"),
+ (lambda value: value.update(schema_version="unknown"), "schema"),
+ (lambda value: value.update(iterations=0), "iterations"),
+ (lambda value: value.update(objective="1.25"), "objective"),
+ (lambda value: value.update(objective=10**400), "finite"),
+ (lambda value: value.update(topic_count=1), "at least two"),
+ (lambda value: value.update(connected_post_count=4), "dimensions"),
+ (lambda value: value.update(connected_post_count=2), "counts"),
+ (lambda value: value.update(sequence_edges=()), "sequence_edges"),
+ (lambda value: value.update(inference_status="causal"), "inference"),
+ (lambda value: value.update(extra=True), "fields"),
+ (lambda value: value["sequence_edges"][0].update(extra=True), "edge fields"),
+ (
+ lambda value: value["sequence_edges"][0].update(
+ predecessor_document_id="not-a-uuid"
+ ),
+ "UUID",
+ ),
+ (
+ lambda value: value["sequence_edges"][0].update(
+ predecessor_document_id="AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA"
+ ),
+ "canonical UUID",
+ ),
+ (lambda value: value["sequence_edges"][0].update(association_strength="0.8"), "numeric"),
+ (lambda value: value["sequence_edges"][0].update(association_strength=10**400), "finite"),
+ (lambda value: value["sequence_edges"][0].update(topic_index=2), "edge"),
+ (
+ lambda value: value["sequence_edges"][0].update(
+ successor_document_id=value["sequence_edges"][0]["predecessor_document_id"]
+ ),
+ "edge",
+ ),
+ ],
+)
+def test_artifact_rejects_contract_drift(mutate, message: str) -> None:
+ """Schema, convergence, count, inference, and edge drift fail closed."""
+
+ artifact = _artifact()
+ mutate(artifact)
+ with pytest.raises(TopicLineageUnavailable, match=message):
+ parse_topic_lineage_artifact(artifact)
+
+
+@pytest.mark.parametrize(
+ "value",
+ [
+ "{not-json",
+ "x" * (256 * 1024 + 1),
+ ["not", "an", "object"],
+ {"oversized": "x" * (256 * 1024)},
+ {"not_json": float("inf")},
+ ],
+)
+def test_artifact_rejects_invalid_or_oversized_json(value) -> None:
+ """The JSON boundary is bounded and rejects non-objects and non-finite values."""
+
+ with pytest.raises(TopicLineageUnavailable):
+ parse_topic_lineage_artifact(value)
+
+
+@pytest.mark.parametrize(
+ "mutation",
+ [
+ lambda value: value.update(status="accepted"),
+ lambda value: value.update(result_sha256="0" * 64),
+ lambda value: value.update(run_id="another-run"),
+ ],
+)
+def test_envelope_rejects_incomplete_or_unbound_results(mutation) -> None:
+ """Completion, digest, and run identity are mandatory."""
+
+ envelope = _envelope()
+ mutation(envelope)
+ with pytest.raises(TopicLineageUnavailable):
+ parse_topic_lineage_envelope(envelope)
+
+
+@pytest.mark.parametrize(
+ "kwargs",
+ [
+ {"expected_remote_run_id": "another-run"},
+ {"expected_snapshot_id": "different-snapshot"},
+ {"expected_knowledge_cutoff": "2026-01-13T12:00:00Z"},
+ ],
+)
+def test_envelope_rejects_persisted_identity_drift(kwargs) -> None:
+ """Stored run, snapshot, and cutoff bindings cannot drift."""
+
+ with pytest.raises(TopicLineageUnavailable):
+ parse_topic_lineage_envelope(_envelope(), **kwargs)
+
+
+def test_envelope_rejects_an_unknown_result_schema() -> None:
+ """A completed result with another schema remains unavailable."""
+
+ envelope = _envelope()
+ envelope["result_schema_version"] = "unknown"
+ with pytest.raises(TopicLineageUnavailable, match="result schema"):
+ parse_topic_lineage_envelope(envelope)