From 42373bf974dbaef9db28887b6fbac3cbd6506ecf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 03:54:53 +0900 Subject: [PATCH 1/2] feat: open reconstructed analysis-run edges as live posts (v0.91.0) After start, the titled A-100 parent and child are buttons. A marked child still shows the live-body warning. The popup does not invent a cutoff snapshot. --- ...0-analysis-run-reconstructed-edge-click.md | 4 ++ CHANGELOG.md | 10 ++++ .../adr/0021-authorized-analysis-run-start.md | 4 +- frontend/package.json | 2 +- frontend/src/App.test.tsx | 53 ++++++++++++++----- frontend/src/App.tsx | 41 +++++++++++++- lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 9 files changed, 102 insertions(+), 18 deletions(-) create mode 100644 CHANGELOG.d/0.91.0-analysis-run-reconstructed-edge-click.md diff --git a/CHANGELOG.d/0.91.0-analysis-run-reconstructed-edge-click.md b/CHANGELOG.d/0.91.0-analysis-run-reconstructed-edge-click.md new file mode 100644 index 000000000..ed51dafed --- /dev/null +++ b/CHANGELOG.d/0.91.0-analysis-run-reconstructed-edge-click.md @@ -0,0 +1,4 @@ +# 0.91.0 Analysis-run reconstructed edge click + +Started reconstruction edges are buttons. Open the revised-quote child +or the pricing-follow-up parent. Live-body warning still applies. diff --git a/CHANGELOG.md b/CHANGELOG.md index f839f5e11..43ecdc93c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ 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.91.0] - 2026-08-17 + +### Added + +- After **Start reconstruction**, the titled A-100 edges are buttons. + Click the revised-quote child to open the live post; click the + pricing-follow-up parent to open that post. A child marked + **Updated after cutoff** still shows the live-body warning. The + popup does not invent a cutoff snapshot. No TEPP theta is invented. + ## [0.90.0] - 2026-08-17 ### Added diff --git a/docs/adr/0021-authorized-analysis-run-start.md b/docs/adr/0021-authorized-analysis-run-start.md index 61db36679..34ac16d95 100644 --- a/docs/adr/0021-authorized-analysis-run-start.md +++ b/docs/adr/0021-authorized-analysis-run-start.md @@ -79,7 +79,9 @@ Rules: 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 +and lists titled parent→child edges after Succeeded. Those titles are +buttons that open the live post (a marked child still shows the +live-body warning). 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. diff --git a/frontend/package.json b/frontend/package.json index 356c15b51..41cbc30c2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.90.0", + "version": "0.91.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index d031fa066..b7119c7c6 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -356,17 +356,28 @@ describe("App, authenticated", () => { 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" }], + visible_posts: [ + { + post_id: "post-1", + post_title: "Pricing renegotiation: revised quote sent", + live_after_cutoff: true, + }, + { + post_id: "post-2", + post_title: "Pricing renegotiation follow-up", + live_after_cutoff: false, + }, + ], reconstructed_edges: [ { - parent_post_id: "post-follow-up", + parent_post_id: "post-2", parent_post_title: "Pricing renegotiation follow-up", - child_post_id: "post-quote", + child_post_id: "post-1", child_post_title: "Pricing renegotiation: revised quote sent", fused_score: 0.72, }, { - parent_post_id: "post-follow-up", + parent_post_id: "post-2", parent_post_title: "Pricing renegotiation follow-up", child_post_id: "post-delivery", child_post_title: "Delivery schedule question raised", @@ -1960,14 +1971,13 @@ describe("App, authenticated", () => { 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 fork = screen.getByRole("list", { name: "Reconstructed lineage edges" }); + expect(fork).toHaveTextContent( + "Pricing renegotiation: revised quote sent follows Pricing renegotiation follow-up", + ); + expect(fork).toHaveTextContent( + "Delivery schedule question raised follows Pricing renegotiation follow-up", + ); const digests = screen.getByLabelText("Analysis run reproducibility digests"); expect(digests).toHaveTextContent("Result aaaaaaaaaaaa"); expect(screen.getByTitle("aa".repeat(32))).toHaveTextContent("Result aaaaaaaaaaaa"); @@ -1975,6 +1985,25 @@ describe("App, authenticated", () => { String(call[0]).endsWith("/api/analysis-runs/run-demo-lineage-pending/start"), ); expect(startCall?.[1]?.method).toBe("POST"); + + await userEvent.click( + screen.getByRole("button", { + name: "Open reconstructed child: Pricing renegotiation: revised quote sent", + }), + ); + await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); + expect(screen.getByRole("status", { name: "Live body warning" })).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Close" })); + await userEvent.click( + screen.getAllByRole("button", { + name: "Open reconstructed parent: Pricing renegotiation follow-up", + })[0], + ); + await waitFor(() => + expect(screen.getByText("The evidence panel should show exactly this text.")).toBeInTheDocument(), + ); + expect(screen.queryByRole("status", { name: "Live body warning" })).not.toBeInTheDocument(); }); it("shows the calibrated period-report mean theta on the home page", async () => { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d2b429ada..801409f16 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1667,6 +1667,21 @@ function analysisRunCanStartReconstruction(run: AnalysisRun): boolean { ); } +/** + * Open options for a reconstructed parent or child. + * + * The run-scoped edge is the reconstruction result. The popup still + * shows the live body; reuse the cutoff write-clock flag when that + * title is marked rewritten after this run. + */ +function analysisRunPostOpenOptions(run: AnalysisRun, postId: string): SelectPostOptions { + const post = run.visible_posts?.find((item) => item.post_id === postId); + return { + liveAfterCutoff: Boolean(post?.live_after_cutoff), + knowledgeCutoff: run.knowledge_cutoff, + }; +} + function AnalysisRunsPanel({ accessToken, onSelectPost, @@ -1815,7 +1830,31 @@ function AnalysisRunsPanel({ diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 937a27782..7e663f10a 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.90.0" +__version__ = "0.91.0" diff --git a/pyproject.toml b/pyproject.toml index f9501e803..2736a16a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.90.0" +version = "0.91.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/uv.lock b/uv.lock index 3f222bb06..9f05b7e02 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.90.0" +version = "0.91.0" source = { virtual = "." } dependencies = [ { name = "certifi" }, From c3d52df8d9eae83ee54586244d64cac245d2100a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 04:56:45 +0900 Subject: [PATCH 2/2] feat: start pending TEPP measurement through tepp_client (v0.92.0) POST /api/analysis-runs/{id}/start submits AnalysisRunRequest via tepp_client. A missing or refused transport stays Failed. An accepted envelope is not persistable yet. Period-report remains 422. No theta is invented. --- ARCHITECTURE.md | 9 +- CHANGELOG.d/0.92.0-analysis-run-tepp-start.md | 4 + CHANGELOG.md | 12 ++ CLAUDE.md | 7 +- backend/app/analysis_run_ingestion.py | 5 +- backend/app/analysis_run_start.py | 173 +++++++++++++++--- backend/app/config.py | 4 + backend/app/main.py | 13 +- backend/tests/test_api.py | 13 +- backend/tests/test_config.py | 8 + .../0013-normalized-analysis-run-registry.md | 16 +- .../0017-authorized-analysis-run-create.md | 3 +- .../adr/0021-authorized-analysis-run-start.md | 4 +- docs/adr/0022-authorized-tepp-start.md | 95 ++++++++++ .../ANALYSIS_RUN_REGISTRY_REFERENCES.md | 4 +- frontend/package.json | 2 +- frontend/src/App.test.tsx | 149 +++++++++++++++ frontend/src/App.tsx | 60 +++++- frontend/src/api.ts | 1 + lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- tests/test_analysis_run_start.py | 66 ++++++- uv.lock | 2 +- 23 files changed, 583 insertions(+), 71 deletions(-) create mode 100644 CHANGELOG.d/0.92.0-analysis-run-tepp-start.md create mode 100644 docs/adr/0022-authorized-tepp-start.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2d5cd8527..4291ba016 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -475,9 +475,12 @@ revision and configuration digest prefixes. 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 +frozen bag and persists run-scoped edges (ADR 0021), or submits TEPP +through `tepp_client` (ADR 0022). It does not invent a TEPP score. +Request a lineage reconstruction from the home list, open the Pending +row, then start reconstruction. A Pending TEPP row starts a +measurement; a missing transport stays Failed / +`tepp_not_available`. 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 diff --git a/CHANGELOG.d/0.92.0-analysis-run-tepp-start.md b/CHANGELOG.d/0.92.0-analysis-run-tepp-start.md new file mode 100644 index 000000000..6ea75f0a3 --- /dev/null +++ b/CHANGELOG.d/0.92.0-analysis-run-tepp-start.md @@ -0,0 +1,4 @@ +# 0.92.0 Analysis-run TEPP start + +Pending TEPP start goes through tepp_client. Missing transport stays +Failed. No invented theta. diff --git a/CHANGELOG.md b/CHANGELOG.md index 43ecdc93c..b874d67de 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.92.0] - 2026-08-17 + +### Added + +- **Start TEPP measurement** on a Pending TEPP row submits + `AnalysisRunRequest` through `tepp_client` (ADR 0022). A missing + `TEPP_TRANSPORT_URL` or a refused URL is Failed / + `tepp_not_available`. An accepted envelope is Failed / + `tepp_result_not_persisted`. Failed stays terminal: **Request a new + TEPP measurement** records a new Pending run. Period-report start + stays 422. No TEPP theta is invented. + ## [0.91.0] - 2026-08-17 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index a855ef5cd..d6d386499 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,5 +34,8 @@ after cutoff were rewritten after the run; compare those bodies before treating them as reconstructed evidence (ADR 0016). `POST /api/analysis-runs` records Pending on an authorized cutoff capture (ADR 0017). `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. +reconstructs that frozen cutoff bag (ADR 0021) or submits TEPP +through `tepp_client` (ADR 0022). A missing transport or unused +accepted envelope is Failed. Failed TEPP is terminal — request a +new run, then start. Do 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 0a07f1e02..fef422a59 100644 --- a/backend/app/analysis_run_ingestion.py +++ b/backend/app/analysis_run_ingestion.py @@ -8,8 +8,9 @@ ``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. +``start_pending_analysis_run`` later reconstructs lineage (ADR 0021) +or submits TEPP through ``tepp_client`` (ADR 0022). Neither path +invents a TEPP score. """ from __future__ import annotations diff --git a/backend/app/analysis_run_start.py b/backend/app/analysis_run_start.py index 0e9e3a2e7..474bcd2ef 100644 --- a/backend/app/analysis_run_start.py +++ b/backend/app/analysis_run_start.py @@ -1,8 +1,8 @@ -"""Start a Pending lineage reconstruction without inventing a TEPP score. +"""Start a Pending lineage reconstruction or TEPP measurement. -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. +ADR 0021 reconstructs lineage. ADR 0022 starts TEPP through +``tepp_client`` only. Period-report stays another path. Neither start +invents a theta or a calibrated report score. """ from __future__ import annotations @@ -20,8 +20,10 @@ fetch_visible_analysis_run, ) from backend.app.lineage_ingestion import records_from_source_posts +from lineageweave.http_client import HttpClientError, post_json from lineageweave.lineage_persistence import lineage_edge_specs from lineageweave.models import Edge +from lineageweave.tepp_client import AnalysisRunRequest, TeppClient, TeppNotAvailable _LINEAGE_KIND = "analysis_run_lineage" _TEPP_KIND = "analysis_run_tepp" @@ -29,6 +31,9 @@ _PENDING = "analysis_status_pending" _RUNNING = "analysis_status_running" _SUCCEEDED = "analysis_status_succeeded" +_FAILED = "analysis_status_failed" +_TEPP_MODEL_CONTRACT = "tepp-analysis-run-v1" +_TEPP_OUTPUT_PROFILE = "calibrated_event_measurement" class AnalysisRunStartError(AnalysisRunCreateError): @@ -53,19 +58,14 @@ def reconstruction_result_digest(edges: list[Edge]) -> str: def start_kind_rejection(run_kind_code: str) -> AnalysisRunStartError | None: - """Return a 422 when start is not a lineage reconstruction. + """Return a 422 when start cannot run this kind. - TEPP and period-report keep their own transports. This path must not - invent a theta or a calibrated report score. + Lineage reconstructs the frozen bag. TEPP submits through + ``tepp_client`` and never invents a theta. Period-report stays on + its own rebuild path. """ - if run_kind_code == _LINEAGE_KIND: + if run_kind_code in {_LINEAGE_KIND, _TEPP_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, @@ -74,11 +74,69 @@ def start_kind_rejection(run_kind_code: str) -> AnalysisRunStartError | None: ) return AnalysisRunStartError( 422, - "Start reconstructs a Pending lineage run only. " + "Start reconstructs a Pending lineage run or submits TEPP. " "This start path does not invent a measurement.", ) +def configured_tepp_client(transport_url: str = "") -> TeppClient: + """Build a TEPP client from an optional HTTP transport URL. + + An empty URL keeps the default unavailable transport. A set URL + POSTs TEPP's published wire payload. File URLs and other schemes + stay unavailable -- this is not a local psychometric substitute. + """ + url = transport_url.strip() + if not url: + return TeppClient() + + def transport(payload: dict[str, Any]) -> dict[str, Any]: + try: + return post_json(url, payload, headers={}, timeout=30.0) + except (HttpClientError, ValueError, TypeError) as exc: + raise TeppNotAvailable(str(exc)) from exc + + return TeppClient(transport=transport) + + +def tepp_run_request( + *, + idempotency_key: str, + snapshot_sha256: str, + knowledge_cutoff: datetime, + corporate_entity_id: str, +) -> AnalysisRunRequest: + """Build TEPP's published request from the frozen run, never a theta.""" + cutoff = knowledge_cutoff + if cutoff.tzinfo is None: + cutoff = cutoff.replace(tzinfo=timezone.utc) + return AnalysisRunRequest( + idempotency_key=idempotency_key, + tenant_workspace_id=str(corporate_entity_id), + snapshot_id=snapshot_sha256, + knowledge_cutoff=cutoff.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + model_contract_version=_TEPP_MODEL_CONTRACT, + output_profile=_TEPP_OUTPUT_PROFILE, + ) + + +def tepp_submit_outcome( + client: TeppClient, + request: AnalysisRunRequest, +) -> tuple[str, str]: + """Submit through ``tepp_client``. Never invent or persist a theta. + + A missing transport is ``tepp_not_available``. An accepted envelope + is not a persistable measurement until TEPP publishes one, so the + run stays Failed / ``tepp_result_not_persisted``. + """ + try: + client.submit_analysis_run(request) + except TeppNotAvailable: + return _FAILED, "tepp_not_available" + return _FAILED, "tepp_result_not_persisted" + + def start_write_conflict_error() -> AnalysisRunStartError: """Next action when a concurrent start already wrote this run.""" return AnalysisRunStartError( @@ -201,14 +259,17 @@ async def start_pending_analysis_run( analysis_run_id: str, account_id: str, affiliated_entity_ids: list[str], + tepp_client: TeppClient | None = None, ) -> 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. + """Run ThreadWeave or submit TEPP on a visible Pending row. + + Period-report is rejected so this path cannot invent a calibrated + score. TEPP goes through ``tepp_client`` and stays Failed when the + transport is missing or the envelope is not persistable. 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) @@ -231,15 +292,19 @@ async def start_pending_analysis_run( if current["status_code"] != _PENDING: raise AnalysisRunStartError( 409, - "Open this run. Start is only for a Pending lineage reconstruction.", + "Open this run. Start is only for a Pending lineage reconstruction " + "or TEPP measurement.", ) locked = await conn.fetchrow( """ - select run.analysis_run_id, run.knowledge_cutoff, - run.analysis_source_snapshot_id, scope.corporate_entity_id + select run.analysis_run_id, run.knowledge_cutoff, run.run_kind_code, + run.idempotency_key, run.analysis_source_snapshot_id, + snapshot.snapshot_sha256, scope.corporate_entity_id from analysis_run run join analysis_run_scope scope on scope.analysis_run_id = run.analysis_run_id + join analysis_source_snapshot snapshot + on snapshot.analysis_source_snapshot_id = run.analysis_source_snapshot_id where run.analysis_run_id = $1 for update of run """, @@ -266,7 +331,18 @@ async def start_pending_analysis_run( if locked_status != _PENDING: raise AnalysisRunStartError( 409, - "Open this run. Start is only for a Pending lineage reconstruction.", + "Open this run. Start is only for a Pending lineage reconstruction " + "or TEPP measurement.", + ) + + if locked["run_kind_code"] == _TEPP_KIND: + return await _start_tepp_measurement( + conn, + analysis_run_id=analysis_run_id, + account_id=account_id, + affiliated_entity_ids=affiliated_entity_ids, + locked=locked, + tepp_client=tepp_client or TeppClient(), ) now = datetime.now(timezone.utc) @@ -334,3 +410,48 @@ async def start_pending_analysis_run( if started is None: raise AnalysisRunStartError(404, "This analysis run is not visible.") return started + + +async def _start_tepp_measurement( + conn: asyncpg.Connection, + *, + analysis_run_id: str, + account_id: str, + affiliated_entity_ids: list[str], + locked: asyncpg.Record, + tepp_client: TeppClient, +) -> dict[str, Any]: + """Submit the frozen snapshot through ``tepp_client``. Never persist a theta.""" + 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) + request = tepp_run_request( + idempotency_key=str(locked["idempotency_key"]), + snapshot_sha256=str(locked["snapshot_sha256"]), + knowledge_cutoff=locked["knowledge_cutoff"], + corporate_entity_id=str(locked["corporate_entity_id"]), + ) + status_code, failure_code = tepp_submit_outcome(tepp_client, request) + finished = datetime.now(timezone.utc) + if finished < now: + finished = now + await _append_status( + conn, + analysis_run_id, + running_ordinal + 1, + status_code, + finished, + failure_code, + ) + 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/config.py b/backend/app/config.py index 4202136a6..773991c8d 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -48,6 +48,9 @@ class Settings: # means the verification channel is unavailable, same "no fake # channel" discipline as every other pluggable client. searxng_base_url: str + # Optional TEPP HTTP transport. Empty keeps TeppClient's default + # unavailable transport. Never a local psychometric substitute. + tepp_transport_url: str @property def keycloak_jwks_uri(self) -> str: @@ -80,4 +83,5 @@ def load_settings() -> Settings: vision_model=os.environ.get("VISION_MODEL", ""), valkey_url=os.environ.get("VALKEY_URL", "redis://localhost:16379/0"), searxng_base_url=os.environ.get("SEARXNG_BASE_URL", ""), + tepp_transport_url=os.environ.get("TEPP_TRANSPORT_URL", ""), ) diff --git a/backend/app/main.py b/backend/app/main.py index b3da2497f..da7067f43 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -74,6 +74,7 @@ ) from backend.app.analysis_run_start import ( AnalysisRunStartError, + configured_tepp_client, start_pending_analysis_run, ) from backend.app.activity_stream import ( @@ -1263,13 +1264,16 @@ async def start_analysis_run( account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), ) -> dict[str, Any]: - """Start ThreadWeave on a visible Pending lineage run. + """Start ThreadWeave or submit TEPP on a visible Pending 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. + post_read is enough. Hidden runs 404. Period-report is 422 so this + path cannot invent a calibrated score. TEPP goes through + ``tepp_client`` and stays Failed when the transport is missing or + the envelope is not persistable. A Succeeded lineage retry returns + the stored tree. A Running restart is 409. """ _require_post_read(account) + settings = load_settings() async with pool.acquire() as conn: async with conn.transaction(): try: @@ -1278,6 +1282,7 @@ async def start_analysis_run( analysis_run_id=analysis_run_id, account_id=account.user_account_id, affiliated_entity_ids=list(account.corporate_entity_ids), + tepp_client=configured_tepp_client(settings.tepp_transport_url), ) except AnalysisRunStartError as exc: raise HTTPException(exc.status_code, exc.detail) from exc diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index bedef5775..9d5995bfd 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -688,12 +688,19 @@ def test_start_analysis_run_recovers_the_a100_fork( }, ) assert tepp.status_code == 201 - refused = client.post( + measured = 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"] + assert measured.status_code == 200, measured.text + tepp_body = measured.json() + assert tepp_body["status_label"] == "Failed" + assert tepp_body["failure_code"] == "tepp_not_available" + assert any( + event.get("failure_code") == "tepp_not_available" + for event in tepp_body["status_history"] + ) + assert "theta" not in str(tepp_body).lower() admin_conn = psycopg2.connect(seeded_db["dsn"]) admin_conn.autocommit = True diff --git a/backend/tests/test_config.py b/backend/tests/test_config.py index 655f51654..993e38a0d 100644 --- a/backend/tests/test_config.py +++ b/backend/tests/test_config.py @@ -21,3 +21,11 @@ def test_frontend_origins_are_parsed_from_comma_separated_env(monkeypatch) -> No def test_frontend_origins_drop_blank_entries(monkeypatch) -> None: monkeypatch.setenv("FRONTEND_ORIGINS", "http://localhost:5173,,") assert load_settings().frontend_origins == ["http://localhost:5173"] + + +def test_tepp_transport_url_defaults_empty_and_is_not_a_score(monkeypatch) -> None: + """Missing TEPP_TRANSPORT_URL keeps the channel dropped.""" + monkeypatch.delenv("TEPP_TRANSPORT_URL", raising=False) + assert load_settings().tepp_transport_url == "" + monkeypatch.setenv("TEPP_TRANSPORT_URL", "https://tepp.example/v1/analysis-runs") + assert load_settings().tepp_transport_url == "https://tepp.example/v1/analysis-runs" diff --git a/docs/adr/0013-normalized-analysis-run-registry.md b/docs/adr/0013-normalized-analysis-run-registry.md index afc601897..8184c9ffd 100644 --- a/docs/adr/0013-normalized-analysis-run-registry.md +++ b/docs/adr/0013-normalized-analysis-run-registry.md @@ -242,18 +242,18 @@ Acceptance requires: `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. + (ADR 0021). A durable outbox / Valkey worker remains a later slice. + Live TEPP start now submits through `tepp_client` (ADR 0022). 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. 4. Add TEPP and contextual-orchestrator adapters only after their versioned - contracts are present on reviewed main branches. Seed now records a - Failed TEPP run through `tepp_client` on the shared Demo Corp snapshot; - a live transport remains a later slice. A missing or unused TEPP - envelope must stay Failed (`tepp_not_available` / - `tepp_result_not_persisted`) and must not write a local psychometric - substitute. + contracts are present on reviewed main branches. Seed and + `POST /api/analysis-runs/{id}/start` now record Failed TEPP through + `tepp_client` on the frozen snapshot; a persistable measurement + remains a later slice. A missing or unused TEPP envelope must stay + Failed (`tepp_not_available` / `tepp_result_not_persisted`) and must + not write a local psychometric substitute. 5. Execute private actual-data analysis and store only signed aggregate and reproducibility manifests outside public source control. 6. Run browser E2E through real OIDC, product navigation, and evidence drill-down. diff --git a/docs/adr/0017-authorized-analysis-run-create.md b/docs/adr/0017-authorized-analysis-run-create.md index be7328877..ef6446a81 100644 --- a/docs/adr/0017-authorized-analysis-run-create.md +++ b/docs/adr/0017-authorized-analysis-run-create.md @@ -38,7 +38,8 @@ still owns reconstruction and live TEPP execution. The home panel's **Request a lineage reconstruction** button records a 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. +(ADR 0021). TEPP start now goes through `tepp_client` (ADR 0022). The +outbox worker remains a later slice. 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 index 34ac16d95..572bd884a 100644 --- a/docs/adr/0021-authorized-analysis-run-start.md +++ b/docs/adr/0021-authorized-analysis-run-start.md @@ -30,8 +30,8 @@ free slot. 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; +2. rejects period-report so this path cannot invent a calibrated + score; TEPP start is ADR 0022 and still cannot invent a theta; 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 diff --git a/docs/adr/0022-authorized-tepp-start.md b/docs/adr/0022-authorized-tepp-start.md new file mode 100644 index 000000000..6949209fa --- /dev/null +++ b/docs/adr/0022-authorized-tepp-start.md @@ -0,0 +1,95 @@ +# ADR 0022 — Operators start a pending TEPP measurement through tepp_client + +**Decision status:** Accepted on this active PR; not protected-main truth until merge +**Date:** 2026-08-17 +**Depends on:** ADR 0013 registry; ADR 0017 authorized create; ADR 0021 +authorized lineage start +**Refs:** Issue #79 (Milestone 2 parent); ADR 0013 follow-up 4 (live TEPP +through the published client; persistable result remains later) + +## Context + +ADR 0021 starts a Pending lineage reconstruction in-process. The same +`POST /api/analysis-runs/{id}/start` path returned 422 for TEPP so it +could not invent a theta. Create already records a Pending TEPP run. +Seed already records a Failed TEPP run through `tepp_client`. The Failed +row tells the operator to connect the measurement service and re-run, +but Failed is terminal and there was no start path that called +`tepp_client`. + +A buyer who connects a live TEPP transport still could not submit the +frozen snapshot. A 422 that says "do not invent a measurement" is +honest, but it is not a product. The missing work is to submit TEPP's +published `AnalysisRunRequest` and fail closed when the transport is +missing or the envelope is not a persistable measurement. + +## Decision + +`POST /api/analysis-runs/{id}/start` accepts Pending TEPP as well as +Pending lineage. Period-report stays 422. TEPP start, in the same +authorized transaction: + +1. locks the visible Pending TEPP row; +2. appends Running; +3. builds `AnalysisRunRequest` from the run's idempotency key, snapshot + digest, knowledge cutoff, and corporate-entity workspace id — never a + post body or a theta; +4. submits through `TeppClient`. An empty `TEPP_TRANSPORT_URL` keeps the + default unavailable transport. A set URL POSTs the published wire + payload through the http(s)-only helper. File URLs stay unavailable; +5. appends Failed / `tepp_not_available` when the transport is missing + or refused, or Failed / `tepp_result_not_persisted` when TEPP accepts + an envelope this product cannot store yet. + +Succeeded TEPP stays later. This slice does not persist a local +psychometric substitute, does not call contextual-orchestrator as TEPP, +and does not stamp Succeeded from an `accepted` envelope. Failed remains +terminal: the detail offers **Request a new TEPP measurement**, which +creates a new Pending run (ADR 0017). The operator then starts that +row. + +```mermaid +sequenceDiagram + participant Operator + participant API + participant TeppClient + participant Registry + Operator->>API: POST /api/analysis-runs/{id}/start + API->>Registry: lock visible Pending TEPP run + alt period-report + API-->>Operator: 422 use the reports panel + else Pending TEPP + Registry->>Registry: Running + API->>TeppClient: AnalysisRunRequest v1 + alt TeppNotAvailable + Registry->>Registry: Failed tepp_not_available + else accepted envelope + Registry->>Registry: Failed tepp_result_not_persisted + end + API-->>Operator: 200 Failed history + end +``` + +A durable outbox / Valkey worker remains a later slice. Start still +holds the request through the TEPP call so a crash rolls back to +Pending. + +## Consequences + +Demo Analyst can request a TEPP run, start it, and see Failed / +`tepp_not_available` until a live transport is configured. Connecting +`TEPP_TRANSPORT_URL` submits the same published payload. An accepted +envelope still does not become a calibrated result. 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). + +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/ diff --git a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md index 69cf32d97..951f72a17 100644 --- a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md +++ b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md @@ -16,7 +16,7 @@ real-PostgreSQL contract tests. | 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. | `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). | +| 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). TEPP start uses `tepp_client` only. | ## Temporal reasoning @@ -79,7 +79,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 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. | +| 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 period-report start must 422 without a theta. TEPP start submits through `tepp_client` and stays Failed (`tepp_not_available` / `tepp_result_not_persisted`) 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 diff --git a/frontend/package.json b/frontend/package.json index 41cbc30c2..dc98dbd98 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.91.0", + "version": "0.92.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index b7119c7c6..0b8b5b824 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -86,6 +86,7 @@ describe("App, authenticated", () => { const events: { event_id: string; event_type: string; actor_account_id: string; summary: string }[] = []; let nextEventId = 1; let createdPendingLineage: Record | null = null; + let createdPendingTepp: Record | null = null; const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); @@ -214,6 +215,34 @@ describe("App, authenticated", () => { }), ); } + if (url.endsWith("/api/analysis-runs/run-demo-tepp-pending")) { + return Promise.resolve( + jsonResponse( + createdPendingTepp ?? { + analysis_run_id: "run-demo-tepp-pending", + run_kind_code: "analysis_run_tepp", + run_kind_label: "TEPP measurement", + scope_kind_code: "analysis_scope_corporate_entity", + scope_kind_label: "Corporate entity", + scope_entity_name: "Demo Corp", + status_code: "analysis_status_pending", + status_label: "Pending", + knowledge_cutoff: "2026-01-12T12:00:00Z", + requested_at: "2026-01-12T12:41:00Z", + source_counts: [], + visible_posts: [{ post_id: "post-1", post_title: "Public post" }], + status_history: [ + { + status_ordinal: 1, + status_code: "analysis_status_pending", + status_label: "Pending", + occurred_at: "2026-01-12T12:41:00Z", + }, + ], + }, + ), + ); + } if (url.endsWith("/api/analysis-runs/run-demo-tepp")) { const teppStatus = options?.succeededTeppRun ? "analysis_status_succeeded" @@ -408,7 +437,80 @@ describe("App, authenticated", () => { }), ); } + if (url.endsWith("/api/analysis-runs/run-demo-tepp/start") && method === "POST") { + return Promise.resolve( + jsonResponse({ + analysis_run_id: "run-demo-tepp", + run_kind_code: "analysis_run_tepp", + run_kind_label: "TEPP measurement", + scope_kind_code: "analysis_scope_corporate_entity", + scope_kind_label: "Corporate entity", + scope_entity_name: "Demo Corp", + status_code: "analysis_status_failed", + status_label: "Failed", + failure_code: "tepp_not_available", + knowledge_cutoff: "2026-01-12T12:00:00Z", + requested_at: "2026-01-12T12:34:00Z", + source_counts: [ + { + count_type_code: "analysis_count_document", + count_type_label: "Documents", + count_value: 3, + }, + ], + visible_posts: [{ post_id: "post-1", post_title: "Public post" }], + 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_failed", + status_label: "Failed", + occurred_at: "2026-01-12T12:37:00Z", + failure_code: "tepp_not_available", + }, + ], + }), + ); + } if (url.endsWith("/api/analysis-runs") && method === "POST") { + const payload = init?.body ? JSON.parse(String(init.body)) : {}; + if (payload.run_kind_code === "analysis_run_tepp") { + const created = { + analysis_run_id: "run-demo-tepp-pending", + run_kind_code: "analysis_run_tepp", + run_kind_label: "TEPP measurement", + scope_kind_code: "analysis_scope_corporate_entity", + scope_kind_label: "Corporate entity", + scope_entity_name: "Demo Corp", + status_code: "analysis_status_pending", + status_label: "Pending", + knowledge_cutoff: "2026-01-12T12:00:00Z", + requested_at: "2026-01-12T12:41:00Z", + source_counts: [], + visible_posts: [{ post_id: "post-1", post_title: "Public post" }], + status_history: [ + { + status_ordinal: 1, + status_code: "analysis_status_pending", + status_label: "Pending", + occurred_at: "2026-01-12T12:41:00Z", + }, + ], + }; + createdPendingTepp = created; + return Promise.resolve(new Response(JSON.stringify(created), { status: 201 })); + } const created = { analysis_run_id: "run-demo-lineage-pending", run_kind_code: "analysis_run_lineage", @@ -440,6 +542,7 @@ describe("App, authenticated", () => { jsonResponse({ analysis_runs: [ ...(createdPendingLineage ? [createdPendingLineage] : []), + ...(createdPendingTepp ? [createdPendingTepp] : []), { analysis_run_id: "run-demo-lineage", run_kind_code: "analysis_run_lineage", @@ -1911,6 +2014,52 @@ describe("App, authenticated", () => { 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(); + expect(screen.getByRole("button", { name: "Start TEPP measurement" })).toBeInTheDocument(); + }); + + it("starts a pending TEPP run through tepp_client and does not invent a theta", async () => { + const fetchMock = stubBackend({ pendingTeppRun: true }); + render(); + + await userEvent.click( + await screen.findByRole("button", { + name: "Open analysis run: TEPP measurement · Pending · Demo Corp", + }), + ); + await userEvent.click(screen.getByRole("button", { name: "Start TEPP measurement" })); + expect( + await screen.findByRole("heading", { name: "TEPP measurement · Failed · Demo Corp" }), + ).toBeInTheDocument(); + expect(screen.getByText(/tepp_not_available/)).toBeInTheDocument(); + expect(screen.queryByText(/theta/i)).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Start reconstruction" })).not.toBeInTheDocument(); + const startCall = fetchMock.mock.calls.find((call) => + String(call[0]).endsWith("/api/analysis-runs/run-demo-tepp/start"), + ); + expect(startCall?.[1]?.method).toBe("POST"); + }); + + it("requests a new TEPP run from a failed row instead of mutating Failed", async () => { + const fetchMock = stubBackend(); + render(); + + await userEvent.click( + await screen.findByRole("button", { + name: "Open analysis run: TEPP measurement · Failed · Demo Corp", + }), + ); + await userEvent.click(screen.getByRole("button", { name: "Request a new TEPP measurement" })); + expect( + await screen.findByRole("heading", { name: "TEPP measurement · Pending · Demo Corp" }), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Start TEPP measurement" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Start reconstruction" })).not.toBeInTheDocument(); + const postCall = fetchMock.mock.calls.find( + (call) => String(call[0]).endsWith("/api/analysis-runs") && call[1]?.method === "POST", + ); + expect(postCall).toBeDefined(); + const body = JSON.parse(String(postCall?.[1]?.body)); + expect(body.run_kind_code).toBe("analysis_run_tepp"); }); it("does not tell a succeeded TEPP run to replace Failed", async () => { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 801409f16..5b1a1eea5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1656,17 +1656,29 @@ function AnalysisRunReproducibilityDigests({ } /** - * Start is only for a Pending Demo Corp lineage row after Request. + * Start is for a Pending lineage or TEPP row after Request. * - * TEPP and period-report keep their own transports. This button must - * not appear on those kinds. + * Period-report keeps its own rebuild path. TEPP start goes through + * tepp_client and must not be labeled reconstruction. */ -function analysisRunCanStartReconstruction(run: AnalysisRun): boolean { +function analysisRunCanStart(run: AnalysisRun): boolean { return ( - run.run_kind_code === "analysis_run_lineage" && run.status_code === "analysis_status_pending" + (run.run_kind_code === "analysis_run_lineage" || run.run_kind_code === "analysis_run_tepp") && + run.status_code === "analysis_status_pending" ); } +function analysisRunStartLabel(run: AnalysisRun): string { + return run.run_kind_code === "analysis_run_tepp" + ? "Start TEPP measurement" + : "Start reconstruction"; +} + +/** Failed TEPP is terminal. Re-run records a new Pending TEPP row. */ +function analysisRunCanRequestTeppRetry(run: AnalysisRun): boolean { + return run.run_kind_code === "analysis_run_tepp" && run.status_code === "analysis_status_failed"; +} + /** * Open options for a reconstructed parent or child. * @@ -1735,6 +1747,24 @@ function AnalysisRunsPanel({ } } + async function handleRequestTepp() { + setError(null); + setRequesting(true); + try { + const created = await createAnalysisRun(accessToken, { + run_kind_code: "analysis_run_tepp", + idempotency_key: crypto.randomUUID(), + }); + const listed = await fetchAnalysisRuns(accessToken); + setRuns(listed.analysis_runs); + setSelected(created); + } catch (err) { + setError(err instanceof BackendError ? err.message : String(err)); + } finally { + setRequesting(false); + } + } + async function handleOpen(runId: string) { setError(null); try { @@ -1816,14 +1846,28 @@ function AnalysisRunsPanel({ configurationSha256={selected.configuration_sha256} reconstructionResultSha256={selected.reconstruction_result_sha256} /> - {analysisRunCanStartReconstruction(selected) && ( + {analysisRunCanStart(selected) && ( + )} + {analysisRunCanRequestTeppRetry(selected) && ( + )} {selected.reconstructed_edges && selected.reconstructed_edges.length > 0 && ( diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 8c3298fa8..b8269e2b0 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -560,6 +560,7 @@ export interface AnalysisRun { scope_entity_name?: string; status_code: AnalysisRunStatusCode | null; status_label: string | null; + failure_code?: string; knowledge_cutoff: string; requested_at: string; source_counts: AnalysisRunCount[]; diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 7e663f10a..cc28322f0 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.91.0" +__version__ = "0.92.0" diff --git a/pyproject.toml b/pyproject.toml index 2736a16a8..2a84bfa96 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.91.0" +version = "0.92.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/tests/test_analysis_run_start.py b/tests/test_analysis_run_start.py index e7752b350..795586a7e 100644 --- a/tests/test_analysis_run_start.py +++ b/tests/test_analysis_run_start.py @@ -1,16 +1,24 @@ """Start-reconstruction contracts: digest, freeze, 422/409, designed tree.""" +from datetime import datetime, timezone + +import pytest + from backend.app.analysis_run_ingestion import reconstructed_edge_is_visible from backend.app.analysis_run_start import ( AnalysisRunStartError, + configured_tepp_client, reconstruction_member_ids, reconstruction_result_digest, start_kind_rejection, start_write_conflict_error, + tepp_run_request, + tepp_submit_outcome, ) from backend.app.lineage_ingestion import records_from_source_posts from lineageweave.fixtures import sample_records from lineageweave.lineage_persistence import lineage_edge_specs +from lineageweave.tepp_client import AnalysisRunRequest, TeppClient, TeppNotAvailable def test_reconstruction_digest_is_stable_and_ignores_edge_order() -> None: @@ -85,18 +93,64 @@ def test_reconstructed_edge_hides_unaffiliated_private_titles() -> None: ) -def test_tepp_and_period_report_start_are_unprocessable() -> None: - """TEPP and period-report start stay 422 so this path cannot invent a score.""" - tepp = start_kind_rejection("analysis_run_tepp") - assert tepp is not None - assert tepp.status_code == 422 - assert "invent a measurement" in tepp.detail +def test_period_report_start_is_unprocessable_and_tepp_is_allowed() -> None: + """Period-report stays 422. TEPP start is allowed so tepp_client can run.""" report = start_kind_rejection("analysis_run_report") assert report is not None assert report.status_code == 422 assert "invent a measurement" in report.detail assert "period report" in report.detail assert start_kind_rejection("analysis_run_lineage") is None + assert start_kind_rejection("analysis_run_tepp") is None + + +def _tepp_request() -> AnalysisRunRequest: + return tepp_run_request( + idempotency_key="buyer-tepp-2026-w07", + snapshot_sha256="ab" * 32, + knowledge_cutoff=datetime(2026, 1, 12, 12, 0, tzinfo=timezone.utc), + corporate_entity_id="11111111-1111-1111-1111-111111111111", + ) + + +def test_tepp_run_request_is_the_published_wire_shape() -> None: + """Start builds TEPP's seven-field request from the frozen run.""" + request = _tepp_request() + payload = request.to_json() + assert payload["contract_version"] == 1 + assert payload["idempotency_key"] == "buyer-tepp-2026-w07" + assert payload["snapshot_id"] == "ab" * 32 + assert payload["knowledge_cutoff"] == "2026-01-12T12:00:00Z" + assert payload["model_contract_version"] == "tepp-analysis-run-v1" + assert payload["output_profile"] == "calibrated_event_measurement" + assert "theta" not in str(payload).casefold() + + +def test_tepp_submit_outcome_drops_a_missing_transport() -> None: + """A missing TEPP transport is Failed, never a fabricated score.""" + status, failure = tepp_submit_outcome(TeppClient(), _tepp_request()) + assert status == "analysis_status_failed" + assert failure == "tepp_not_available" + + +def test_tepp_submit_outcome_does_not_persist_an_empty_envelope() -> None: + """An accepted envelope is not a persistable measurement.""" + + class _Accepting(TeppClient): + def __init__(self) -> None: + super().__init__(transport=lambda _payload: {"status": "accepted"}) + + status, failure = tepp_submit_outcome(_Accepting(), _tepp_request()) + assert status == "analysis_status_failed" + assert failure == "tepp_result_not_persisted" + + +def test_configured_tepp_client_stays_unavailable_without_http() -> None: + """Empty or non-http URLs keep the default dropped channel.""" + assert isinstance(configured_tepp_client(""), TeppClient) + client = configured_tepp_client("file:///tmp/tepp.json") + with pytest.raises(TeppNotAvailable): + client.submit_analysis_run(_tepp_request()) def test_hidden_run_start_is_not_found() -> None: diff --git a/uv.lock b/uv.lock index 9f05b7e02..313978937 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.91.0" +version = "0.92.0" source = { virtual = "." } dependencies = [ { name = "certifi" },