diff --git a/AGENTS.md b/AGENTS.md
index 47a71c8c3..cebc790a9 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -91,6 +91,10 @@ A run-bearing analysis-run registry empties only after an unrevoked
(ADR 0020 / v0.87.0). The documented phrase is not a secret. Do not
expose purge on a public HTTP route.
+`POST /api/analysis-runs/{id}/start` reconstructs a Pending lineage
+cutoff bag through `reconstruct()` / `lineage_edge_specs` (ADR 0021 /
+v0.88.0). TEPP and period-report start stay 422. Do not invent a theta.
+
## CI gates
`.github/workflows/tests.yml` runs the full suite on every PR to `main`.
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index bf19c5f77..96b775ba5 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -472,10 +472,14 @@ run's scope whose `created_at` is at or before `knowledge_cutoff`
without seeing later live rows or hidden bodies. Detail also returns
revision and configuration digest prefixes.
`POST /api/analysis-runs` records a Pending run on a new authorized
-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.
+cutoff capture (ADR 0017): snapshot, counts, frozen membership, run,
+scope, and the first status in one transaction.
+`POST /api/analysis-runs/{id}/start` then runs ThreadWeave on that
+frozen bag and persists run-scoped edges (ADR 0021). It does not invent
+a TEPP score. Request a lineage reconstruction from the home list, open
+the Pending row, then start reconstruction. Hover the Result digest
+prefix, then confirm the designed A-100 fork before treating the live
+Event Lineage panel as that run's tree.
`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.
@@ -492,7 +496,8 @@ calibrated negative result. A failed lineage row tells the operator
to retry reconstruction, not to connect TEPP. A failed period-report
row tells the operator to rebuild the report. A pending TEPP row
does not claim a calibrated measurement. A pending lineage row
-says reconstruction has not started yet. The
+says reconstruction has not started yet; open it and start
+reconstruction. The
payload is lookup labels plus non-negative aggregate counts -- never
source SQL, a DSN, a raw record, or a provider body. After `make seed`,
Demo Analyst and Demo Admin see "Lineage reconstruction · Succeeded ·
diff --git a/CHANGELOG.d/0.88.0-analysis-run-start.md b/CHANGELOG.d/0.88.0-analysis-run-start.md
new file mode 100644
index 000000000..a94209c26
--- /dev/null
+++ b/CHANGELOG.d/0.88.0-analysis-run-start.md
@@ -0,0 +1,5 @@
+# 0.88.0 start a pending lineage reconstruction
+
+`POST /api/analysis-runs/{id}/start` runs ThreadWeave on a Pending
+lineage cutoff bag and persists run-scoped edges. Start reconstruction
+from the open run. This path does not invent a TEPP measurement.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 00a19fe92..42239b071 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.88.0] - 2026-08-16
+
+### Added
+
+- `POST /api/analysis-runs/{id}/start` runs ThreadWeave on a visible
+ Pending lineage cutoff bag and persists run-scoped parent choices
+ (ADR 0021). Open the Pending Demo Corp row, then start reconstruction.
+ The designed A-100 fork (revised quote and delivery question under the
+ pricing follow-up) is the acceptance tree. TEPP and period-report
+ start are 422 — this path does not invent a theta. A Succeeded retry
+ returns the stored digest. A Running restart is 409. Create freezes
+ authorized post ids so start cannot pick up a later backfill. Live
+ Event Lineage stays a separate rebuild.
+
## [0.87.0] - 2026-08-16
### Added
diff --git a/CLAUDE.md b/CLAUDE.md
index 870c77f87..5096a8ea5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -32,4 +32,6 @@ Digest prefixes stay audible; hover a prefix to read the full digest.
Opening a cutoff title shows the live post -- compare it with the
cutoff before treating the body as reconstructed evidence (ADR 0016).
`POST /api/analysis-runs` records Pending on an authorized
-cutoff capture (ADR 0017) and does not reconstruct lineage.
+cutoff capture (ADR 0017). `POST /api/analysis-runs/{id}/start`
+reconstructs that frozen cutoff bag (ADR 0021) and does not invent a
+theta. Hover the Result prefix to read the parent-choice digest.
diff --git a/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py
index d26eb6f6e..4fe53d760 100644
--- a/backend/app/analysis_run_ingestion.py
+++ b/backend/app/analysis_run_ingestion.py
@@ -6,9 +6,10 @@
lookup labels come back; source SQL, DSNs, raw records, and provider
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.
+``create_pending_analysis_run`` (ADR 0017) writes snapshot, counts, frozen
+membership, run, scope, and the first Pending event atomically.
+``start_pending_analysis_run`` (ADR 0021) later reconstructs lineage on
+that cutoff bag. Neither path invents a TEPP score.
"""
from __future__ import annotations
@@ -247,9 +248,123 @@ 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,
+ )
+ if digest is not None:
+ detail["reconstruction_result_sha256"] = digest
+ detail["reconstructed_edges"] = edges
return detail
+def reconstructed_edge_is_visible(
+ *,
+ parent_visibility_code: str,
+ parent_corporate_entity_id: Any,
+ child_visibility_code: str,
+ child_corporate_entity_id: Any,
+ affiliated_entity_ids: list[str],
+) -> bool:
+ """Hide an edge when either endpoint is outside the caller's ABAC bag."""
+ affiliated = {str(entity_id) for entity_id in affiliated_entity_ids}
+ parent_visible = (
+ parent_visibility_code == "public"
+ or str(parent_corporate_entity_id) in affiliated
+ )
+ child_visible = (
+ child_visibility_code == "public"
+ or str(child_corporate_entity_id) in affiliated
+ )
+ return parent_visible and child_visible
+
+
+async def fetch_reconstructed_edges(
+ conn: asyncpg.Connection,
+ analysis_run_id: str,
+ affiliated_entity_ids: list[str],
+) -> tuple[str | None, list[dict[str, Any]]]:
+ """Return the persisted digest and titled edges, or ``(None, [])``.
+
+ Missing reconstruction tables mean this database has not applied
+ migration 0021 yet; treat that as no stored tree rather than 500.
+ Titles follow the same public-or-affiliated rule as ``visible_posts``.
+ """
+ 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,
+ 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,
+ 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,
+ )
+ return header["result_sha256"], [
+ {
+ "parent_post_id": str(row["parent_post_id"]),
+ "parent_post_title": row["parent_post_title"],
+ "child_post_id": str(row["child_post_id"]),
+ "child_post_title": row["child_post_title"],
+ "fused_score": float(row["fused_score"]),
+ }
+ for row in rows
+ if reconstructed_edge_is_visible(
+ parent_visibility_code=row["parent_visibility_code"],
+ parent_corporate_entity_id=row["parent_corporate_entity_id"],
+ child_visibility_code=row["child_visibility_code"],
+ child_corporate_entity_id=row["child_corporate_entity_id"],
+ affiliated_entity_ids=affiliated_entity_ids,
+ )
+ ]
+
+
+async def persist_snapshot_members(
+ conn: asyncpg.Connection,
+ snapshot_id: Any,
+ post_ids: list[str],
+) -> None:
+ """Freeze authorized post ids on a new snapshot. Skip a legacy database."""
+ if not post_ids:
+ return
+ try:
+ await conn.executemany(
+ """
+ insert into analysis_source_snapshot_member
+ (analysis_source_snapshot_id, source_post_id)
+ values ($1, $2)
+ on conflict do nothing
+ """,
+ [(snapshot_id, post_id) for post_id in post_ids],
+ )
+ except asyncpg.UndefinedTableError:
+ return
+
+
async def fetch_visible_scope_posts(
conn: asyncpg.Connection,
scope_kind_code: str,
@@ -441,7 +556,7 @@ async def create_pending_analysis_run(
knowledge_cutoff: datetime | None,
idempotency_key: str,
) -> dict[str, Any]:
- """Insert snapshot, counts, run, scope, and Pending in one transaction.
+ """Insert snapshot, counts, frozen members, run, scope, and Pending.
Does not reconstruct lineage and does not call TEPP. A missing
measurement stays a later worker slice; this write only records the
@@ -574,6 +689,7 @@ async def create_pending_analysis_run(
capture.document_count,
capture.thread_count,
)
+ await persist_snapshot_members(conn, snapshot_id, post_ids)
try:
run_id = await conn.fetchval(
"""
diff --git a/backend/app/analysis_run_start.py b/backend/app/analysis_run_start.py
new file mode 100644
index 000000000..0e9e3a2e7
--- /dev/null
+++ b/backend/app/analysis_run_start.py
@@ -0,0 +1,336 @@
+"""Start a Pending lineage reconstruction without inventing a TEPP score.
+
+ADR 0021. ``POST /api/analysis-runs/{id}/start`` transitions Pending to
+Running, runs ThreadWeave on the frozen cutoff bag, persists run-scoped
+edges, then stamps Succeeded. TEPP and period-report stay other paths.
+"""
+
+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_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"
+_TEPP_KIND = "analysis_run_tepp"
+_REPORT_KIND = "analysis_run_report"
+_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()
+
+
+def start_kind_rejection(run_kind_code: str) -> AnalysisRunStartError | None:
+ """Return a 422 when start is not a lineage reconstruction.
+
+ TEPP and period-report keep their own transports. This path must not
+ invent a theta or a calibrated report score.
+ """
+ if run_kind_code == _LINEAGE_KIND:
+ return None
+ if run_kind_code == _TEPP_KIND:
+ return AnalysisRunStartError(
+ 422,
+ "Connect a TEPP transport from a Failed TEPP row. "
+ "This start path does not invent a measurement.",
+ )
+ if run_kind_code == _REPORT_KIND:
+ return AnalysisRunStartError(
+ 422,
+ "Rebuild the period report from the reports panel. "
+ "This start path does not invent a measurement.",
+ )
+ return AnalysisRunStartError(
+ 422,
+ "Start reconstructs a Pending lineage run only. "
+ "This start path does not invent a measurement.",
+ )
+
+
+def start_write_conflict_error() -> AnalysisRunStartError:
+ """Next action when a concurrent start already wrote this run."""
+ return AnalysisRunStartError(
+ 409,
+ "Open this run. Refresh to see the stored tree if start already finished.",
+ )
+
+
+def reconstruction_member_ids(
+ snapshot_member_ids: list[str],
+ cutoff_post_ids: list[str],
+) -> list[str]:
+ """Prefer create-time membership over a later cutoff re-query.
+
+ An empty member list means this database has not frozen the bag yet
+ (migration 0022 missing, or a legacy snapshot). Start then uses the
+ live cutoff query so those rows still reconstruct.
+ """
+ if snapshot_member_ids:
+ return list(snapshot_member_ids)
+ return list(cutoff_post_ids)
+
+
+async def _cutoff_source_posts(
+ conn: asyncpg.Connection,
+ *,
+ corporate_entity_id: Any,
+ knowledge_cutoff: Any,
+ affiliated_entity_ids: list[str],
+) -> list[asyncpg.Record]:
+ """ABAC-visible cutoff rows with the grouping keys reconstruct needs."""
+ rows = await conn.fetch(
+ """
+ select post_id, post_title, created_at, visibility_code,
+ corporate_entity_id, process_unit_id,
+ thread_group_key, secondary_grouping_key
+ from source_post
+ where corporate_entity_id = $1 and created_at <= $2
+ order by created_at, post_title
+ """,
+ corporate_entity_id,
+ knowledge_cutoff,
+ )
+ 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 _snapshot_member_posts(
+ conn: asyncpg.Connection,
+ snapshot_id: Any,
+) -> list[asyncpg.Record]:
+ """Load frozen capture rows, or empty when the member table is absent."""
+ try:
+ return list(
+ 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
+ """,
+ snapshot_id,
+ )
+ )
+ except asyncpg.UndefinedTableError:
+ return []
+
+
+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,
+ )
+
+
+async def _next_status_ordinal(
+ conn: asyncpg.Connection,
+ analysis_run_id: str,
+) -> int:
+ """Return the next contiguous status ordinal for this run."""
+ current_max = await conn.fetchval(
+ """
+ select coalesce(max(status_ordinal), 0)
+ from analysis_run_status_event
+ where analysis_run_id = $1
+ """,
+ analysis_run_id,
+ )
+ return int(current_max) + 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 and period-report are rejected so this path cannot invent a
+ theta. A Succeeded retry returns the stored reconstruction (documented
+ no-op replay). A Running or concurrent write is 409. Hidden runs 404.
+ The run row is locked before Running so a double-click is 409 or a
+ replay, never a 500.
+ """
+ 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.")
+ kind_error = start_kind_rejection(current["run_kind_code"])
+ if kind_error is not None:
+ raise kind_error
+ 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.",
+ )
+
+ locked = await conn.fetchrow(
+ """
+ select run.analysis_run_id, run.knowledge_cutoff,
+ run.analysis_source_snapshot_id, scope.corporate_entity_id
+ 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,
+ )
+ locked_status = await conn.fetchval(
+ """
+ select status_code
+ from analysis_run_current_status
+ where analysis_run_id = $1
+ """,
+ analysis_run_id,
+ )
+ if locked_status == _SUCCEEDED:
+ replayed = await fetch_visible_analysis_run(
+ conn,
+ analysis_run_id,
+ account_id,
+ affiliated_entity_ids,
+ )
+ if replayed is None:
+ raise AnalysisRunStartError(404, "This analysis run is not visible.")
+ return replayed
+ if locked_status != _PENDING:
+ raise AnalysisRunStartError(
+ 409,
+ "Open this run. Start is only for a Pending lineage reconstruction.",
+ )
+
+ now = datetime.now(timezone.utc)
+ running_ordinal = await _next_status_ordinal(conn, analysis_run_id)
+ try:
+ await _append_status(conn, analysis_run_id, running_ordinal, _RUNNING, now)
+ member_rows = await _snapshot_member_posts(
+ conn,
+ locked["analysis_source_snapshot_id"],
+ )
+ if member_rows:
+ rows = member_rows
+ else:
+ rows = await _cutoff_source_posts(
+ conn,
+ corporate_entity_id=locked["corporate_entity_id"],
+ knowledge_cutoff=locked["knowledge_cutoff"],
+ affiliated_entity_ids=affiliated_entity_ids,
+ )
+ 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 start_write_conflict_error() 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..b3da2497f 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,33 @@ 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 and period-report are 422
+ so this path cannot invent a theta. A Succeeded retry returns the
+ stored tree. A Running restart is 409.
+ """
+ _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 3b74c22a3..ef147e99f 100644
--- a/backend/tests/test_api.py
+++ b/backend/tests/test_api.py
@@ -33,6 +33,12 @@
_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"
_RETENTION_MIGRATION = Path(__file__).resolve().parents[2] / "migrations" / "0020_analysis_run_retention_purge.sql"
+_RECONSTRUCTION_MIGRATION = (
+ Path(__file__).resolve().parents[2] / "migrations" / "0021_analysis_run_reconstruction.sql"
+)
+_SNAPSHOT_MEMBER_MIGRATION = (
+ Path(__file__).resolve().parents[2] / "migrations" / "0022_analysis_source_snapshot_member.sql"
+)
def _postgres_available() -> bool:
@@ -117,6 +123,8 @@ def seeded_db(demo_analyst_token):
cur.execute(_MIGRATION_PATH.read_text())
cur.execute(_REGISTRY_MIGRATION.read_text())
cur.execute(_RETENTION_MIGRATION.read_text())
+ cur.execute(_RECONSTRUCTION_MIGRATION.read_text())
+ cur.execute(_SNAPSHOT_MEMBER_MIGRATION.read_text())
cur.execute(
"insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values "
"('corporate_entity_level', 'group', 'Group'), "
@@ -575,6 +583,216 @@ 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"]
+
+ admin_conn = psycopg2.connect(seeded_db["dsn"])
+ admin_conn.autocommit = True
+ try:
+ with admin_conn.cursor() as cur:
+ cur.execute(
+ """
+ insert into analysis_source_snapshot
+ (snapshot_sha256, source_contract_version,
+ maximum_available_time, captured_at)
+ values (%s, 'source-contract-v1',
+ '2026-01-12T00:00:00Z', '2026-01-12T00:05:00Z')
+ returning analysis_source_snapshot_id
+ """,
+ ("9" * 64,),
+ )
+ report_snapshot_id = cur.fetchone()[0]
+ cur.execute(
+ "select requested_by_account_id from analysis_run where analysis_run_id = %s",
+ (run_id,),
+ )
+ requester_id = cur.fetchone()[0]
+ 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_report', 'buyer-start-report',
+ %s, '2026-01-12T12:00:00Z', 'lineage-run-v1', %s, %s,
+ '2026-01-12T12:30:00Z')
+ returning analysis_run_id
+ """,
+ (report_snapshot_id, requester_id, "8" * 64, "7" * 40),
+ )
+ report_run_id = str(cur.fetchone()[0])
+ cur.execute(
+ """
+ insert into analysis_run_scope
+ (analysis_run_id, scope_kind_code, corporate_entity_id)
+ values (%s, 'analysis_scope_corporate_entity', %s)
+ """,
+ (report_run_id, seeded_db["own_corp_id"]),
+ )
+ cur.execute(
+ """
+ insert into analysis_run_status_event
+ (analysis_run_id, status_ordinal, status_code, occurred_at)
+ values (%s, 1, 'analysis_status_pending', '2026-01-12T12:31:00Z')
+ """,
+ (report_run_id,),
+ )
+ cur.execute(
+ """
+ insert into analysis_source_snapshot
+ (snapshot_sha256, source_contract_version,
+ maximum_available_time, captured_at)
+ values (%s, 'source-contract-v1',
+ '2026-01-12T00:00:00Z', '2026-01-12T00:05:00Z')
+ returning analysis_source_snapshot_id
+ """,
+ ("6" * 64,),
+ )
+ running_snapshot_id = cur.fetchone()[0]
+ 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_lineage', 'buyer-start-running',
+ %s, '2026-01-12T12:00:00Z', 'lineage-run-v1', %s, %s,
+ '2026-01-12T12:30:00Z')
+ returning analysis_run_id
+ """,
+ (running_snapshot_id, requester_id, "5" * 64, "4" * 40),
+ )
+ running_run_id = str(cur.fetchone()[0])
+ cur.execute(
+ """
+ insert into analysis_run_scope
+ (analysis_run_id, scope_kind_code, corporate_entity_id)
+ values (%s, 'analysis_scope_corporate_entity', %s)
+ """,
+ (running_run_id, seeded_db["own_corp_id"]),
+ )
+ for ordinal, status, occurred in (
+ (1, "analysis_status_pending", "2026-01-12T12:31:00Z"),
+ (2, "analysis_status_running", "2026-01-12T12:32:00Z"),
+ ):
+ cur.execute(
+ """
+ insert into analysis_run_status_event
+ (analysis_run_id, status_ordinal, status_code, occurred_at)
+ values (%s, %s, %s, %s)
+ """,
+ (running_run_id, ordinal, status, occurred),
+ )
+ finally:
+ admin_conn.close()
+
+ report_refused = client.post(
+ f"/api/analysis-runs/{report_run_id}/start",
+ headers={"Authorization": f"Bearer {demo_analyst_token}"},
+ )
+ assert report_refused.status_code == 422
+ assert "invent a measurement" in report_refused.json()["detail"]
+
+ running = client.post(
+ f"/api/analysis-runs/{running_run_id}/start",
+ headers={"Authorization": f"Bearer {demo_analyst_token}"},
+ )
+ assert running.status_code == 409
+
+ 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 ce2f0e6b5..71e9fc733 100644
--- a/docker/postgres-init/Dockerfile
+++ b/docker/postgres-init/Dockerfile
@@ -26,6 +26,8 @@ COPY migrations/0017_prov_o_standard_relations.sql /docker-entrypoint-initdb.d/1
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_retention_purge.sql /docker-entrypoint-initdb.d/21-analysis-run-retention-purge.sql
+COPY migrations/0021_analysis_run_reconstruction.sql /docker-entrypoint-initdb.d/22-analysis-run-reconstruction.sql
+COPY migrations/0022_analysis_source_snapshot_member.sql /docker-entrypoint-initdb.d/23-analysis-source-snapshot-member.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 d13994bc5..afc601897 100644
--- a/docs/adr/0013-normalized-analysis-run-registry.md
+++ b/docs/adr/0013-normalized-analysis-run-registry.md
@@ -239,8 +239,11 @@ 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).
+ `POST /api/analysis-runs/{id}/start` now reconstructs a Pending
+ lineage cutoff bag in-process from frozen snapshot membership
+ (ADR 0021). A durable outbox / Valkey worker 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..be7328877 100644
--- a/docs/adr/0017-authorized-analysis-run-create.md
+++ b/docs/adr/0017-authorized-analysis-run-create.md
@@ -22,7 +22,8 @@ still owns reconstruction and live TEPP execution.
they already walk. An unaffiliated corp is 404, not 403.
- The capture digest hashes scope, entity, cutoff, and authorized post
ids — never a post body, DSN, or source SQL.
-- The write inserts snapshot, aggregate counts, `analysis_run`,
+- The write inserts snapshot, aggregate counts, frozen
+ `analysis_source_snapshot_member` ids, `analysis_run`,
`analysis_run_scope`, and `analysis_status_pending` in one transaction.
- The first status is Pending. This slice does not reconstruct lineage
and does not call TEPP. A missing measurement stays Failed only on the
@@ -35,9 +36,10 @@ 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. `POST
+/api/analysis-runs/{id}/start` then reconstructs that frozen bag
+(ADR 0021). TEPP transport and the outbox worker remain later slices.
+Do not stamp Succeeded or invent a theta from this write.
## References — APA 7th
diff --git a/docs/adr/0021-authorized-analysis-run-start.md b/docs/adr/0021-authorized-analysis-run-start.md
new file mode 100644
index 000000000..61db36679
--- /dev/null
+++ b/docs/adr/0021-authorized-analysis-run-start.md
@@ -0,0 +1,112 @@
+# ADR 0021 — Operators start a pending lineage reconstruction
+
+**Decision status:** Accepted on this active PR; not protected-main truth until merge
+**Date:** 2026-08-16
+**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.
+
+Landed #145 occupies ADR 0020 / package 0.87.0 for granted retention
+purge. ADR 0019 binds R&R catalog identity. This decision is the next
+free slot.
+
+## 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 and period-report cannot invent a
+ theta or a calibrated score;
+3. replays a Succeeded run (documented no-op; same stored digest);
+4. accepts only Pending lineage — Running is 409;
+5. locks the run row, re-reads status, appends Running, runs
+ `lineage_edge_specs` / `reconstruct()` on the frozen
+ `analysis_source_snapshot_member` bag (or the live cutoff query when
+ membership was never persisted), persists
+ `analysis_run_reconstruction` plus `analysis_run_lineage_edge`, then
+ appends Succeeded. A concurrent start is 409 with a refresh next
+ action, not a 500.
+
+```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 period-report
+ API-->>Operator: 422 use the kind's own path
+ else already Succeeded
+ Registry-->>API: stored edges
+ API-->>Operator: 200 replay
+ else Running
+ API-->>Operator: 409 refresh
+ else Pending lineage
+ Registry->>Registry: Running
+ API->>ThreadWeave: reconstruct frozen bag
+ 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.
+- Empty cutoff bags Succeed with zero edges.
+- Failed TEPP remains a `tepp_client` transport problem.
+- Create freezes authorized post ids on
+ `analysis_source_snapshot_member` so start cannot pick up a later
+ backfill that shares the cutoff clock.
+
+The home detail adds **Start reconstruction** on a Pending lineage row
+and lists titled parent→child edges after Succeeded. The Result digest
+prefix is audible next to Code and Config; hover it to verify the
+parent-choice hash. Edge titles stay public-or-affiliated. TEPP and
+period-report rows do not show the button.
+
+## 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 c776053b1..6d3427fa1 100644
--- a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md
+++ b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md
@@ -1,7 +1,8 @@
# Analysis-run registry standards and research traceability
**Status:** Active PR evidence; not protected-main truth until merge.
-**Scope:** Migrations 0018 and 0020, ADR 0013 / 0020, rollback, and real-PostgreSQL contract tests.
+**Scope:** Migrations 0018–0022, ADR 0013 / 0017 / 0020 / 0021, rollback, and
+real-PostgreSQL contract tests.
## Standards mapped to implementation
@@ -14,7 +15,8 @@
| PostgreSQL 18 constraints and trigger contracts | Put integrity close to durable truth and use constraints for row shape while triggers enforce cross-row state and serialization. | Digest/check constraints, category allowlists, account-scoped uniqueness, shape constraints, immutable-row triggers, shared snapshot-row locking, and serialized status transitions. |
| NIST SP 800-92 | Treat audit records as bounded, protected operational evidence rather than unstructured application logging. | Append-only status events, machine failure codes, actor identity, occurrence/record clocks, fail-closed rollback, `invoking_session_role` on each retention event, and exclusion of raw source/provider payloads. |
| NIST SP 800-53 Rev. 5 AC-3 | Enforce least privilege on privileged procedures; a well-known procedure name is not an authorization secret. | `REVOKE ALL` on `purge_analysis_run_registry` from `PUBLIC`; `GRANT EXECUTE` only to `analysis_run_retention_admin`; unrevoked `analysis_run_retention_grant` required (ADR 0020). |
-| OpenAPI 3.2.0 | Define explicit versioned API schemas rather than exposing database rows or implementation-specific payloads. | API intentionally deferred; ADR 0013 requires a source-redacting run list/detail contract before a product surface is claimed. |
+| OpenAPI 3.2.0 | Define explicit versioned API schemas rather than exposing database rows or implementation-specific payloads. | `GET` / `POST /api/analysis-runs` and `POST /api/analysis-runs/{id}/start` return labels, clocks, aggregates, and titled reconstruction edges — never source SQL or a provider body. |
+| ThreadWeave tree assembly | Persist the same parent choices the library reconstructs on the cutoff bag. | `start_pending_analysis_run` calls `lineage_edge_specs` on frozen `analysis_source_snapshot_member` rows (or the live cutoff query when membership is absent); tests require the designed A-100 fork through `records_from_source_posts` (revised quote + delivery question under the pricing follow-up). |
## Temporal reasoning
@@ -77,12 +79,16 @@ provenance, retention, and immutable evidence rather than blanket masking.
| Idempotency is actor-scoped | Permit identical opaque keys for two accounts and reject reuse by the same account. |
| Lifecycle is ordered | Require pending first, contiguous ordinals, monotonic time, legal transitions, terminal finality, and append-only rows. |
| Rollback does not erase audit data silently | Reject 0018 rollback with any registry rows. A run-bearing registry empties only through an unrevoked `analysis_run_retention_grant` plus `analysis_run_retention_admin`, then `purge_analysis_run_registry('approved-retention-purge')`; a wrong token, a raw `DELETE`, and a runtime role that only knows the public phrase stay rejected. Export then delete `analysis_run_retention_event` before 0020 rollback. |
+| Start reconstruction recovers the designed tree | Persist edges from `lineage_edge_specs` on the A-100 fixture bag via `records_from_source_posts`; the pricing follow-up must parent both the revised quote and the delivery question. A TEPP or period-report start must 422 without a theta. Snapshot members exclude a later backfill. A concurrent or Running start is 409. A Succeeded retry returns the stored digest. |
## APA 7th references
American Institute of Certified Public Accountants. (2017). *SOC 2®: SOC
for Service Organizations: Trust Services Criteria*.
+ContextualWisdomLab. (2026). *ThreadWeave* [Computer software].
+https://github.com/ContextualWisdomLab/ThreadWeave
+
International Organization for Standardization. (2016). *ISO 15489-1:2016:
Information and documentation—Records management—Part 1: Concepts and
principles*.
diff --git a/frontend/package.json b/frontend/package.json
index 0d43d9fa2..4c66c7205 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
- "version": "0.87.0",
+ "version": "0.88.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index fd8a15146..b06693250 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -329,6 +329,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",
@@ -343,6 +398,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,
@@ -1754,6 +1810,7 @@ describe("App, authenticated", () => {
expect(reportButton).not.toHaveTextContent("reconstruction");
await userEvent.click(reportButton);
+ expect(screen.queryByRole("button", { name: "Start reconstruction" })).not.toBeInTheDocument();
expect(
await screen.findByText(
"No posts were available at this cutoff for the period report. Open a later run, or ask an administrator to capture a newer snapshot.",
@@ -1776,6 +1833,7 @@ describe("App, authenticated", () => {
expect(screen.queryByText(/replace Failed/i)).not.toBeInTheDocument();
expect(screen.queryByText(/this TEPP run measured/i)).not.toBeInTheDocument();
expect(screen.queryByText(/Reconstruction has not started yet/)).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Start reconstruction" })).not.toBeInTheDocument();
});
it("does not tell a succeeded TEPP run to replace Failed", async () => {
@@ -1810,9 +1868,10 @@ describe("App, authenticated", () => {
).toBeInTheDocument();
expect(
screen.getAllByText(
- "Open this run to confirm which posts it will use. Reconstruction has not started yet.",
+ "Open this run, then start reconstruction. Reconstruction has not started yet.",
),
).toHaveLength(2);
+ expect(screen.getByRole("button", { name: "Start reconstruction" })).toBeInTheDocument();
const postCall = fetchMock.mock.calls.find(
(call) => String(call[0]).endsWith("/api/analysis-runs") && call[1]?.method === "POST",
);
@@ -1824,6 +1883,34 @@ describe("App, authenticated", () => {
);
});
+ it("starts reconstruction and shows the designed A-100 fork", async () => {
+ const fetchMock = stubBackend();
+ render(