diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 8a5e5071c..43d2788d8 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -475,7 +475,11 @@ revision and configuration digest prefixes.
cutoff capture (ADR 0017): snapshot, counts, run, scope, and the first
status in one transaction. It does not reconstruct lineage and does not
invent a TEPP score. Request a lineage reconstruction from the home
-list, then open the Pending row to confirm the cutoff corpus.
+list, then open the Pending row and **Start reconstruction**
+(ADR 0020). That start locks the run, reconstructs the create-time
+snapshot members, and persists run-scoped edges; it does not replace
+live Event Lineage and does not invent a TEPP score. Hover **Result**
+to read the reconstruction digest.
`make seed` also records a TEPP measurement run through
`tepp_client` on that same snapshot; the default transport is
unavailable, so that run is Failed rather than a fabricated score.
diff --git a/CHANGELOG.d/0.89.0-analysis-run-start.md b/CHANGELOG.d/0.89.0-analysis-run-start.md
new file mode 100644
index 000000000..e9b49ccf8
--- /dev/null
+++ b/CHANGELOG.d/0.89.0-analysis-run-start.md
@@ -0,0 +1,5 @@
+# 0.89.0 Analysis-run start reconstruction
+
+Pending lineage rows can start ThreadWeave on the create-time snapshot
+members. The designed A-100 fork appears as titled edges. Hover Result
+to read the digest. TEPP start stays 422.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 037b11287..3745c594a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,20 @@ All notable changes to this project are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [0.89.0] - 2026-08-17
+
+### Added
+
+- A Pending lineage run now has **Start reconstruction**. After
+ `make seed`, request a lineage reconstruction, open the Pending Demo
+ Corp row, and start it: the designed A-100 fork appears as titled
+ parent→child edges (revised quote and delivery question under the
+ pricing follow-up). The run is locked before Running; a raced start
+ is 409. Create freezes `analysis_source_snapshot_member`; start
+ reconstructs that bag. Hover **Result** to read the reconstruction
+ digest. TEPP start is 422 — this path does not invent a theta.
+ Edges stay on the run; live Event Lineage is unchanged (ADR 0020).
+
## [0.88.0] - 2026-08-17
### Added
diff --git a/CLAUDE.md b/CLAUDE.md
index 2d9bf975a..50fd7bdc9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -21,3 +21,7 @@ after cutoff were rewritten after the run; compare those bodies
before treating them as reconstructed evidence (ADR 0016).
`POST /api/analysis-runs` records Pending on an authorized
cutoff capture (ADR 0017) and does not reconstruct lineage.
+`POST /api/analysis-runs/{id}/start` reconstructs a Pending
+lineage run from the create-time snapshot members (ADR 0020).
+TEPP start is 422. Hover the Result prefix to read the
+reconstruction digest.
diff --git a/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py
index 1da59ad1d..711444b0f 100644
--- a/backend/app/analysis_run_ingestion.py
+++ b/backend/app/analysis_run_ingestion.py
@@ -7,8 +7,9 @@
payloads never do.
``create_pending_analysis_run`` (ADR 0017) writes snapshot, counts, run,
-scope, and the first Pending event atomically. It does not reconstruct
-lineage or invent a TEPP score.
+scope, and the first Pending event atomically. ``start_pending_analysis_run``
+(ADR 0020) later reconstructs lineage on that cutoff bag. Neither path
+invents a TEPP score.
"""
from __future__ import annotations
@@ -263,9 +264,226 @@ async def fetch_visible_analysis_run(
affiliated_entity_ids,
row["knowledge_cutoff"],
)
+ digest, edges = await fetch_reconstructed_edges(
+ conn,
+ analysis_run_id,
+ affiliated_entity_ids,
+ row["knowledge_cutoff"],
+ )
+ if digest is not None:
+ detail["reconstruction_result_sha256"] = digest
+ detail["reconstructed_edges"] = edges
return detail
+def _affiliated_post_visible(
+ visibility_code: str,
+ corporate_entity_id: Any,
+ affiliated_entity_ids: set[str],
+) -> bool:
+ """Return whether this post is public or in the caller's walk."""
+ return visibility_code == "public" or str(corporate_entity_id) in affiliated_entity_ids
+
+
+async def fetch_reconstructed_edges(
+ conn: asyncpg.Connection,
+ analysis_run_id: str,
+ affiliated_entity_ids: list[str],
+ knowledge_cutoff: Any,
+) -> tuple[str | None, list[dict[str, Any]]]:
+ """Return the persisted digest and ABAC-visible titled edges.
+
+ Missing reconstruction tables mean this database has not applied
+ migration 0020 yet; treat that as no stored tree rather than 500.
+ Hidden parent or child titles are omitted; the digest stays on the
+ run. Titles rewritten after cutoff are marked ``live_after_cutoff``.
+ """
+ try:
+ header = await conn.fetchrow(
+ """
+ select result_sha256
+ from analysis_run_reconstruction
+ where analysis_run_id = $1
+ """,
+ analysis_run_id,
+ )
+ except asyncpg.UndefinedTableError:
+ return None, []
+ if header is None:
+ return None, []
+ rows = await conn.fetch(
+ """
+ select
+ edge.parent_post_id,
+ parent_post.post_title as parent_post_title,
+ parent_post.visibility_code as parent_visibility_code,
+ parent_post.corporate_entity_id as parent_corporate_entity_id,
+ parent_post.updated_at as parent_updated_at,
+ edge.child_post_id,
+ child_post.post_title as child_post_title,
+ child_post.visibility_code as child_visibility_code,
+ child_post.corporate_entity_id as child_corporate_entity_id,
+ child_post.updated_at as child_updated_at,
+ edge.fused_score
+ from analysis_run_lineage_edge edge
+ join source_post parent_post on parent_post.post_id = edge.parent_post_id
+ join source_post child_post on child_post.post_id = edge.child_post_id
+ where edge.analysis_run_id = $1
+ order by parent_post.post_title, child_post.post_title
+ """,
+ analysis_run_id,
+ )
+ affiliated = {str(entity_id) for entity_id in affiliated_entity_ids}
+ edges: list[dict[str, Any]] = []
+ for row in rows:
+ parent_visible = _affiliated_post_visible(
+ row["parent_visibility_code"],
+ row["parent_corporate_entity_id"],
+ affiliated,
+ )
+ child_visible = _affiliated_post_visible(
+ row["child_visibility_code"],
+ row["child_corporate_entity_id"],
+ affiliated,
+ )
+ if not parent_visible or not child_visible:
+ continue
+ edges.append(
+ {
+ "parent_post_id": str(row["parent_post_id"]),
+ "parent_post_title": row["parent_post_title"],
+ "parent_live_after_cutoff": live_write_after_cutoff(
+ row["parent_updated_at"], knowledge_cutoff
+ ),
+ "child_post_id": str(row["child_post_id"]),
+ "child_post_title": row["child_post_title"],
+ "child_live_after_cutoff": live_write_after_cutoff(
+ row["child_updated_at"], knowledge_cutoff
+ ),
+ "fused_score": float(row["fused_score"]),
+ }
+ )
+ return header["result_sha256"], edges
+
+
+async def persist_snapshot_members(
+ conn: asyncpg.Connection,
+ analysis_source_snapshot_id: Any,
+ post_ids: list[str],
+) -> None:
+ """Freeze authorized post ids onto the snapshot. Skip if 0020 is absent."""
+ try:
+ for post_id in post_ids:
+ await conn.execute(
+ """
+ insert into analysis_source_snapshot_member
+ (analysis_source_snapshot_id, source_post_id)
+ values ($1, $2)
+ on conflict do nothing
+ """,
+ analysis_source_snapshot_id,
+ post_id,
+ )
+ except asyncpg.UndefinedTableError:
+ return
+
+
+async def fetch_snapshot_member_posts(
+ conn: asyncpg.Connection,
+ analysis_source_snapshot_id: Any,
+) -> list[asyncpg.Record] | None:
+ """Return frozen snapshot members, or ``None`` if migration 0020 is absent.
+
+ An empty list means the create-time bag was empty. Start must not
+ re-walk live ``source_post`` in that case.
+ """
+ try:
+ return await conn.fetch(
+ """
+ select
+ post.post_id,
+ post.post_title,
+ post.created_at,
+ post.visibility_code,
+ post.corporate_entity_id,
+ post.process_unit_id,
+ post.thread_group_key,
+ post.secondary_grouping_key
+ from analysis_source_snapshot_member member
+ join source_post post on post.post_id = member.source_post_id
+ where member.analysis_source_snapshot_id = $1
+ order by post.created_at, post.post_title
+ """,
+ analysis_source_snapshot_id,
+ )
+ except asyncpg.UndefinedTableError:
+ return None
+
+
+async def fetch_cutoff_reconstruct_posts(
+ conn: asyncpg.Connection,
+ scope_kind_code: str,
+ corporate_entity_id: Any,
+ process_unit_id: Any,
+ scope_key: str | None,
+ affiliated_entity_ids: list[str],
+ knowledge_cutoff: Any,
+) -> list[asyncpg.Record]:
+ """ABAC-visible cutoff rows with the grouping keys reconstruct needs.
+
+ Same scope branches as ``fetch_visible_scope_posts``. The list
+ payload stays titles-only; this bag is the start path only.
+ """
+ columns = (
+ "post_id, post_title, created_at, visibility_code, "
+ "corporate_entity_id, process_unit_id, "
+ "thread_group_key, secondary_grouping_key"
+ )
+ if scope_kind_code == "analysis_scope_corporate_entity" and corporate_entity_id:
+ rows = await conn.fetch(
+ f"select {columns} "
+ "from source_post where corporate_entity_id = $1 "
+ "and created_at <= $2 "
+ "order by created_at, post_title",
+ corporate_entity_id,
+ knowledge_cutoff,
+ )
+ elif scope_kind_code == "analysis_scope_process_unit" and process_unit_id:
+ rows = await conn.fetch(
+ f"select {columns} "
+ "from source_post where process_unit_id = $1 "
+ "and created_at <= $2 "
+ "order by created_at, post_title",
+ process_unit_id,
+ knowledge_cutoff,
+ )
+ elif scope_kind_code == "analysis_scope_thread_group" and scope_key:
+ rows = await conn.fetch(
+ f"select {columns} "
+ "from source_post where thread_group_key = $1 "
+ "and created_at <= $2 "
+ "order by created_at, post_title",
+ scope_key,
+ knowledge_cutoff,
+ )
+ elif scope_kind_code == "analysis_scope_all_visible":
+ rows = await conn.fetch(
+ f"select {columns} "
+ "from source_post where created_at <= $1 "
+ "order by created_at, post_title",
+ knowledge_cutoff,
+ )
+ else:
+ return []
+ affiliated = {str(entity_id) for entity_id in affiliated_entity_ids}
+ return [
+ row
+ for row in rows
+ if row["visibility_code"] == "public"
+ or str(row["corporate_entity_id"]) in affiliated
+ ]
+
+
async def fetch_visible_scope_posts(
conn: asyncpg.Connection,
scope_kind_code: str,
@@ -585,6 +803,7 @@ async def create_pending_analysis_run(
""",
capture.snapshot_sha256,
)
+ await persist_snapshot_members(conn, snapshot_id, post_ids)
count_exists = await conn.fetchval(
"""
select 1 from analysis_source_count
diff --git a/backend/app/analysis_run_start.py b/backend/app/analysis_run_start.py
new file mode 100644
index 000000000..10e16d477
--- /dev/null
+++ b/backend/app/analysis_run_start.py
@@ -0,0 +1,214 @@
+"""Start a Pending lineage reconstruction without inventing a TEPP score.
+
+ADR 0020. ``POST /api/analysis-runs/{id}/start`` locks the run, then
+transitions Pending to Running, runs ThreadWeave on the create-time
+snapshot members, persists run-scoped edges, then stamps Succeeded.
+A raced insert is 409. TEPP stays a wire client.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import json
+from datetime import datetime, timezone
+from typing import Any
+from uuid import UUID
+
+import asyncpg
+
+from backend.app.analysis_run_ingestion import (
+ AnalysisRunCreateError,
+ fetch_cutoff_reconstruct_posts,
+ fetch_snapshot_member_posts,
+ fetch_visible_analysis_run,
+)
+from backend.app.lineage_ingestion import records_from_source_posts
+from lineageweave.lineage_persistence import lineage_edge_specs
+from lineageweave.models import Edge
+
+_LINEAGE_KIND = "analysis_run_lineage"
+_PENDING = "analysis_status_pending"
+_RUNNING = "analysis_status_running"
+_SUCCEEDED = "analysis_status_succeeded"
+
+
+class AnalysisRunStartError(AnalysisRunCreateError):
+ """Fail-closed start: HTTP status plus a next-action detail string."""
+
+
+def reconstruction_result_digest(edges: list[Edge]) -> str:
+ """SHA-256 of the ordered parent choices. Never hashes a post body."""
+ material = json.dumps(
+ [
+ {
+ "child_post_id": edge.child_id,
+ "fused_score": round(float(edge.fused_score), 6),
+ "parent_post_id": edge.parent_id,
+ }
+ for edge in sorted(edges, key=lambda item: (item.child_id, item.parent_id))
+ ],
+ separators=(",", ":"),
+ sort_keys=True,
+ )
+ return hashlib.sha256(material.encode()).hexdigest()
+
+
+async def _append_status(
+ conn: asyncpg.Connection,
+ analysis_run_id: str,
+ status_ordinal: int,
+ status_code: str,
+ occurred_at: datetime,
+ failure_code: str | None = None,
+) -> None:
+ """Append one legal lifecycle event. Failed rows carry a machine code."""
+ await conn.execute(
+ """
+ insert into analysis_run_status_event
+ (analysis_run_id, status_ordinal, status_code, occurred_at, failure_code)
+ values ($1, $2, $3, $4, $5)
+ """,
+ analysis_run_id,
+ status_ordinal,
+ status_code,
+ occurred_at,
+ failure_code,
+ )
+
+
+def _next_status_ordinal(current: dict[str, Any]) -> int:
+ """Continue the append-only lifecycle after the last visible event."""
+ history = current.get("status_history") or []
+ ordinals = [int(event["status_ordinal"]) for event in history]
+ return (max(ordinals) if ordinals else 0) + 1
+
+
+async def start_pending_analysis_run(
+ conn: asyncpg.Connection,
+ *,
+ analysis_run_id: str,
+ account_id: str,
+ affiliated_entity_ids: list[str],
+) -> dict[str, Any]:
+ """Run ThreadWeave on a visible Pending lineage row.
+
+ TEPP is rejected so this path cannot invent a theta. A Succeeded
+ retry returns the stored reconstruction. Hidden runs 404.
+ """
+ try:
+ UUID(analysis_run_id)
+ except ValueError as exc:
+ raise AnalysisRunStartError(404, "This analysis run is not visible.") from exc
+
+ current = await fetch_visible_analysis_run(
+ conn,
+ analysis_run_id,
+ account_id,
+ affiliated_entity_ids,
+ )
+ if current is None:
+ raise AnalysisRunStartError(404, "This analysis run is not visible.")
+ if current["run_kind_code"] != _LINEAGE_KIND:
+ raise AnalysisRunStartError(
+ 422,
+ "Connect a TEPP transport from a Failed TEPP row. "
+ "This start path does not invent a measurement.",
+ )
+ if current["status_code"] == _SUCCEEDED:
+ return current
+
+ locked = await conn.fetchrow(
+ """
+ select run.analysis_run_id, run.knowledge_cutoff,
+ run.analysis_source_snapshot_id,
+ scope.scope_kind_code, scope.corporate_entity_id,
+ scope.process_unit_id, scope.scope_key
+ from analysis_run run
+ join analysis_run_scope scope on scope.analysis_run_id = run.analysis_run_id
+ where run.analysis_run_id = $1
+ for update of run
+ """,
+ analysis_run_id,
+ )
+ if locked is None:
+ raise AnalysisRunStartError(404, "This analysis run is not visible.")
+ current = await fetch_visible_analysis_run(
+ conn,
+ analysis_run_id,
+ account_id,
+ affiliated_entity_ids,
+ )
+ if current is None:
+ raise AnalysisRunStartError(404, "This analysis run is not visible.")
+ if current["status_code"] == _SUCCEEDED:
+ return current
+ if current["status_code"] != _PENDING:
+ raise AnalysisRunStartError(
+ 409,
+ "Open this run. Start is only for a Pending lineage reconstruction.",
+ )
+
+ now = datetime.now(timezone.utc)
+ running_ordinal = _next_status_ordinal(current)
+ try:
+ await _append_status(conn, analysis_run_id, running_ordinal, _RUNNING, now)
+ rows = await fetch_snapshot_member_posts(
+ conn, locked["analysis_source_snapshot_id"]
+ )
+ if rows is None:
+ rows = await fetch_cutoff_reconstruct_posts(
+ conn,
+ locked["scope_kind_code"],
+ locked["corporate_entity_id"],
+ locked["process_unit_id"],
+ locked["scope_key"],
+ affiliated_entity_ids,
+ locked["knowledge_cutoff"],
+ )
+ edges = lineage_edge_specs(records_from_source_posts(rows))
+ digest = reconstruction_result_digest(edges)
+ finished = datetime.now(timezone.utc)
+ if finished < now:
+ finished = now
+ await conn.execute(
+ """
+ insert into analysis_run_reconstruction
+ (analysis_run_id, result_sha256, edge_count, reconstructed_at)
+ values ($1, $2, $3, $4)
+ """,
+ analysis_run_id,
+ digest,
+ len(edges),
+ finished,
+ )
+ for edge in edges:
+ await conn.execute(
+ """
+ insert into analysis_run_lineage_edge
+ (analysis_run_id, child_post_id, parent_post_id,
+ fused_score, reconstructed_at)
+ values ($1, $2, $3, $4, $5)
+ """,
+ analysis_run_id,
+ edge.child_id,
+ edge.parent_id,
+ edge.fused_score,
+ finished,
+ )
+ await _append_status(
+ conn, analysis_run_id, running_ordinal + 1, _SUCCEEDED, finished
+ )
+ except asyncpg.UniqueViolationError as exc:
+ raise AnalysisRunStartError(
+ 409,
+ "Open this run. Start is only for a Pending lineage reconstruction.",
+ ) from exc
+ started = await fetch_visible_analysis_run(
+ conn,
+ analysis_run_id,
+ account_id,
+ affiliated_entity_ids,
+ )
+ if started is None:
+ raise AnalysisRunStartError(404, "This analysis run is not visible.")
+ return started
diff --git a/backend/app/main.py b/backend/app/main.py
index adb7a20a8..120c8d33a 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -72,6 +72,10 @@
fetch_visible_analysis_run,
fetch_visible_analysis_runs,
)
+from backend.app.analysis_run_start import (
+ AnalysisRunStartError,
+ start_pending_analysis_run,
+)
from backend.app.activity_stream import (
create_valkey_client,
get_valkey,
@@ -1253,6 +1257,32 @@ async def create_analysis_run(
return created
+@app.post("/api/analysis-runs/{analysis_run_id}/start")
+async def start_analysis_run(
+ analysis_run_id: str,
+ account: CurrentAccount = Depends(get_current_account),
+ pool: asyncpg.Pool = Depends(get_pool),
+) -> dict[str, Any]:
+ """Start ThreadWeave on a visible Pending lineage run.
+
+ post_read is enough. Hidden runs 404. TEPP is 422 so this path
+ cannot invent a theta. A Succeeded retry returns the stored tree.
+ """
+ _require_post_read(account)
+ async with pool.acquire() as conn:
+ async with conn.transaction():
+ try:
+ started = await start_pending_analysis_run(
+ conn,
+ analysis_run_id=analysis_run_id,
+ account_id=account.user_account_id,
+ affiliated_entity_ids=list(account.corporate_entity_ids),
+ )
+ except AnalysisRunStartError as exc:
+ raise HTTPException(exc.status_code, exc.detail) from exc
+ return started
+
+
@app.get("/api/analysis-runs/{analysis_run_id}")
async def read_analysis_run(
analysis_run_id: str,
diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py
index 424996afc..0935e17a2 100644
--- a/backend/tests/test_api.py
+++ b/backend/tests/test_api.py
@@ -32,6 +32,9 @@
_REALM = "lineageweave-demo"
_MIGRATION_PATH = Path(__file__).resolve().parents[2] / "migrations" / "0001_initial_schema.sql"
_REGISTRY_MIGRATION = Path(__file__).resolve().parents[2] / "migrations" / "0018_analysis_run_registry.sql"
+_RECONSTRUCTION_MIGRATION = (
+ Path(__file__).resolve().parents[2] / "migrations" / "0020_analysis_run_reconstruction.sql"
+)
def _postgres_available() -> bool:
@@ -115,6 +118,7 @@ def seeded_db(demo_analyst_token):
with conn.cursor() as cur:
cur.execute(_MIGRATION_PATH.read_text())
cur.execute(_REGISTRY_MIGRATION.read_text())
+ cur.execute(_RECONSTRUCTION_MIGRATION.read_text())
cur.execute(
"insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values "
"('corporate_entity_level', 'group', 'Group'), "
@@ -597,6 +601,101 @@ def test_create_analysis_run_records_pending_without_inventing_a_score(
assert unauthenticated.status_code == 401
+def test_start_analysis_run_recovers_the_a100_fork(
+ client, demo_analyst_token, seeded_db
+) -> None:
+ """Starting a Pending lineage run persists the designed fixture tree."""
+ from scripts.seed_demo_data import insert_fixture_source_posts
+
+ admin_conn = psycopg2.connect(seeded_db["dsn"])
+ admin_conn.autocommit = True
+ try:
+ with admin_conn.cursor() as cur:
+ cur.execute(
+ "insert into common_lookup_value (lookup_category, lookup_code, lookup_label) "
+ "values ('voc_type', 'vom', 'Voice of Market') "
+ "on conflict (lookup_code) do nothing"
+ )
+ cur.execute(
+ "insert into process_unit (corporate_entity_id, process_unit_code, process_unit_name) "
+ "select corporate_entity_id, 'TEST-PU-START', 'Start reconstruction' "
+ "from source_post where post_id = %s returning process_unit_id",
+ (seeded_db["own_private_post_id"],),
+ )
+ process_unit_id = cur.fetchone()[0]
+ cur.execute(
+ "select author_account_id, corporate_entity_id from source_post where post_id = %s",
+ (seeded_db["own_private_post_id"],),
+ )
+ author_id, corp_id = cur.fetchone()
+ insert_fixture_source_posts(cur, author_id, corp_id, process_unit_id)
+ finally:
+ admin_conn.close()
+
+ created = client.post(
+ "/api/analysis-runs",
+ headers={"Authorization": f"Bearer {demo_analyst_token}"},
+ json={
+ "run_kind_code": "analysis_run_lineage",
+ "corporate_entity_id": seeded_db["own_corp_id"],
+ "knowledge_cutoff": "2026-02-15T00:00:00Z",
+ "idempotency_key": "buyer-start-2026-w07",
+ },
+ )
+ assert created.status_code == 201, created.text
+ run_id = created.json()["analysis_run_id"]
+ assert created.json()["status_label"] == "Pending"
+
+ started = client.post(
+ f"/api/analysis-runs/{run_id}/start",
+ headers={"Authorization": f"Bearer {demo_analyst_token}"},
+ )
+ assert started.status_code == 200, started.text
+ body = started.json()
+ assert body["status_label"] == "Succeeded"
+ assert all(event["status_label"] != "Failed" for event in body["status_history"])
+ assert body["reconstruction_result_sha256"]
+ children = {
+ edge["child_post_title"]
+ for edge in body["reconstructed_edges"]
+ if edge["parent_post_title"] == "Pricing renegotiation follow-up"
+ }
+ assert "Pricing renegotiation: revised quote sent" in children
+ assert "Delivery schedule question raised" in children
+ assert "theta" not in str(body).lower()
+
+ replay = client.post(
+ f"/api/analysis-runs/{run_id}/start",
+ headers={"Authorization": f"Bearer {demo_analyst_token}"},
+ )
+ assert replay.status_code == 200
+ assert replay.json()["reconstruction_result_sha256"] == body["reconstruction_result_sha256"]
+
+ tepp = client.post(
+ "/api/analysis-runs",
+ headers={"Authorization": f"Bearer {demo_analyst_token}"},
+ json={
+ "run_kind_code": "analysis_run_tepp",
+ "corporate_entity_id": seeded_db["own_corp_id"],
+ "knowledge_cutoff": "2026-02-15T00:00:00Z",
+ "idempotency_key": "buyer-start-tepp-2026-w07",
+ },
+ )
+ assert tepp.status_code == 201
+ refused = client.post(
+ f"/api/analysis-runs/{tepp.json()['analysis_run_id']}/start",
+ headers={"Authorization": f"Bearer {demo_analyst_token}"},
+ )
+ assert refused.status_code == 422
+ assert "invent a measurement" in refused.json()["detail"]
+
+ hidden = client.post(
+ f"/api/analysis-runs/{seeded_db['hidden_run_id']}/start",
+ headers={"Authorization": f"Bearer {demo_analyst_token}"},
+ )
+ assert hidden.status_code == 404
+
+
def test_me_reflects_the_authenticated_account(client, demo_analyst_token) -> None:
response = client.get("/api/me", headers={"Authorization": f"Bearer {demo_analyst_token}"})
assert response.status_code == 200
diff --git a/docker/postgres-init/Dockerfile b/docker/postgres-init/Dockerfile
index e394d376e..c1d548993 100644
--- a/docker/postgres-init/Dockerfile
+++ b/docker/postgres-init/Dockerfile
@@ -25,6 +25,7 @@ COPY migrations/0016_cross_post_actor_identity.sql /docker-entrypoint-initdb.d/1
COPY migrations/0017_prov_o_standard_relations.sql /docker-entrypoint-initdb.d/18-prov-o-standard-relations.sql
COPY migrations/0018_analysis_run_registry.sql /docker-entrypoint-initdb.d/19-analysis-run-registry.sql
COPY migrations/0019_role_catalog_identity.sql /docker-entrypoint-initdb.d/20-role-catalog-identity.sql
+COPY migrations/0020_analysis_run_reconstruction.sql /docker-entrypoint-initdb.d/21-analysis-run-reconstruction.sql
# Official image already drops to this account at runtime; declare it so
# the Dockerfile itself satisfies DS-0002 (explicit non-root USER).
USER postgres
diff --git a/docs/adr/0013-normalized-analysis-run-registry.md b/docs/adr/0013-normalized-analysis-run-registry.md
index b60164dc6..78f80ab91 100644
--- a/docs/adr/0013-normalized-analysis-run-registry.md
+++ b/docs/adr/0013-normalized-analysis-run-registry.md
@@ -238,8 +238,9 @@ Acceptance requires:
1. Add a transaction repository that creates snapshot, counts, run, scope, and
first status atomically and compares request digests on idempotent retries.
- `POST /api/analysis-runs` now records that Pending write (ADR 0017);
- reconstruction and live TEPP execution remain later slices.
+ `POST /api/analysis-runs` now records that Pending write (ADR 0017).
+ In-process start is ADR 0020; the durable outbox and live TEPP
+ execution remain later slices.
2. Add RBAC/ABAC-protected run list/detail endpoints and the DB-grounded
read-only administrator surface.
3. Add a normalized PostgreSQL outbox and Valkey delivery worker.
diff --git a/docs/adr/0017-authorized-analysis-run-create.md b/docs/adr/0017-authorized-analysis-run-create.md
index e3a535a18..aef156599 100644
--- a/docs/adr/0017-authorized-analysis-run-create.md
+++ b/docs/adr/0017-authorized-analysis-run-create.md
@@ -35,9 +35,9 @@ still owns reconstruction and live TEPP execution.
## Consequences
The home panel's **Request a lineage reconstruction** button records a
-Pending row the operator can open immediately. Reconstruction, TEPP
-transport, and the outbox worker remain later slices. Do not stamp
-Succeeded or invent a theta from this write.
+Pending row the operator can open immediately. In-process start is
+ADR 0020. TEPP transport and the durable outbox worker remain later
+slices. Do not stamp Succeeded or invent a theta from this write.
## References — APA 7th
diff --git a/docs/adr/0020-authorized-analysis-run-start.md b/docs/adr/0020-authorized-analysis-run-start.md
new file mode 100644
index 000000000..549efee8d
--- /dev/null
+++ b/docs/adr/0020-authorized-analysis-run-start.md
@@ -0,0 +1,101 @@
+# ADR 0020 — Operators start a pending lineage reconstruction
+
+**Decision status:** Accepted on this active PR; not protected-main truth until merge
+**Date:** 2026-08-17
+**Depends on:** ADR 0013 registry; ADR 0014 authorized read; ADR 0016 cutoff
+posts; ADR 0017 authorized create
+**Refs:** Issue #79 (Milestone 2 parent); ADR 0013 follow-up 3 (in-process
+start; durable outbox remains later)
+
+## Context
+
+ADR 0017 let an operator record a Pending analysis run. The home button
+said “Request a lineage reconstruction,” then the row stayed Pending.
+Seed still owned the only Succeeded Demo Corp tree. A buyer cannot
+treat a request they cannot start as a product.
+
+ADR 0013 follow-up 3 asked for a PostgreSQL outbox and Valkey worker.
+That durable delivery path is still later. This slice starts
+reconstruction in the authorized request so the operator can see the
+cutoff tree immediately. A crash after Running and before Succeeded
+rolls the transaction back to Pending.
+
+## Decision
+
+`POST /api/analysis-runs/{id}/start` requires `post_read` and, in one
+transaction:
+
+1. loads the authorized run (hidden scopes 404);
+2. rejects non-lineage kinds so TEPP cannot invent a theta;
+3. locks the run (`FOR UPDATE`), then re-reads status;
+4. replays a Succeeded run;
+5. accepts only Pending lineage (a raced insert is 409, not 500);
+6. appends Running, runs `lineage_edge_specs` / ThreadWeave on the
+ create-time `analysis_source_snapshot_member` bag (live `source_post`
+ is only a fallback when migration 0020 is absent), persists
+ `analysis_run_reconstruction` plus `analysis_run_lineage_edge`, then
+ appends Succeeded. Edge titles are ABAC-filtered on read.
+
+```mermaid
+sequenceDiagram
+ participant Operator
+ participant API
+ participant ThreadWeave
+ participant Registry
+ Operator->>API: POST /api/analysis-runs/{id}/start
+ API->>Registry: lock visible Pending lineage run
+ alt TEPP or other kind
+ API-->>Operator: 422 connect the measurement service
+ else already Succeeded
+ Registry-->>API: stored edges
+ API-->>Operator: 200 replay
+ else Pending lineage
+ Registry->>Registry: Running
+ API->>ThreadWeave: reconstruct cutoff records
+ ThreadWeave-->>API: parent choices
+ Registry->>Registry: reconstruction + edges + Succeeded
+ API-->>Operator: 200 titled edges
+ end
+```
+
+Rules:
+
+- Edges are run-scoped. This write does not replace live
+ `post_lineage_edge` (the Event Lineage panel stays a later rebuild).
+- The digest hashes parent id, child id, and rounded fused score — never
+ a post body, DSN, or image. The home detail shows that digest as
+ `Result` beside Code/Config.
+- Empty cutoff bags Succeed with zero edges.
+- Failed TEPP remains a `tepp_client` transport problem.
+- Create persists snapshot members. Start does not re-walk live posts
+ when those members exist.
+
+The home detail adds **Start reconstruction** on a Pending lineage row
+and lists titled parent→child edges after Succeeded.
+
+## Consequences
+
+Demo Analyst can request a run, start it, and confirm the designed A-100
+fork (revised quote and delivery question under the pricing follow-up)
+without a seed-only Succeeded row. The durable outbox / Valkey worker
+and live TEPP transport remain later slices. Do not stamp Succeeded
+from a missing reconstruct library, and do not invent a theta.
+
+## References — APA 7th
+
+International Organization for Standardization. (2019). *ISO 8601-1:2019:
+Date and time—Representations for information interchange—Part 1: Basic
+rules* (confirmed 2024; Amendment 1:2022).
+
+Jensen, C. S., & Snodgrass, R. T. (1999). Temporal data management.
+*IEEE Transactions on Knowledge and Data Engineering, 11*(1), 36–44.
+https://doi.org/10.1109/69.755613
+
+Moreau, L., & Missier, P. (Eds.). (2013). *PROV-DM: The PROV data model*.
+World Wide Web Consortium. https://www.w3.org/TR/prov-dm/
+
+World Wide Web Consortium. (2013). *PROV-O: The PROV ontology* (W3C
+Recommendation). https://www.w3.org/TR/prov-o/
+
+World Wide Web Consortium. (2022). *Time ontology in OWL* (W3C
+Recommendation). https://www.w3.org/TR/owl-time/
diff --git a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md
index 4e65dd277..a04d209ba 100644
--- a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md
+++ b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md
@@ -71,6 +71,9 @@ provenance, retention, and immutable evidence rather than blanket masking.
| One snapshot supports multiple analyses | Insert two runs over one snapshot with different valid cutoffs. |
| Future evidence is excluded | Reject a run whose cutoff precedes the snapshot's maximum availability time. A late own-corp post stays out of `visible_posts`. |
| Evidence cannot change after derivation | Reject snapshot/count updates and count insert/delete after the first run. |
+| Start reconstructs the create-time bag | Persist `analysis_source_snapshot_member` at create; start from those ids, not a live `source_post` walk (ADR 0020). |
+| Concurrent start is one legal winner | Lock the run before Running; map `UniqueViolation` to 409. |
+| Result digest is verifiable | Home detail shows `Result` with an audible prefix; hover reads the full SHA-256. |
| Count/run race is serialized | Both paths acquire the snapshot row first; a later concurrency test must prove one legal winner and no lost freeze. |
| Request identity is stable | Reject analysis-run updates; scope and lifecycle live in their own relations. |
| Idempotency is actor-scoped | Permit identical opaque keys for two accounts and reject reuse by the same account. |
diff --git a/frontend/package.json b/frontend/package.json
index a9cf709cf..51401bfd3 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
- "version": "0.88.0",
+ "version": "0.89.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index 42b99c766..94c998dc2 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -342,6 +342,61 @@ describe("App, authenticated", () => {
}),
);
}
+ if (url.endsWith("/api/analysis-runs/run-demo-lineage-pending/start") && method === "POST") {
+ return Promise.resolve(
+ jsonResponse({
+ analysis_run_id: "run-demo-lineage-pending",
+ run_kind_code: "analysis_run_lineage",
+ run_kind_label: "Lineage reconstruction",
+ scope_kind_code: "analysis_scope_corporate_entity",
+ scope_kind_label: "Corporate entity",
+ scope_entity_name: "Demo Corp",
+ status_code: "analysis_status_succeeded",
+ status_label: "Succeeded",
+ knowledge_cutoff: "2026-01-12T12:00:00Z",
+ requested_at: "2026-01-12T12:35:00Z",
+ source_counts: [],
+ visible_posts: [{ post_id: "post-1", post_title: "Public post" }],
+ reconstructed_edges: [
+ {
+ parent_post_id: "post-follow-up",
+ parent_post_title: "Pricing renegotiation follow-up",
+ child_post_id: "post-quote",
+ child_post_title: "Pricing renegotiation: revised quote sent",
+ fused_score: 0.72,
+ },
+ {
+ parent_post_id: "post-follow-up",
+ parent_post_title: "Pricing renegotiation follow-up",
+ child_post_id: "post-delivery",
+ child_post_title: "Delivery schedule question raised",
+ fused_score: 0.68,
+ },
+ ],
+ reconstruction_result_sha256: "aa".repeat(32),
+ status_history: [
+ {
+ status_ordinal: 1,
+ status_code: "analysis_status_pending",
+ status_label: "Pending",
+ occurred_at: "2026-01-12T12:35:00Z",
+ },
+ {
+ status_ordinal: 2,
+ status_code: "analysis_status_running",
+ status_label: "Running",
+ occurred_at: "2026-01-12T12:36:00Z",
+ },
+ {
+ status_ordinal: 3,
+ status_code: "analysis_status_succeeded",
+ status_label: "Succeeded",
+ occurred_at: "2026-01-12T12:37:00Z",
+ },
+ ],
+ }),
+ );
+ }
if (url.endsWith("/api/analysis-runs") && method === "POST") {
const created = {
analysis_run_id: "run-demo-lineage-pending",
@@ -356,6 +411,7 @@ describe("App, authenticated", () => {
requested_at: "2026-01-12T12:35:00Z",
source_counts: [],
visible_posts: [{ post_id: "post-1", post_title: "Public post" }],
+ reconstructed_edges: [],
status_history: [
{
status_ordinal: 1,
@@ -1873,7 +1929,8 @@ describe("App, authenticated", () => {
expect(
await screen.findByRole("heading", { name: "Lineage reconstruction · Pending · Demo Corp" }),
).toBeInTheDocument();
- expect(screen.getByText(/has not started yet/)).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Start reconstruction" })).toBeInTheDocument();
+ expect(screen.getAllByText(/has not started yet/).length).toBeGreaterThan(0);
const postCall = fetchMock.mock.calls.find(
(call) => String(call[0]).endsWith("/api/analysis-runs") && call[1]?.method === "POST",
);
@@ -1885,6 +1942,33 @@ describe("App, authenticated", () => {
);
});
+ it("starts reconstruction and shows the designed A-100 fork", async () => {
+ const fetchMock = stubBackend();
+ render(
{analysisRunNextAction(selected)}
+ )} + {selected.run_kind_code === "analysis_run_lineage" && + selected.status_code === "analysis_status_pending" && ( + + )} + {selected.reconstructed_edges && selected.reconstructed_edges.length > 0 && ( +