From ff14607590a983b389ca531c93972b519346e49e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:07:22 +0000 Subject: [PATCH 1/2] feat: start a pending lineage reconstruction (v0.87.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/analysis-runs/{id}/start runs ThreadWeave on the authorized cutoff bag and persists run-scoped edges. The home detail starts that row so a buyer can confirm the designed A-100 fork. TEPP start stays 422 — this path never invents a theta. Co-authored-by: Seongho Bae --- ARCHITECTURE.md | 9 +- CHANGELOG.d/0.87.0-analysis-run-start.md | 5 + CHANGELOG.md | 12 ++ CLAUDE.md | 5 +- backend/app/analysis_run_ingestion.py | 59 ++++- backend/app/analysis_run_start.py | 203 ++++++++++++++++++ backend/app/main.py | 30 +++ backend/tests/test_api.py | 99 +++++++++ docker/postgres-init/Dockerfile | 1 + .../0013-normalized-analysis-run-registry.md | 6 +- .../0017-authorized-analysis-run-create.md | 3 +- .../adr/0019-authorized-analysis-run-start.md | 95 ++++++++ .../ANALYSIS_RUN_REGISTRY_REFERENCES.md | 10 +- frontend/package.json | 2 +- frontend/src/App.test.tsx | 81 +++++++ frontend/src/App.tsx | 46 +++- frontend/src/api.ts | 19 ++ lineageweave/__init__.py | 2 +- .../0019_analysis_run_reconstruction.sql | 79 +++++++ .../0019_analysis_run_reconstruction.sql | 37 ++++ pyproject.toml | 2 +- scripts/seed_demo_data.py | 1 + ...test_analysis_run_reconstruction_schema.py | 127 +++++++++++ tests/test_analysis_run_start.py | 45 ++++ 24 files changed, 962 insertions(+), 16 deletions(-) create mode 100644 CHANGELOG.d/0.87.0-analysis-run-start.md create mode 100644 backend/app/analysis_run_start.py create mode 100644 docs/adr/0019-authorized-analysis-run-start.md create mode 100644 migrations/0019_analysis_run_reconstruction.sql create mode 100644 migrations/rollback/0019_analysis_run_reconstruction.sql create mode 100644 tests/test_analysis_run_reconstruction_schema.py create mode 100644 tests/test_analysis_run_start.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index aa557bd7a..ee11eb8d2 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -473,9 +473,12 @@ 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. +status in one transaction. `POST /api/analysis-runs/{id}/start` then +runs ThreadWeave on that cutoff bag and persists run-scoped edges +(ADR 0019). It does not invent a TEPP score. Request a lineage +reconstruction from the home list, open the Pending row, then start +reconstruction. 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. diff --git a/CHANGELOG.d/0.87.0-analysis-run-start.md b/CHANGELOG.d/0.87.0-analysis-run-start.md new file mode 100644 index 000000000..f20b39d08 --- /dev/null +++ b/CHANGELOG.d/0.87.0-analysis-run-start.md @@ -0,0 +1,5 @@ +# 0.87.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 434c4d63b..9a98d23ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ 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.87.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 0019). Open the Pending run, then start reconstruction. The + designed A-100 fork (revised quote and delivery question under the + pricing follow-up) is the acceptance tree. TEPP start is 422 — this + path does not invent a theta. A Succeeded retry returns the stored + digest. Live Event Lineage stays a separate rebuild. + ## [0.86.0] - 2026-08-16 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 2f89bada7..d84602f08 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,7 +3,7 @@ Tool-specific pointer. Policy lives in [AGENTS.md](AGENTS.md) and the ADRs under `docs/adr/`. Do not fork those rules here. -## Analysis-run seed (v0.85.0) +## Analysis-run seed (v0.87.0) `make seed` writes a Demo Corp lineage run and a TEPP run on the same snapshot (ADR 0013). The TEPP path goes through `tepp_client`. A missing @@ -18,4 +18,5 @@ 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 cutoff bag (ADR 0019) and does not invent a theta. diff --git a/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py index d26eb6f6e..82f5be7b8 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 0019) later reconstructs lineage on that cutoff bag. Neither path +invents a TEPP score. """ from __future__ import annotations @@ -247,9 +248,63 @@ async def fetch_visible_analysis_run( affiliated_entity_ids, row["knowledge_cutoff"], ) + digest, edges = await fetch_reconstructed_edges(conn, analysis_run_id) + if digest is not None: + detail["reconstruction_result_sha256"] = digest + detail["reconstructed_edges"] = edges return detail +async def fetch_reconstructed_edges( + conn: asyncpg.Connection, + analysis_run_id: 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 0019 yet; treat that as no stored tree rather than 500. + """ + 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, + edge.child_post_id, + child_post.post_title as child_post_title, + 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 + ] + + async def fetch_visible_scope_posts( conn: asyncpg.Connection, scope_kind_code: str, diff --git a/backend/app/analysis_run_start.py b/backend/app/analysis_run_start.py new file mode 100644 index 000000000..184d622b4 --- /dev/null +++ b/backend/app/analysis_run_start.py @@ -0,0 +1,203 @@ +"""Start a Pending lineage reconstruction without inventing a TEPP score. + +ADR 0019. ``POST /api/analysis-runs/{id}/start`` transitions Pending to +Running, runs ThreadWeave on the authorized cutoff bag, persists +run-scoped edges, then stamps Succeeded. 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_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 _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 _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 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 + if current["status_code"] != _PENDING: + raise AnalysisRunStartError( + 409, + "Open this run. Start is only for a Pending lineage reconstruction.", + ) + + now = datetime.now(timezone.utc) + await _append_status(conn, analysis_run_id, 2, _RUNNING, now) + + locked = await conn.fetchrow( + """ + select run.analysis_run_id, run.knowledge_cutoff, + 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, + ) + 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, 3, _SUCCEEDED, finished) + 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 bff7f7c64..e447c6081 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" / "0019_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'), " @@ -573,6 +577,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 d95e2c917..b80b7b4d8 100644 --- a/docker/postgres-init/Dockerfile +++ b/docker/postgres-init/Dockerfile @@ -24,6 +24,7 @@ COPY migrations/0015_organization_name_resolution.sql /docker-entrypoint-initdb. COPY migrations/0016_cross_post_actor_identity.sql /docker-entrypoint-initdb.d/17-cross-post-actor-identity.sql 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_analysis_run_reconstruction.sql /docker-entrypoint-initdb.d/20-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..f9e09e07f 100644 --- a/docs/adr/0013-normalized-analysis-run-registry.md +++ b/docs/adr/0013-normalized-analysis-run-registry.md @@ -238,8 +238,10 @@ 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 (ADR 0019). 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..ec43f4ea4 100644 --- a/docs/adr/0017-authorized-analysis-run-create.md +++ b/docs/adr/0017-authorized-analysis-run-create.md @@ -1,6 +1,7 @@ # ADR 0017 — Operators request an analysis run through the product API -**Decision status:** Accepted on this active PR; not protected-main truth until merge +**Decision status:** Accepted; merged to the #74 stack via #125. Not +protected-main truth until #74 lands. **Date:** 2026-08-16 **Depends on:** ADR 0013 normalized analysis-run registry; ADR 0014 authorized analysis-run read; ADR 0016 knowledge-cutoff posts diff --git a/docs/adr/0019-authorized-analysis-run-start.md b/docs/adr/0019-authorized-analysis-run-start.md new file mode 100644 index 000000000..c80e4a6ed --- /dev/null +++ b/docs/adr/0019-authorized-analysis-run-start.md @@ -0,0 +1,95 @@ +# ADR 0019 — 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. + +## 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. replays a Succeeded run; +4. accepts only Pending lineage; +5. appends Running, runs `lineage_edge_specs` / ThreadWeave on the + ABAC-visible cutoff bag, persists `analysis_run_reconstruction` plus + `analysis_run_lineage_edge`, then appends Succeeded. + +```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. +- Empty cutoff bags Succeed with zero edges. +- Failed TEPP remains a `tepp_client` transport problem. + +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 b41b31c17..c4eaee918 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:** Migration 0018, ADR 0013, rollback, and real-PostgreSQL contract tests. +**Scope:** Migrations 0018–0019, ADR 0013 / 0017 / 0019, rollback, and +real-PostgreSQL contract tests. ## Standards mapped to implementation @@ -13,7 +14,8 @@ | ISO 8601-1:2019 | Use unambiguous timestamp representation and timezone-aware persistence. | PostgreSQL `timestamptz` for availability, capture, cutoff, request, occurrence, and record clocks; tests use explicit `Z` offsets. | | 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, and exclusion of raw source/provider payloads. | -| 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`; tests require the designed A-100 fork (revised quote + delivery question under the pricing follow-up). | ## Temporal reasoning @@ -76,9 +78,13 @@ 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 rollback with any registry rows and allow replay after explicit cleanup. | +| Start reconstruction recovers the designed tree | Persist edges from `lineage_edge_specs` on the A-100 fixture bag; the pricing follow-up must parent both the revised quote and the delivery question. A TEPP start must 422 without a theta. | ## APA 7th references +ContextualWisdomLab. (2026). *ThreadWeave* [Computer software]. +https://github.com/ContextualWisdomLab/ThreadWeave + 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). diff --git a/frontend/package.json b/frontend/package.json index dac241738..c93670d28 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.86.0", + "version": "0.87.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 934f150e4..0855a4ab7 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -269,6 +269,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", @@ -283,6 +338,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, @@ -1666,6 +1722,31 @@ describe("App, authenticated", () => { ); }); + it("starts reconstruction and shows the designed A-100 fork", async () => { + const fetchMock = stubBackend(); + render(); + + await userEvent.click( + await screen.findByRole("button", { name: "Request a lineage reconstruction" }), + ); + await userEvent.click(await screen.findByRole("button", { name: "Start reconstruction" })); + expect( + await screen.findByRole("heading", { name: "Lineage reconstruction · Succeeded · Demo Corp" }), + ).toBeInTheDocument(); + expect( + screen.getByText( + "Pricing renegotiation: revised quote sent follows Pricing renegotiation follow-up", + ), + ).toBeInTheDocument(); + expect( + screen.getByText("Delivery schedule question raised follows Pricing renegotiation follow-up"), + ).toBeInTheDocument(); + const startCall = fetchMock.mock.calls.find((call) => + String(call[0]).endsWith("/api/analysis-runs/run-demo-lineage-pending/start"), + ); + expect(startCall?.[1]?.method).toBe("POST"); + }); + it("shows the calibrated period-report mean theta on the home page", async () => { stubBackend(); render(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 83fb88860..5987f7af5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ import { askPostChat, BackendError, createAnalysisRun, + startAnalysisRun, createPostTicket, deriveCommitment, evaluatePost, @@ -1458,7 +1459,10 @@ function analysisRunCaption(run: AnalysisRun): string { */ function analysisRunNextAction(run: AnalysisRun): string | null { if (run.status_code === "analysis_status_pending") { - return "Open this run to confirm which posts it will use. Reconstruction has not started yet."; + if (run.run_kind_code === "analysis_run_tepp") { + return "Open this run to confirm which posts it will measure. Measurement has not started yet."; + } + return "Open this run, then start reconstruction. Reconstruction has not started yet."; } if (run.status_code !== "analysis_status_failed") { return null; @@ -1572,6 +1576,7 @@ function AnalysisRunsPanel({ const [selected, setSelected] = useState(null); const [error, setError] = useState(null); const [requesting, setRequesting] = useState(false); + const [starting, setStarting] = useState(false); useEffect(() => { fetchAnalysisRuns(accessToken) @@ -1597,6 +1602,22 @@ function AnalysisRunsPanel({ } } + async function handleStartReconstruction() { + if (!selected) return; + setError(null); + setStarting(true); + try { + const started = await startAnalysisRun(accessToken, selected.analysis_run_id); + const listed = await fetchAnalysisRuns(accessToken); + setRuns(listed.analysis_runs); + setSelected(started); + } catch (err) { + setError(err instanceof BackendError ? err.message : String(err)); + } finally { + setStarting(false); + } + } + async function handleOpen(runId: string) { setError(null); try { @@ -1675,6 +1696,29 @@ function AnalysisRunsPanel({ codeRevisionSha={selected.code_revision_sha} configurationSha256={selected.configuration_sha256} /> + {selected.status_code === "analysis_status_pending" && ( +

{analysisRunNextAction(selected)}

+ )} + {selected.run_kind_code === "analysis_run_lineage" && + selected.status_code === "analysis_status_pending" && ( + + )} + {selected.reconstructed_edges && selected.reconstructed_edges.length > 0 && ( +
    + {selected.reconstructed_edges.map((edge) => ( +
  • + {edge.child_post_title} follows {edge.parent_post_title} +
  • + ))} +
+ )}
    {selected.source_counts.map((count) => (
  • diff --git a/frontend/src/api.ts b/frontend/src/api.ts index f9bd4068e..208d0f0dc 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -522,6 +522,14 @@ export interface AnalysisRunStatusEvent { failure_code?: string; } +export interface AnalysisRunReconstructedEdge { + parent_post_id: string; + parent_post_title: string; + child_post_id: string; + child_post_title: string; + fused_score: number; +} + export interface AnalysisRun { analysis_run_id: string; run_kind_code: string; @@ -536,6 +544,8 @@ export interface AnalysisRun { source_counts: AnalysisRunCount[]; status_history?: AnalysisRunStatusEvent[]; visible_posts?: { post_id: string; post_title: string }[]; + reconstructed_edges?: AnalysisRunReconstructedEdge[]; + reconstruction_result_sha256?: string; code_revision_sha?: string; configuration_sha256?: string; } @@ -565,3 +575,12 @@ export function createAnalysisRun( body: JSON.stringify(request), }); } + +export function startAnalysisRun( + accessToken: string, + analysisRunId: string, +): Promise { + return backendFetch(`/api/analysis-runs/${analysisRunId}/start`, accessToken, { + method: "POST", + }); +} diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 5f70c6064..1950c39f8 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.86.0" +__version__ = "0.87.0" diff --git a/migrations/0019_analysis_run_reconstruction.sql b/migrations/0019_analysis_run_reconstruction.sql new file mode 100644 index 000000000..39d517760 --- /dev/null +++ b/migrations/0019_analysis_run_reconstruction.sql @@ -0,0 +1,79 @@ +-- Run-scoped lineage reconstruction result (ADR 0019). +-- +-- A Pending analysis run may later persist the ThreadWeave parent choices +-- for its cutoff bag. Edges belong to the run, not the live Event Lineage +-- panel. No post body, DSN, or fabricated measurement is stored. + +create table if not exists analysis_run_reconstruction ( + analysis_run_id uuid primary key + references analysis_run (analysis_run_id), + result_sha256 text not null, + edge_count integer not null, + reconstructed_at timestamptz not null, + recorded_at timestamptz not null default clock_timestamp(), + constraint analysis_run_reconstruction_digest_check + check (result_sha256 ~ '^[0-9a-f]{64}$'), + constraint analysis_run_reconstruction_edge_count_check + check (edge_count >= 0), + constraint analysis_run_reconstruction_time_check + check (reconstructed_at <= recorded_at) +); + +comment on table analysis_run_reconstruction is + 'One immutable reconstruction digest per analysis run; never a post body ' + 'or a fabricated psychometric score.'; + +create table if not exists analysis_run_lineage_edge ( + analysis_run_id uuid not null + references analysis_run_reconstruction (analysis_run_id), + child_post_id uuid not null + references source_post (post_id), + parent_post_id uuid not null + references source_post (post_id), + fused_score double precision not null, + reconstructed_at timestamptz not null, + primary key (analysis_run_id, child_post_id), + constraint analysis_run_lineage_edge_distinct_check + check (child_post_id <> parent_post_id), + constraint analysis_run_lineage_edge_score_check + check (fused_score >= 0 and fused_score <= 1) +); + +comment on table analysis_run_lineage_edge is + 'One reconstructed parent choice per child post inside one analysis run.'; + +create or replace function reject_analysis_run_reconstruction_update() +returns trigger +language plpgsql +as $$ +begin + raise exception 'analysis_run_reconstruction_is_immutable'; +end +$$; + +comment on function reject_analysis_run_reconstruction_update() is + 'Rejects mutation of a persisted reconstruction digest.'; + +drop trigger if exists analysis_run_reconstruction_update_reject + on analysis_run_reconstruction; +create trigger analysis_run_reconstruction_update_reject +before update or delete on analysis_run_reconstruction +for each row execute function reject_analysis_run_reconstruction_update(); + +create or replace function reject_analysis_run_lineage_edge_update() +returns trigger +language plpgsql +as $$ +begin + raise exception 'analysis_run_lineage_edge_is_immutable'; +end +$$; + +comment on function reject_analysis_run_lineage_edge_update() is + 'Rejects mutation of a persisted run-scoped lineage edge.'; + +drop trigger if exists analysis_run_lineage_edge_update_reject + on analysis_run_lineage_edge; +create trigger analysis_run_lineage_edge_update_reject +before update or delete on analysis_run_lineage_edge +for each row execute function reject_analysis_run_lineage_edge_update(); diff --git a/migrations/rollback/0019_analysis_run_reconstruction.sql b/migrations/rollback/0019_analysis_run_reconstruction.sql new file mode 100644 index 000000000..ce0c73588 --- /dev/null +++ b/migrations/rollback/0019_analysis_run_reconstruction.sql @@ -0,0 +1,37 @@ +-- Fail-closed rollback for migration 0019. +-- +-- Reconstruction evidence must be exported or explicitly deleted under an +-- approved retention procedure before these objects can be removed. + +begin; + +do $$ +declare + relation_name text; + relation_has_rows boolean; +begin + foreach relation_name in array array[ + 'analysis_run_lineage_edge', + 'analysis_run_reconstruction' + ] loop + if to_regclass('public.' || relation_name) is not null then + execute format('select exists (select 1 from %I)', relation_name) + into relation_has_rows; + if relation_has_rows then + raise exception 'analysis_run_reconstruction_not_empty'; + end if; + end if; + end loop; +end +$$; + +drop trigger if exists analysis_run_lineage_edge_update_reject + on analysis_run_lineage_edge; +drop trigger if exists analysis_run_reconstruction_update_reject + on analysis_run_reconstruction; +drop function if exists reject_analysis_run_lineage_edge_update(); +drop function if exists reject_analysis_run_reconstruction_update(); +drop table if exists analysis_run_lineage_edge; +drop table if exists analysis_run_reconstruction; + +commit; diff --git a/pyproject.toml b/pyproject.toml index 0393b774a..ecfe24877 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.86.0" +version = "0.87.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py index f6f575ccc..d4a109c11 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -120,6 +120,7 @@ def seed( cur.execute((migrations / "0015_organization_name_resolution.sql").read_text()) cur.execute((migrations / "0016_cross_post_actor_identity.sql").read_text()) cur.execute((migrations / "0018_analysis_run_registry.sql").read_text()) + cur.execute((migrations / "0019_analysis_run_reconstruction.sql").read_text()) cur.execute( """ insert into common_lookup_value (lookup_category, lookup_code, lookup_label, display_order) values diff --git a/tests/test_analysis_run_reconstruction_schema.py b/tests/test_analysis_run_reconstruction_schema.py new file mode 100644 index 000000000..390a9c74b --- /dev/null +++ b/tests/test_analysis_run_reconstruction_schema.py @@ -0,0 +1,127 @@ +"""Static and optional PostgreSQL contracts for run-scoped reconstruction.""" + +from __future__ import annotations + +import os +import re +import uuid +from pathlib import Path +from urllib.parse import urlsplit, urlunsplit + +import pytest + +_ROOT = Path(__file__).resolve().parents[1] +_INITIAL_MIGRATION = _ROOT / "migrations" / "0001_initial_schema.sql" +_REGISTRY_MIGRATION = _ROOT / "migrations" / "0018_analysis_run_registry.sql" +_RECONSTRUCTION_MIGRATION = _ROOT / "migrations" / "0019_analysis_run_reconstruction.sql" +_RECONSTRUCTION_ROLLBACK = _ROOT / "migrations" / "rollback" / "0019_analysis_run_reconstruction.sql" +_POSTGRES_IMAGE = _ROOT / "docker" / "postgres-init" / "Dockerfile" +_ADMIN_DSN = os.environ.get( + "LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN", "postgresql://localhost/postgres" +) +_REQUIRED_TABLES = { + "analysis_run_reconstruction", + "analysis_run_lineage_edge", +} + + +def test_reconstruction_migration_is_normalized_and_wired() -> None: + """Static contract: 3NF names, no payload JSON, Dockerfile copy, rollback.""" + migration = _RECONSTRUCTION_MIGRATION.read_text(encoding="utf-8") + rollback = _RECONSTRUCTION_ROLLBACK.read_text(encoding="utf-8") + dockerfile = _POSTGRES_IMAGE.read_text(encoding="utf-8") + created_tables = set( + re.findall(r"create table if not exists\s+([a-z0-9_]+)", migration, re.I) + ) + assert _REQUIRED_TABLES <= created_tables + assert "jsonb" not in migration.casefold() + assert "metadata_payload" not in migration + assert "theta" not in migration.casefold() + assert "0019_analysis_run_reconstruction.sql" in dockerfile + assert "analysis_run_reconstruction_not_empty" in rollback + assert "reject_analysis_run_reconstruction_update" in migration + assert "reject_analysis_run_lineage_edge_update" in migration + + object_patterns = ( + r"create table if not exists\s+([a-z0-9_]+)", + r"create or replace function\s+([a-z0-9_]+)", + r"create trigger\s+([a-z0-9_]+)", + ) + for pattern in object_patterns: + for object_name in re.findall(pattern, migration, re.I): + assert len(object_name.split("_")) >= 2, object_name + + +def _postgres_available() -> bool: + """Return whether the configured administrator DSN is reachable.""" + try: + import psycopg2 + + psycopg2.connect(_ADMIN_DSN, connect_timeout=2).close() + return True + except Exception: + return False + + +def _database_dsn(database_name: str) -> str: + """Replace only the database path while preserving DSN query options.""" + parsed = urlsplit(_ADMIN_DSN) + return urlunsplit(parsed._replace(path=f"/{database_name}")) + + +@pytest.fixture +def reconstruction_db(): + """Yield a throwaway registry+reconstruction database.""" + if not _postgres_available(): + pytest.skip("a reachable PostgreSQL administrator DSN is required") + import psycopg2 + + database_name = f"lineageweave_recon_{uuid.uuid4().hex[:12]}" + admin = psycopg2.connect(_ADMIN_DSN) + admin.autocommit = True + try: + with admin.cursor() as cursor: + cursor.execute(f'create database "{database_name}"') + finally: + admin.close() + conn = psycopg2.connect(_database_dsn(database_name)) + conn.autocommit = True + try: + with conn.cursor() as cursor: + cursor.execute(_INITIAL_MIGRATION.read_text(encoding="utf-8")) + cursor.execute(_REGISTRY_MIGRATION.read_text(encoding="utf-8")) + cursor.execute(_RECONSTRUCTION_MIGRATION.read_text(encoding="utf-8")) + yield conn + finally: + conn.close() + admin = psycopg2.connect(_ADMIN_DSN) + admin.autocommit = True + try: + with admin.cursor() as cursor: + cursor.execute( + "select pg_terminate_backend(pid) from pg_stat_activity " + "where datname = %s and pid <> pg_backend_pid()", + (database_name,), + ) + cursor.execute(f'drop database "{database_name}"') + finally: + admin.close() + + +def test_empty_reconstruction_rollback_is_replayable(reconstruction_db) -> None: + """An empty reconstruction schema can be rolled back and removed.""" + with reconstruction_db.cursor() as cursor: + cursor.execute( + "select table_name from information_schema.tables " + "where table_schema = 'public' and table_name = any(%s)", + (list(_REQUIRED_TABLES),), + ) + assert {row[0] for row in cursor.fetchall()} == _REQUIRED_TABLES + cursor.execute(_RECONSTRUCTION_ROLLBACK.read_text(encoding="utf-8")) + cursor.execute( + "select table_name from information_schema.tables " + "where table_schema = 'public' and table_name = any(%s)", + (list(_REQUIRED_TABLES),), + ) + assert cursor.fetchall() == [] + cursor.execute(_RECONSTRUCTION_ROLLBACK.read_text(encoding="utf-8")) diff --git a/tests/test_analysis_run_start.py b/tests/test_analysis_run_start.py new file mode 100644 index 000000000..239c5b5bb --- /dev/null +++ b/tests/test_analysis_run_start.py @@ -0,0 +1,45 @@ +"""Start-reconstruction contracts: digest stability and designed-tree fidelity.""" + +from lineageweave.fixtures import sample_records +from lineageweave.lineage_persistence import lineage_edge_specs + +from backend.app.analysis_run_start import ( + AnalysisRunStartError, + reconstruction_result_digest, +) + + +def test_reconstruction_digest_is_stable_and_ignores_edge_order() -> None: + """The same parent choices hash the same way regardless of insert order.""" + edges = lineage_edge_specs(sample_records()) + reversed_edges = list(reversed(edges)) + assert reconstruction_result_digest(edges) == reconstruction_result_digest(reversed_edges) + assert reconstruction_result_digest([]) == reconstruction_result_digest([]) + assert reconstruction_result_digest(edges) != reconstruction_result_digest([]) + + +def test_start_uses_the_same_parent_choices_as_library_reconstruct() -> None: + """The product start path must recover the designed A-100 fork. + + fixtures.sample_records() is the synthetic gold tree: rec-002 is the + branch point for the revised quote and the delivery question. A start + that dropped an edge or invented a parent would fail this check. + """ + edges = lineage_edge_specs(sample_records()) + children = { + edge.child_id for edge in edges if edge.parent_id == "rec-002" + } + assert children >= {"rec-003", "rec-004"} + assert all(0.0 <= edge.fused_score <= 1.0 for edge in edges) + assert "theta" not in reconstruction_result_digest(edges) + + +def test_start_error_carries_a_next_action() -> None: + """Operators get a next action, not an internal exception name.""" + error = AnalysisRunStartError( + 422, + "Connect a TEPP transport from a Failed TEPP row. " + "This start path does not invent a measurement.", + ) + assert error.status_code == 422 + assert "invent a measurement" in error.detail From 8ff0fee6a8e1b40b8b93085a0c488c7ec42b6d32 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:21:52 +0000 Subject: [PATCH 2/2] fix: freeze the cutoff bag before starting reconstruction (v0.87.1) Lock the Pending run before Running so a double-click is 409, persist snapshot membership at create, show the Result digest, and keep edge titles public-or-affiliated. CI now checks the A-100 fork through records_from_source_posts. This path still does not invent a theta. Co-authored-by: Seongho Bae --- ARCHITECTURE.md | 11 +- CHANGELOG.d/0.87.1-analysis-run-start-bag.md | 6 + CHANGELOG.md | 12 ++ CLAUDE.md | 5 +- backend/app/analysis_run_ingestion.py | 63 ++++++- backend/app/analysis_run_start.py | 170 ++++++++++++++---- backend/tests/test_api.py | 4 + docker/postgres-init/Dockerfile | 1 + .../0013-normalized-analysis-run-registry.md | 5 +- .../0017-authorized-analysis-run-create.md | 3 +- .../adr/0019-authorized-analysis-run-start.md | 14 +- .../ANALYSIS_RUN_REGISTRY_REFERENCES.md | 6 +- frontend/package.json | 2 +- frontend/src/App.test.tsx | 3 + frontend/src/App.tsx | 28 ++- lineageweave/__init__.py | 2 +- .../0020_analysis_source_snapshot_member.sql | 35 ++++ .../0020_analysis_source_snapshot_member.sql | 27 +++ pyproject.toml | 2 +- scripts/seed_demo_data.py | 32 ++++ ...test_analysis_run_reconstruction_schema.py | 17 ++ tests/test_analysis_run_start.py | 61 ++++++- 22 files changed, 444 insertions(+), 65 deletions(-) create mode 100644 CHANGELOG.d/0.87.1-analysis-run-start-bag.md create mode 100644 migrations/0020_analysis_source_snapshot_member.sql create mode 100644 migrations/rollback/0020_analysis_source_snapshot_member.sql diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ee11eb8d2..ebe0aae9b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -474,11 +474,12 @@ 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. `POST /api/analysis-runs/{id}/start` then -runs ThreadWeave on that cutoff bag and persists run-scoped edges -(ADR 0019). It does not invent a TEPP score. Request a lineage -reconstruction from the home list, open the Pending row, then start -reconstruction. Confirm the designed A-100 fork before treating the -live Event Lineage panel as that run's tree. +runs ThreadWeave on the frozen snapshot membership and persists +run-scoped edges (ADR 0019). 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. diff --git a/CHANGELOG.d/0.87.1-analysis-run-start-bag.md b/CHANGELOG.d/0.87.1-analysis-run-start-bag.md new file mode 100644 index 000000000..544989285 --- /dev/null +++ b/CHANGELOG.d/0.87.1-analysis-run-start-bag.md @@ -0,0 +1,6 @@ +# 0.87.1 start reconstructs the create-time cutoff bag + +Start locks the run before writing Running, persists snapshot membership +at create, and shows the Result digest prefix. Refresh after a double +start to see the stored tree. This path still does not invent a TEPP +measurement. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a98d23ff..282ba8fca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ 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.87.1] - 2026-08-16 + +### Fixed + +- Start locks the Pending run before writing Running, so a double-click + is a next-action 409 instead of a 500. Create now freezes authorized + post ids on `analysis_source_snapshot_member`; start reconstructs that + bag instead of a later backfill that shares the cutoff clock. Hover + the Result digest prefix after Succeeded to verify the parent-choice + hash. Edge titles stay public-or-affiliated. Open the Pending Demo + Corp row, start reconstruction, and confirm the A-100 fork. + ## [0.87.0] - 2026-08-16 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index d84602f08..bf68e0c9d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,7 +3,7 @@ Tool-specific pointer. Policy lives in [AGENTS.md](AGENTS.md) and the ADRs under `docs/adr/`. Do not fork those rules here. -## Analysis-run seed (v0.87.0) +## Analysis-run seed (v0.87.1) `make seed` writes a Demo Corp lineage run and a TEPP run on the same snapshot (ADR 0013). The TEPP path goes through `tepp_client`. A missing @@ -19,4 +19,5 @@ 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). `POST /api/analysis-runs/{id}/start` -reconstructs that cutoff bag (ADR 0019) and does not invent a theta. +reconstructs that frozen cutoff bag (ADR 0019) 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 82f5be7b8..a45f12bf6 100644 --- a/backend/app/analysis_run_ingestion.py +++ b/backend/app/analysis_run_ingestion.py @@ -248,21 +248,48 @@ async def fetch_visible_analysis_run( affiliated_entity_ids, row["knowledge_cutoff"], ) - digest, edges = await fetch_reconstructed_edges(conn, analysis_run_id) + 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 0019 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( @@ -282,8 +309,12 @@ async def fetch_reconstructed_edges( 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 @@ -302,9 +333,38 @@ async def fetch_reconstructed_edges( "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, @@ -629,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 index 184d622b4..78f375f38 100644 --- a/backend/app/analysis_run_start.py +++ b/backend/app/analysis_run_start.py @@ -102,6 +102,70 @@ async def _append_status( ) +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 0020 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 _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 _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, *, @@ -112,7 +176,9 @@ async def start_pending_analysis_run( """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. + retry returns the stored reconstruction. 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) @@ -141,13 +207,10 @@ async def start_pending_analysis_run( "Open this run. Start is only for a Pending lineage reconstruction.", ) - now = datetime.now(timezone.utc) - await _append_status(conn, analysis_run_id, 2, _RUNNING, now) - locked = await conn.fetchrow( """ select run.analysis_run_id, run.knowledge_cutoff, - scope.corporate_entity_id + 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 @@ -155,43 +218,86 @@ async def start_pending_analysis_run( """, analysis_run_id, ) - 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( + locked_status = await conn.fetchval( """ - insert into analysis_run_reconstruction - (analysis_run_id, result_sha256, edge_count, reconstructed_at) - values ($1, $2, $3, $4) + select status_code + from analysis_run_current_status + where analysis_run_id = $1 """, analysis_run_id, - digest, - len(edges), - finished, ) - for edge in edges: + 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_lineage_edge - (analysis_run_id, child_post_id, parent_post_id, - fused_score, reconstructed_at) - values ($1, $2, $3, $4, $5) + insert into analysis_run_reconstruction + (analysis_run_id, result_sha256, edge_count, reconstructed_at) + values ($1, $2, $3, $4) """, analysis_run_id, - edge.child_id, - edge.parent_id, - edge.fused_score, + 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, ) - await _append_status(conn, analysis_run_id, 3, _SUCCEEDED, finished) + except asyncpg.UniqueViolationError as exc: + raise start_write_conflict_error() from exc started = await fetch_visible_analysis_run( conn, analysis_run_id, diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index e447c6081..3352e89db 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -35,6 +35,9 @@ _RECONSTRUCTION_MIGRATION = ( Path(__file__).resolve().parents[2] / "migrations" / "0019_analysis_run_reconstruction.sql" ) +_SNAPSHOT_MEMBER_MIGRATION = ( + Path(__file__).resolve().parents[2] / "migrations" / "0020_analysis_source_snapshot_member.sql" +) def _postgres_available() -> bool: @@ -119,6 +122,7 @@ def seeded_db(demo_analyst_token): cur.execute(_MIGRATION_PATH.read_text()) cur.execute(_REGISTRY_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'), " diff --git a/docker/postgres-init/Dockerfile b/docker/postgres-init/Dockerfile index b80b7b4d8..e56fee0aa 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_analysis_run_reconstruction.sql /docker-entrypoint-initdb.d/20-analysis-run-reconstruction.sql +COPY migrations/0020_analysis_source_snapshot_member.sql /docker-entrypoint-initdb.d/21-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 f9e09e07f..6f1a30b46 100644 --- a/docs/adr/0013-normalized-analysis-run-registry.md +++ b/docs/adr/0013-normalized-analysis-run-registry.md @@ -240,8 +240,9 @@ Acceptance requires: first status atomically and compares request digests on idempotent retries. `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 (ADR 0019). A durable outbox / Valkey - worker and live TEPP execution remain later slices. + lineage cutoff bag in-process from frozen snapshot membership + (ADR 0019). 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 ec43f4ea4..30c9883d5 100644 --- a/docs/adr/0017-authorized-analysis-run-create.md +++ b/docs/adr/0017-authorized-analysis-run-create.md @@ -23,7 +23,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 diff --git a/docs/adr/0019-authorized-analysis-run-start.md b/docs/adr/0019-authorized-analysis-run-start.md index c80e4a6ed..b3d8dbcdc 100644 --- a/docs/adr/0019-authorized-analysis-run-start.md +++ b/docs/adr/0019-authorized-analysis-run-start.md @@ -29,9 +29,13 @@ transaction: 2. rejects non-lineage kinds so TEPP cannot invent a theta; 3. replays a Succeeded run; 4. accepts only Pending lineage; -5. appends Running, runs `lineage_edge_specs` / ThreadWeave on the - ABAC-visible cutoff bag, persists `analysis_run_reconstruction` plus - `analysis_run_lineage_edge`, then appends Succeeded. +5. locks the run row, re-reads status, appends Running, runs + `lineage_edge_specs` / ThreadWeave 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 @@ -65,7 +69,9 @@ Rules: - Failed TEPP remains a `tepp_client` transport problem. The home detail adds **Start reconstruction** on a Pending lineage row -and lists titled parent→child edges after Succeeded. +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. ## Consequences diff --git a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md index c4eaee918..2c7730d38 100644 --- a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md +++ b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md @@ -1,7 +1,7 @@ # Analysis-run registry standards and research traceability **Status:** Active PR evidence; not protected-main truth until merge. -**Scope:** Migrations 0018–0019, ADR 0013 / 0017 / 0019, rollback, and +**Scope:** Migrations 0018–0020, ADR 0013 / 0017 / 0019, rollback, and real-PostgreSQL contract tests. ## Standards mapped to implementation @@ -15,7 +15,7 @@ real-PostgreSQL contract tests. | 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, and exclusion of raw source/provider payloads. | | 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`; tests require the designed A-100 fork (revised quote + delivery question under the pricing follow-up). | +| 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 @@ -78,7 +78,7 @@ 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 rollback with any registry rows and allow replay after explicit cleanup. | -| Start reconstruction recovers the designed tree | Persist edges from `lineage_edge_specs` on the A-100 fixture bag; the pricing follow-up must parent both the revised quote and the delivery question. A TEPP start must 422 without a theta. | +| 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 start must 422 without a theta. Snapshot members exclude a later backfill. A concurrent start is 409 with a refresh next action. | ## APA 7th references diff --git a/frontend/package.json b/frontend/package.json index c93670d28..8153ec16f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.87.0", + "version": "0.87.1", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 0855a4ab7..d65dc1a51 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1741,6 +1741,9 @@ describe("App, authenticated", () => { expect( screen.getByText("Delivery schedule question raised follows Pricing renegotiation follow-up"), ).toBeInTheDocument(); + const digests = screen.getByLabelText("Analysis run reproducibility digests"); + expect(digests).toHaveTextContent("Result aaaaaaaaaaaa"); + expect(screen.getByTitle("aa".repeat(32))).toHaveTextContent("Result aaaaaaaaaaaa"); const startCall = fetchMock.mock.calls.find((call) => String(call[0]).endsWith("/api/analysis-runs/run-demo-lineage-pending/start"), ); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5987f7af5..80f476fc0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1538,11 +1538,23 @@ function analysisRunLivePostButtonLabel(postTitle: string): string { function AnalysisRunReproducibilityDigests({ codeRevisionSha, configurationSha256, + reconstructionResultSha256, }: { codeRevisionSha?: string; configurationSha256?: string; + reconstructionResultSha256?: string; }) { - if (!codeRevisionSha && !configurationSha256) { + const parts: { label: string; digest: string }[] = []; + if (codeRevisionSha) { + parts.push({ label: "Code", digest: codeRevisionSha }); + } + if (configurationSha256) { + parts.push({ label: "Config", digest: configurationSha256 }); + } + if (reconstructionResultSha256) { + parts.push({ label: "Result", digest: reconstructionResultSha256 }); + } + if (parts.length === 0) { return null; } return ( @@ -1551,15 +1563,12 @@ function AnalysisRunReproducibilityDigests({ Hover a prefix to read the full digest for verification.{" "} - {codeRevisionSha ? ( - {`Code ${analysisRunDigestPrefix(codeRevisionSha)}`} - ) : null} - {codeRevisionSha && configurationSha256 ? " · " : null} - {configurationSha256 ? ( - - {`Config ${analysisRunDigestPrefix(configurationSha256)}`} + {parts.map((part, index) => ( + + {index > 0 ? " · " : null} + {`${part.label} ${analysisRunDigestPrefix(part.digest)}`} - ) : null} + ))}

    ); @@ -1695,6 +1704,7 @@ function AnalysisRunsPanel({ {selected.status_code === "analysis_status_pending" && (

    {analysisRunNextAction(selected)}

    diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 1950c39f8..6d22286fa 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.87.0" +__version__ = "0.87.1" diff --git a/migrations/0020_analysis_source_snapshot_member.sql b/migrations/0020_analysis_source_snapshot_member.sql new file mode 100644 index 000000000..955d111d8 --- /dev/null +++ b/migrations/0020_analysis_source_snapshot_member.sql @@ -0,0 +1,35 @@ +-- Create-time cutoff membership for an analysis source snapshot (ADR 0019). +-- +-- The snapshot digest already hashes authorized post ids. This relation +-- stores those ids so start reconstructs the same bag, not a later +-- backfill that shares the cutoff clock. No post body is stored. + +create table if not exists analysis_source_snapshot_member ( + analysis_source_snapshot_id uuid not null + references analysis_source_snapshot (analysis_source_snapshot_id), + source_post_id uuid not null + references source_post (post_id), + primary key (analysis_source_snapshot_id, source_post_id) +); + +comment on table analysis_source_snapshot_member is + 'Authorized post ids frozen at snapshot capture; start reconstructs ' + 'these rows and never a later backfill.'; + +create or replace function reject_analysis_source_snapshot_member_update() +returns trigger +language plpgsql +as $$ +begin + raise exception 'analysis_source_snapshot_member_is_immutable'; +end +$$; + +comment on function reject_analysis_source_snapshot_member_update() is + 'Rejects mutation of frozen snapshot membership.'; + +drop trigger if exists analysis_source_snapshot_member_update_reject + on analysis_source_snapshot_member; +create trigger analysis_source_snapshot_member_update_reject +before update or delete on analysis_source_snapshot_member +for each row execute function reject_analysis_source_snapshot_member_update(); diff --git a/migrations/rollback/0020_analysis_source_snapshot_member.sql b/migrations/rollback/0020_analysis_source_snapshot_member.sql new file mode 100644 index 000000000..c0bdd685a --- /dev/null +++ b/migrations/rollback/0020_analysis_source_snapshot_member.sql @@ -0,0 +1,27 @@ +-- Fail-closed rollback for migration 0020. +-- +-- Snapshot membership must be exported or explicitly deleted under an +-- approved retention procedure before these objects can be removed. + +begin; + +do $$ +declare + relation_has_rows boolean; +begin + if to_regclass('public.analysis_source_snapshot_member') is not null then + execute 'select exists (select 1 from analysis_source_snapshot_member)' + into relation_has_rows; + if relation_has_rows then + raise exception 'analysis_source_snapshot_member_not_empty'; + end if; + end if; +end +$$; + +drop trigger if exists analysis_source_snapshot_member_update_reject + on analysis_source_snapshot_member; +drop function if exists reject_analysis_source_snapshot_member_update(); +drop table if exists analysis_source_snapshot_member; + +commit; diff --git a/pyproject.toml b/pyproject.toml index ecfe24877..9dea9d3f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.87.0" +version = "0.87.1" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py index d4a109c11..0131cc33d 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -121,6 +121,7 @@ def seed( cur.execute((migrations / "0016_cross_post_actor_identity.sql").read_text()) cur.execute((migrations / "0018_analysis_run_registry.sql").read_text()) cur.execute((migrations / "0019_analysis_run_reconstruction.sql").read_text()) + cur.execute((migrations / "0020_analysis_source_snapshot_member.sql").read_text()) cur.execute( """ insert into common_lookup_value (lookup_category, lookup_code, lookup_label, display_order) values @@ -1282,6 +1283,35 @@ def _ensure_demo_source_counts(cur, snapshot_id) -> None: ) +def _ensure_demo_source_snapshot_members(cur, snapshot_id, corporate_entity_id) -> None: + """Freeze Demo Corp post ids on the shared snapshot when the table exists.""" + cur.execute( + "select 1 from information_schema.tables " + "where table_schema = 'public' " + "and table_name = 'analysis_source_snapshot_member'" + ) + if cur.fetchone() is None: + return + cur.execute( + "select 1 from analysis_source_snapshot_member " + "where analysis_source_snapshot_id = %s limit 1", + (snapshot_id,), + ) + if cur.fetchone() is not None: + return + cur.execute( + """ + insert into analysis_source_snapshot_member + (analysis_source_snapshot_id, source_post_id) + select %s, post_id from source_post + where corporate_entity_id = %s + and created_at <= '2026-01-12T00:00:00Z' + on conflict do nothing + """, + (snapshot_id, corporate_entity_id), + ) + + def _seed_demo_analysis_run(cur, requested_by_account_id, corporate_entity_id) -> None: """Insert one Demo-Corp lineage run so Analysis runs is not empty. @@ -1291,6 +1321,7 @@ def _seed_demo_analysis_run(cur, requested_by_account_id, corporate_entity_id) - """ 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 @@ -1386,6 +1417,7 @@ def _seed_demo_tepp_run(cur, requested_by_account_id, corporate_entity_id) -> No """ 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 diff --git a/tests/test_analysis_run_reconstruction_schema.py b/tests/test_analysis_run_reconstruction_schema.py index 390a9c74b..0ad355028 100644 --- a/tests/test_analysis_run_reconstruction_schema.py +++ b/tests/test_analysis_run_reconstruction_schema.py @@ -15,6 +15,8 @@ _REGISTRY_MIGRATION = _ROOT / "migrations" / "0018_analysis_run_registry.sql" _RECONSTRUCTION_MIGRATION = _ROOT / "migrations" / "0019_analysis_run_reconstruction.sql" _RECONSTRUCTION_ROLLBACK = _ROOT / "migrations" / "rollback" / "0019_analysis_run_reconstruction.sql" +_SNAPSHOT_MEMBER_MIGRATION = _ROOT / "migrations" / "0020_analysis_source_snapshot_member.sql" +_SNAPSHOT_MEMBER_ROLLBACK = _ROOT / "migrations" / "rollback" / "0020_analysis_source_snapshot_member.sql" _POSTGRES_IMAGE = _ROOT / "docker" / "postgres-init" / "Dockerfile" _ADMIN_DSN = os.environ.get( "LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN", "postgresql://localhost/postgres" @@ -38,9 +40,23 @@ def test_reconstruction_migration_is_normalized_and_wired() -> None: assert "metadata_payload" not in migration assert "theta" not in migration.casefold() assert "0019_analysis_run_reconstruction.sql" in dockerfile + assert "0020_analysis_source_snapshot_member.sql" in dockerfile assert "analysis_run_reconstruction_not_empty" in rollback assert "reject_analysis_run_reconstruction_update" in migration assert "reject_analysis_run_lineage_edge_update" in migration + member_migration = _SNAPSHOT_MEMBER_MIGRATION.read_text(encoding="utf-8") + member_rollback = _SNAPSHOT_MEMBER_ROLLBACK.read_text(encoding="utf-8") + assert "analysis_source_snapshot_member" in member_migration + assert "jsonb" not in member_migration.casefold() + assert "theta" not in member_migration.casefold() + assert "analysis_source_snapshot_member_not_empty" in member_rollback + assert "reject_analysis_source_snapshot_member_update" in member_migration + for object_name in re.findall( + r"create table if not exists\s+([a-z0-9_]+)", + member_migration, + re.I, + ): + assert len(object_name.split("_")) >= 2, object_name object_patterns = ( r"create table if not exists\s+([a-z0-9_]+)", @@ -91,6 +107,7 @@ def reconstruction_db(): cursor.execute(_INITIAL_MIGRATION.read_text(encoding="utf-8")) cursor.execute(_REGISTRY_MIGRATION.read_text(encoding="utf-8")) cursor.execute(_RECONSTRUCTION_MIGRATION.read_text(encoding="utf-8")) + cursor.execute(_SNAPSHOT_MEMBER_MIGRATION.read_text(encoding="utf-8")) yield conn finally: conn.close() diff --git a/tests/test_analysis_run_start.py b/tests/test_analysis_run_start.py index 239c5b5bb..f2b6e53ab 100644 --- a/tests/test_analysis_run_start.py +++ b/tests/test_analysis_run_start.py @@ -1,12 +1,15 @@ """Start-reconstruction contracts: digest stability and designed-tree fidelity.""" -from lineageweave.fixtures import sample_records -from lineageweave.lineage_persistence import lineage_edge_specs - +from backend.app.analysis_run_ingestion import reconstructed_edge_is_visible from backend.app.analysis_run_start import ( AnalysisRunStartError, + reconstruction_member_ids, reconstruction_result_digest, + start_write_conflict_error, ) +from backend.app.lineage_ingestion import records_from_source_posts +from lineageweave.fixtures import sample_records +from lineageweave.lineage_persistence import lineage_edge_specs def test_reconstruction_digest_is_stable_and_ignores_edge_order() -> None: @@ -34,6 +37,55 @@ def test_start_uses_the_same_parent_choices_as_library_reconstruct() -> None: assert "theta" not in reconstruction_result_digest(edges) +def test_start_wiring_recovers_a100_from_source_post_rows() -> None: + """CI must exercise records_from_source_posts, not only library reconstruct.""" + rows = [ + { + "post_id": record.record_id, + "post_title": record.label, + "created_at": record.occurred_at, + "thread_group_key": record.group_key, + "secondary_grouping_key": record.secondary_key, + "process_unit_id": None, + "corporate_entity_id": "corp-demo", + } + for record in sample_records() + ] + edges = lineage_edge_specs(records_from_source_posts(rows)) + children = {edge.child_id for edge in edges if edge.parent_id == "rec-002"} + assert children >= {"rec-003", "rec-004"} + assert reconstruction_result_digest(edges) == reconstruction_result_digest( + lineage_edge_specs(sample_records()) + ) + + +def test_snapshot_members_exclude_a_later_backfill() -> None: + """Start reconstructs the create-time bag, not a later cutoff re-query.""" + captured = ["rec-001", "rec-002", "rec-003", "rec-004"] + cutoff_with_backfill = [*captured, "rec-backfill"] + assert reconstruction_member_ids(captured, cutoff_with_backfill) == captured + assert reconstruction_member_ids([], cutoff_with_backfill) == cutoff_with_backfill + + +def test_reconstructed_edge_hides_unaffiliated_private_titles() -> None: + """Edge titles use the same public-or-affiliated rule as cutoff posts.""" + affiliated = ["corp-demo"] + assert reconstructed_edge_is_visible( + parent_visibility_code="public", + parent_corporate_entity_id="corp-other", + child_visibility_code="public", + child_corporate_entity_id="corp-other", + affiliated_entity_ids=affiliated, + ) + assert not reconstructed_edge_is_visible( + parent_visibility_code="private", + parent_corporate_entity_id="corp-other", + child_visibility_code="public", + child_corporate_entity_id="corp-demo", + affiliated_entity_ids=affiliated, + ) + + def test_start_error_carries_a_next_action() -> None: """Operators get a next action, not an internal exception name.""" error = AnalysisRunStartError( @@ -43,3 +95,6 @@ def test_start_error_carries_a_next_action() -> None: ) assert error.status_code == 422 assert "invent a measurement" in error.detail + conflict = start_write_conflict_error() + assert conflict.status_code == 409 + assert "Refresh to see the stored tree" in conflict.detail