diff --git a/CHANGELOG.d/2.20.0-tepp-terminal-lifecycle.md b/CHANGELOG.d/2.20.0-tepp-terminal-lifecycle.md new file mode 100644 index 000000000..5b2d52c68 --- /dev/null +++ b/CHANGELOG.d/2.20.0-tepp-terminal-lifecycle.md @@ -0,0 +1,7 @@ +# 2.20.0 — TEPP terminal lifecycle + +- Persists strict TEPP accepted receipts as transport evidence while the local + run remains Running. +- Reads and revalidates TEPP's terminal-result v1 contract without resubmitting + accepted work or implementing local psychometric arithmetic. +- Rejects request-binding and replay-digest mismatches before local success. diff --git a/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py index 7335f6fb9..aa04aa23d 100644 --- a/backend/app/analysis_run_ingestion.py +++ b/backend/app/analysis_run_ingestion.py @@ -422,6 +422,21 @@ async def fetch_visible_analysis_run( json.loads(envelope) if isinstance(envelope, str) else envelope ) detail["topic_lineage_result_sha256"] = topic_result["result_sha256"] + if row["run_kind_code"] == _TEPP_RUN_KIND: + receipt = await conn.fetchrow( + """ + select remote_run_id, accepted_status_code, received_at + from analysis_run_tepp_receipt + where analysis_run_id = $1 + """, + analysis_run_id, + ) + if receipt is not None: + detail["tepp_accepted_receipt"] = { + "remote_run_id": str(receipt["remote_run_id"]), + "accepted_status_code": str(receipt["accepted_status_code"]), + "received_at": _iso(receipt["received_at"]), + } return detail diff --git a/backend/app/analysis_run_start.py b/backend/app/analysis_run_start.py index c08810078..44da4a69e 100644 --- a/backend/app/analysis_run_start.py +++ b/backend/app/analysis_run_start.py @@ -38,7 +38,12 @@ 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 +from lineageweave.tepp_client import ( + AnalysisRunRequest, + TeppClient, + TeppInvalidResponse, + TeppNotAvailable, +) _LINEAGE_KIND = "analysis_run_lineage" _TEPP_KIND = "analysis_run_tepp" @@ -110,6 +115,9 @@ class _DeliveryOutcome: envelope: dict[str, Any] | None = None source_snapshot_sha256: str | None = None knowledge_cutoff: datetime | None = None + request: AnalysisRunRequest | None = None + persist_receipt: bool = False + persist_terminal_result: bool = False def reconstruction_result_digest(edges: list[Edge]) -> str: @@ -250,18 +258,145 @@ def _tepp_submission( response = client.submit_analysis_run(request) except TeppNotAvailable: return _FAILED, "tepp_not_available", None + except TeppInvalidResponse: + return _FAILED, "tepp_result_not_persisted", None if not isinstance(response, dict): return _FAILED, "tepp_result_not_persisted", None - if response.get("status") not in {"completed", "succeeded"}: + state = response.get("status") or response.get("run_state") + remote_run_id = response.get("analysis_run_id") or response.get("run_id") + if state == "accepted": + if ( + set(response) + == {"contract_version", "run_id", "run_state", "idempotency_key"} + and response["contract_version"] == 1 + and response["idempotency_key"] == request.idempotency_key + and isinstance(remote_run_id, str) + and remote_run_id.strip() + ): + return _RUNNING, "", response + return _FAILED, "tepp_result_not_persisted", None + if state not in {"completed", "succeeded"}: return _FAILED, "tepp_result_not_persisted", None if not isinstance(response.get("result"), dict): return _FAILED, "tepp_result_not_persisted", None - remote_run_id = response.get("analysis_run_id") or response.get("run_id") if not isinstance(remote_run_id, str) or not remote_run_id.strip(): return _FAILED, "tepp_result_not_persisted", None return _SUCCEEDED, "", response +def _tepp_status( + client: TeppClient, + request: AnalysisRunRequest, + remote_run_id: str, +) -> tuple[str, str, dict[str, Any] | None]: + """Read one strict provider status; unavailable reads remain retryable.""" + try: + response = client.read_analysis_run_status(remote_run_id, request) + except TeppNotAvailable: + return _RUNNING, "", None + except TeppInvalidResponse: + return _FAILED, "tepp_result_not_persisted", None + if response["run_state"] in {"accepted", "running"}: + return _RUNNING, "", response + terminal = response["terminal_result"] + if response["run_state"] == "failed": + return _FAILED, str(terminal["failure_code"]), response + return _SUCCEEDED, "", response + + +async def _persist_tepp_terminal_result( + conn: asyncpg.Connection, + *, + analysis_run_id: str, + envelope: dict[str, Any], +) -> bool: + """Persist a validated TEPP status envelope without reshaping its evidence.""" + remote_run_id = envelope.get("run_id") + if envelope.get("run_state") != "succeeded" or not isinstance(remote_run_id, str): + return False + result_json = json.dumps(envelope, separators=(",", ":"), sort_keys=True) + result_sha256 = hashlib.sha256(result_json.encode("utf-8")).hexdigest() + try: + async with conn.transaction(): + existing = await conn.fetchrow( + """ + select remote_run_id, result_sha256 + from analysis_run_tepp_result + where analysis_run_id = $1 + """, + analysis_run_id, + ) + if existing is not None: + return ( + str(existing["remote_run_id"]) == remote_run_id + and str(existing["result_sha256"]) == result_sha256 + ) + await conn.execute( + """ + insert into analysis_run_tepp_result + (analysis_run_id, remote_run_id, result_json, result_sha256) + values ($1, $2, $3::jsonb, $4) + """, + analysis_run_id, + remote_run_id, + result_json, + result_sha256, + ) + except (asyncpg.PostgresError, TypeError, ValueError): + return False + return True + + +async def _persist_tepp_receipt( + conn: asyncpg.Connection, + *, + analysis_run_id: str, + request: AnalysisRunRequest, + envelope: dict[str, Any], +) -> bool: + """Persist TEPP acceptance as transport evidence, never measurement.""" + remote_run_id = envelope.get("analysis_run_id") or envelope.get("run_id") + state = envelope.get("status") or envelope.get("run_state") + if not isinstance(remote_run_id, str) or state != "accepted": + return False + request_json = json.dumps(request.to_json(), separators=(",", ":"), sort_keys=True) + receipt_json = json.dumps(envelope, separators=(",", ":"), sort_keys=True) + request_sha256 = hashlib.sha256(request_json.encode()).hexdigest() + receipt_sha256 = hashlib.sha256(receipt_json.encode()).hexdigest() + try: + async with conn.transaction(): + existing = await conn.fetchrow( + """ + select remote_run_id, request_sha256, receipt_sha256 + from analysis_run_tepp_receipt + where analysis_run_id = $1 + """, + analysis_run_id, + ) + if existing is not None: + return ( + str(existing["remote_run_id"]) == remote_run_id + and str(existing["request_sha256"]) == request_sha256 + and str(existing["receipt_sha256"]) == receipt_sha256 + ) + await conn.execute( + """ + insert into analysis_run_tepp_receipt + (analysis_run_id, remote_run_id, request_sha256, receipt_sha256, + accepted_status_code) + values ($1, $2, $3, $4, $5) + """, + analysis_run_id, + remote_run_id, + request_sha256, + receipt_sha256, + state, + ) + except (asyncpg.PostgresError, TypeError, ValueError): + return False + return True + + def tepp_submit_outcome( client: TeppClient, request: AnalysisRunRequest, @@ -309,6 +444,8 @@ def topic_lineage_submit_outcome( item 3), not silently persisted as a topic-lineage result. """ status_code, failure_code, envelope = _tepp_submission(client, request) + if status_code == _RUNNING: + return _FAILED, "tepp_result_not_persisted", None if status_code == _SUCCEEDED and not ( envelope is not None and _topic_lineage_envelope_is_valid(envelope) ): @@ -332,6 +469,19 @@ async def _persist_tepp_result( result_sha256 = hashlib.sha256(result_json.encode("utf-8")).hexdigest() try: async with conn.transaction(): + existing = await conn.fetchrow( + """ + select remote_run_id, result_sha256 + from analysis_run_tepp_result + where analysis_run_id = $1 + """, + analysis_run_id, + ) + if existing is not None: + return ( + str(existing["remote_run_id"]) == remote_run_id + and str(existing["result_sha256"]) == result_sha256 + ) await conn.execute( """ insert into analysis_run_tepp_result @@ -899,12 +1049,14 @@ async def _claim_delivery_plan( select outbox.analysis_run_id, outbox.work_kind_code, run.knowledge_cutoff, run.idempotency_key, run.analysis_source_snapshot_id, snapshot.snapshot_sha256, - scope.corporate_entity_id + scope.corporate_entity_id, receipt.remote_run_id from analysis_run_outbox outbox join analysis_run run on run.analysis_run_id = outbox.analysis_run_id join analysis_run_scope scope on scope.analysis_run_id = run.analysis_run_id join analysis_source_snapshot snapshot on snapshot.analysis_source_snapshot_id = run.analysis_source_snapshot_id + left join analysis_run_tepp_receipt receipt + on receipt.analysis_run_id = run.analysis_run_id where outbox.analysis_run_id = $1 for update of outbox """, @@ -1003,12 +1155,20 @@ def _execute_delivery_plan( knowledge_cutoff=plan.locked["knowledge_cutoff"], corporate_entity_id=str(plan.locked["corporate_entity_id"]), ) + persist_receipt = False + persist_terminal_result = False if plan.work_kind_code == _TOPIC_LINEAGE_KIND: status_code, failure_code, envelope = topic_lineage_submit_outcome( tepp_client, request ) + elif plan.locked.get("remote_run_id"): + status_code, failure_code, envelope = _tepp_status( + tepp_client, request, str(plan.locked["remote_run_id"]) + ) + persist_terminal_result = status_code == _SUCCEEDED and envelope is not None else: status_code, failure_code, envelope = _tepp_submission(tepp_client, request) + persist_receipt = status_code == _RUNNING and envelope is not None return _DeliveryOutcome( plan.work_kind_code, plan.started_at, @@ -1017,6 +1177,9 @@ def _execute_delivery_plan( envelope=envelope, source_snapshot_sha256=str(plan.locked["snapshot_sha256"]), knowledge_cutoff=plan.locked["knowledge_cutoff"], + request=request, + persist_receipt=persist_receipt, + persist_terminal_result=persist_terminal_result, ) @@ -1044,12 +1207,41 @@ async def _persist_delivery_outcome( finished = max(datetime.now(timezone.utc), outcome.started_at) status_code = outcome.status_code failure_code = outcome.failure_code + if outcome.work_kind_code == _TEPP_KIND and outcome.status_code == _RUNNING: + if outcome.persist_receipt: + persisted_receipt = ( + outcome.envelope is not None + and outcome.request is not None + and await _persist_tepp_receipt( + conn, + analysis_run_id=analysis_run_id, + request=outcome.request, + envelope=outcome.envelope, + ) + ) + if not persisted_receipt: + status_code = _FAILED + failure_code = "tepp_receipt_not_persisted" + else: + return await _visible_or_404( + conn, analysis_run_id, account_id, affiliated_entity_ids + ) + else: + return await _visible_or_404( + conn, analysis_run_id, account_id, affiliated_entity_ids + ) if outcome.work_kind_code == _LINEAGE_KIND: await _persist_lineage_reconstruction( conn, analysis_run_id=analysis_run_id, edges=outcome.edges, finished=finished ) elif outcome.status_code == _SUCCEEDED and outcome.envelope is not None: - if outcome.work_kind_code == _TOPIC_LINEAGE_KIND: + if outcome.persist_terminal_result: + persisted = await _persist_tepp_terminal_result( + conn, + analysis_run_id=analysis_run_id, + envelope=outcome.envelope, + ) + elif outcome.work_kind_code == _TOPIC_LINEAGE_KIND: persisted = await _persist_topic_lineage_result( conn, analysis_run_id=analysis_run_id, envelope=outcome.envelope ) diff --git a/docs/adr/0219-tepp-terminal-result-lifecycle.md b/docs/adr/0219-tepp-terminal-result-lifecycle.md new file mode 100644 index 000000000..e100ab858 --- /dev/null +++ b/docs/adr/0219-tepp-terminal-result-lifecycle.md @@ -0,0 +1,80 @@ +# ADR 0219 — Persist TEPP acceptance and consume terminal results + +**Decision status:** Accepted on this active PR; not protected-main truth until merge +**Date:** 2026-08-26 +**Depends on:** ADR 0022, ADR 0023, ADR 0204; TEPP PR #157 +**Refs:** LineageWeave issue #277; TEPP issues #156 and #249 + +## Context + +TEPP's `AnalysisRunAccepted` is transport evidence, not measurement. TEPP +PR #157 merged strict `AnalysisRunStatus` and `AnalysisRunTerminalResult` v1 Rust +contracts, but deliberately did not deploy a production HTTP status service. +LineageWeave must retain accepted asynchronous work and consume a future +provider result without guessing a URL, retry interval, score, or theta. + +## Decision + +The existing PostgreSQL outbox remains lifecycle authority. A strict accepted +v1 response persists to `analysis_run_tepp_receipt`, leaves the local run +Running, and leaves the outbox claimed. A later delivery retry sees that +receipt and invokes `TeppClient`'s pluggable status-read port rather than +resubmitting the request. + +The status consumer enforces the provider's 64 KiB limit and exact v1 shape, +then revalidates remote run, idempotency key, tenant/workspace, snapshot, +knowledge cutoff, model contract, output profile, terminal state, RFC 3339 +completion time, result artifact/schema, lowercase SHA-256 digest, bounded +identity-free summary, and failure code. Accepted/running contains no terminal +result. Succeeded persists the validated terminal DTO before the local +Succeeded event. Failed persists no result and appends the validated provider +failure code. Any changed terminal payload for the same local run fails closed. + +Provider work remains outside the asyncpg pool and transaction under ADR 0204. +The configured HTTP client does not synthesize the target +`GET /v1/analysis-runs/{run_id}` route. TEPP issue #249 owns its executable +service and evidence-based retry policy. + +The `Analysis/TeppAcceptedReceipt` Storybook scene asserts that acceptance does +not read as measurement or success. Its synthetic desktop (1280×720) and mobile +(390×844) renderings were screenshot-reviewed on this exact head; neither +screenshot is committed, preserving the repository artifact boundary. + +```mermaid +sequenceDiagram + participant Worker + participant Registry + participant TEPP + Worker->>TEPP: submit immutable request v1 + TEPP-->>Worker: accepted receipt v1 + Worker->>Registry: persist receipt; remain Running + Worker->>Registry: later claim reads remote run id + Worker->>TEPP: status read through provider port + alt accepted or running + Note over Registry: remain Running + else succeeded and bound + Worker->>Registry: terminal DTO + Succeeded + else failed and bound + Worker->>Registry: typed Failed, no result + else invalid or mismatched + Worker->>Registry: fail closed + end +``` + +## Consequences + +LineageWeave owns transport and provenance persistence only. TEPP retains all +statistical, psychometric, CPU, and GPU arithmetic. Automatic polling remains +unavailable until the owning service publishes its route and retry policy. + +## References — APA 7th + +Hohpe, G., & Woolf, B. (2003). *Enterprise integration patterns: Designing, +building, and deploying messaging solutions*. Addison-Wesley. + +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/ diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 2dee4513d..d5d5f6792 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -385,6 +385,15 @@ describe("App, authenticated", () => { status_label: teppLabel, knowledge_cutoff: "2026-01-12T12:00:00Z", requested_at: "2026-01-12T12:34:00Z", + ...(options?.succeededTeppRun + ? { + tepp_accepted_receipt: { + remote_run_id: "tepp-remote-run-1", + accepted_status_code: "accepted", + received_at: "2026-01-12T12:36:00Z", + }, + } + : {}), source_counts: [ { count_type_code: "analysis_count_document", @@ -3802,6 +3811,10 @@ describe("App, authenticated", () => { expect( await screen.findByText("These posts are the cutoff corpus this TEPP run measured."), ).toBeInTheDocument(); + expect(screen.getByLabelText("Measurement request accepted")).toHaveTextContent( + "Refresh this run to check whether results are ready.", + ); + expect(screen.queryByText("tepp-remote-run-1")).not.toBeInTheDocument(); expect(screen.queryByText(/replace Failed/i)).not.toBeInTheDocument(); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fbba1d9f2..ac86e6b59 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -97,6 +97,7 @@ import { AskEvidenceLayerPopup } from "./components/AskEvidenceLayerPopup"; import { PublicClaimVerification } from "./components/PublicClaimVerification"; import { PopupCloseButton } from "./components/PopupCloseButton"; import { SimilarVocPanel } from "./components/SimilarVocPanel"; +import { TeppAcceptedReceipt } from "./components/TeppAcceptedReceipt"; import { chatEvidenceKindLabel } from "./evidenceKindLabels"; import { WorkspaceNav, type WorkspaceDestination } from "./components/WorkspaceNav"; import { OperationsDashboard } from "./components/OperationsDashboard"; @@ -3179,6 +3180,9 @@ function AnalysisRunsPanel({ {" · "} Requested {selected.requested_at.slice(0, 10)}

+ {selected.tepp_accepted_receipt && ( + + )} ; topic_lineage_result_sha256?: string; + tepp_accepted_receipt?: AnalysisRunTeppAcceptedReceipt; code_revision_sha?: string; configuration_sha256?: string; } diff --git a/frontend/src/components/TeppAcceptedReceipt.stories.tsx b/frontend/src/components/TeppAcceptedReceipt.stories.tsx new file mode 100644 index 000000000..f5ded3e48 --- /dev/null +++ b/frontend/src/components/TeppAcceptedReceipt.stories.tsx @@ -0,0 +1,21 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, within } from "storybook/test"; +import { TeppAcceptedReceipt } from "./TeppAcceptedReceipt"; +import "../App.css"; + +const meta = { + title: "Analysis/TeppAcceptedReceipt", + component: TeppAcceptedReceipt, + parameters: { layout: "padded" }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Accepted: Story = { + play: async ({ canvasElement }) => { + const receipt = within(canvasElement).getByLabelText("Measurement request accepted"); + await expect(receipt).toHaveTextContent("Refresh this run"); + await expect(receipt).not.toHaveTextContent(/TEPP|remote|identifier|succeeded/i); + }, +}; diff --git a/frontend/src/components/TeppAcceptedReceipt.tsx b/frontend/src/components/TeppAcceptedReceipt.tsx new file mode 100644 index 000000000..dcd430a86 --- /dev/null +++ b/frontend/src/components/TeppAcceptedReceipt.tsx @@ -0,0 +1,8 @@ +/** Provider acceptance evidence; it deliberately makes no measurement claim. */ +export function TeppAcceptedReceipt() { + return ( +

+ Measurement request accepted. Refresh this run to check whether results are ready. +

+ ); +} diff --git a/lineageweave/tepp_client.py b/lineageweave/tepp_client.py index 7dbfd886f..0dbc51c33 100644 --- a/lineageweave/tepp_client.py +++ b/lineageweave/tepp_client.py @@ -18,7 +18,11 @@ from __future__ import annotations +import json +import re +import unicodedata from dataclasses import dataclass +from datetime import datetime from typing import Any, Callable @@ -26,6 +30,10 @@ class TeppNotAvailable(RuntimeError): """Raised by the default transport: TEPP has no live REST API yet.""" +class TeppInvalidResponse(ValueError): + """Raised when TEPP returns a status payload outside its v1 contract.""" + + def _no_transport(request: dict[str, Any]) -> dict[str, Any]: """Implement the _no_transport operation for this channel.""" raise TeppNotAvailable( @@ -35,6 +43,11 @@ def _no_transport(request: dict[str, Any]) -> dict[str, Any]: ) +def _no_status_transport(remote_run_id: str) -> dict[str, Any]: + """Refuse status reads until a provider-owned read transport is supplied.""" + raise TeppNotAvailable(f"TEPP status transport unavailable for {remote_run_id!r}") + + @dataclass(frozen=True) class AnalysisRunRequest: """Mirrors TEPP's ``schemas/analysis_run_request_v1.json`` exactly. @@ -75,9 +88,177 @@ class exists so the rest of LineageWeave can be written against a touching any other module. """ - def __init__(self, transport: Callable[[dict[str, Any]], dict[str, Any]] = _no_transport) -> None: + def __init__( + self, + transport: Callable[[dict[str, Any]], dict[str, Any]] = _no_transport, + *, + status_transport: Callable[[str], dict[str, Any]] = _no_status_transport, + ) -> None: self._transport = transport + self._status_transport = status_transport def submit_analysis_run(self, request: AnalysisRunRequest) -> dict[str, Any]: """Submit a request; returns TEPP's ``AnalysisRunAccepted`` envelope.""" - return self._transport(request.to_json()) + response = self._transport(request.to_json()) + if ( + isinstance(response, dict) + and response.get("run_state") == "accepted" + and not _valid_analysis_run_accepted(request, response) + ): + raise TeppInvalidResponse("TEPP analysis-run accepted response was invalid") + return response + + def read_analysis_run_status( + self, remote_run_id: str, request: AnalysisRunRequest + ) -> dict[str, Any]: + """Read and validate TEPP's request-bound status/result v1 payload.""" + response = self._status_transport(remote_run_id) + if not _valid_analysis_run_status(remote_run_id, request, response): + raise TeppInvalidResponse("TEPP analysis-run status response was invalid") + return response + + +_SHA256 = re.compile(r"[0-9a-f]{64}") +_FAILURE_CODE = re.compile(r"[a-z][a-z0-9_]{0,63}") +_RFC3339 = re.compile( + r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})" +) + + +def _valid_analysis_run_accepted( + request: AnalysisRunRequest, response: object +) -> bool: + """Mirror TEPP's bounded, strict accepted-response v1 contract.""" + if not isinstance(response, dict) or set(response) != { + "contract_version", + "run_id", + "run_state", + "idempotency_key", + }: + return False + try: + encoded = json.dumps(response, separators=(",", ":"), ensure_ascii=False).encode() + except (TypeError, ValueError): + return False + return ( + len(encoded) <= 64 * 1024 + and response["contract_version"] == 1 + and response["run_state"] == "accepted" + and response["idempotency_key"] == request.idempotency_key + and _nonempty(response["run_id"]) + and _nonempty(response["idempotency_key"]) + ) + + +def _nonempty(value: object) -> bool: + """Return whether a wire string contains non-whitespace, non-control text.""" + return ( + isinstance(value, str) + and bool(value.strip()) + and not any(unicodedata.category(char) == "Cc" for char in value) + ) + + +def _rfc3339(value: object) -> bool: + """Accept a timezone-bearing RFC 3339 timestamp understood by Python.""" + if not isinstance(value, str) or _RFC3339.fullmatch(value) is None: + return False + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return False + return parsed.tzinfo is not None + + +def _valid_analysis_run_status( + remote_run_id: str, request: AnalysisRunRequest, response: object +) -> bool: + """Validate TEPP v1 status and every terminal request binding.""" + if not isinstance(response, dict): + return False + try: + encoded = json.dumps( + response, separators=(",", ":"), ensure_ascii=False + ).encode() + except (TypeError, ValueError): + return False + if len(encoded) > 64 * 1024: + return False + status_keys = { + "contract_version", + "run_id", + "run_state", + "idempotency_key", + "terminal_result", + } + if ( + set(response) != status_keys + or response["contract_version"] != 1 + or response["run_id"] != remote_run_id + or response["idempotency_key"] != request.idempotency_key + ): + return False + state = response["run_state"] + terminal = response["terminal_result"] + if state in {"accepted", "running"}: + return terminal is None + if state not in {"succeeded", "failed"} or not isinstance(terminal, dict): + return False + terminal_keys = { + "contract_version", + "run_id", + "run_state", + "idempotency_key", + "tenant_workspace_id", + "snapshot_id", + "knowledge_cutoff", + "model_contract_version", + "output_profile", + "result_artifact_id", + "result_sha256", + "result_schema_version", + "completed_at", + "summary", + "failure_code", + } + if ( + set(terminal) != terminal_keys + or terminal["contract_version"] != 1 + or terminal["run_id"] != remote_run_id + or terminal["run_state"] != state + or terminal["idempotency_key"] != request.idempotency_key + or terminal["tenant_workspace_id"] != request.tenant_workspace_id + or terminal["snapshot_id"] != request.snapshot_id + or terminal["knowledge_cutoff"] != request.knowledge_cutoff + or not _rfc3339(terminal["knowledge_cutoff"]) + or terminal["model_contract_version"] != request.model_contract_version + or terminal["output_profile"] != request.output_profile + or not _rfc3339(terminal["completed_at"]) + ): + return False + if state == "failed": + return ( + terminal["result_artifact_id"] is None + and terminal["result_sha256"] is None + and terminal["result_schema_version"] is None + and terminal["summary"] is None + and isinstance(terminal["failure_code"], str) + and _FAILURE_CODE.fullmatch(terminal["failure_code"]) is not None + ) + summary = terminal["summary"] + return ( + _nonempty(terminal["result_artifact_id"]) + and isinstance(terminal["result_sha256"], str) + and _SHA256.fullmatch(terminal["result_sha256"]) is not None + and _nonempty(terminal["result_schema_version"]) + and terminal["failure_code"] is None + and isinstance(summary, dict) + and set(summary) + == {"analysis_family", "evidence_count", "statistic_count", "validation_status"} + and _nonempty(summary["analysis_family"]) + and _nonempty(summary["validation_status"]) + and type(summary["evidence_count"]) is int + and 0 <= summary["evidence_count"] <= 1_000_000_000 + and type(summary["statistic_count"]) is int + and 0 <= summary["statistic_count"] <= 1_000_000_000 + ) diff --git a/migrations/0217_analysis_run_tepp_receipt.sql b/migrations/0217_analysis_run_tepp_receipt.sql new file mode 100644 index 000000000..1cbbfaa8f --- /dev/null +++ b/migrations/0217_analysis_run_tepp_receipt.sql @@ -0,0 +1,14 @@ +-- TEPP acceptance is durable transport evidence, never a measurement result. +create table if not exists analysis_run_tepp_receipt ( + analysis_run_id uuid primary key + references analysis_run (analysis_run_id) on delete cascade, + remote_run_id text not null unique check (btrim(remote_run_id) <> ''), + request_sha256 text not null check (request_sha256 ~ '^[0-9a-f]{64}$'), + receipt_sha256 text not null check (receipt_sha256 ~ '^[0-9a-f]{64}$'), + accepted_status_code text not null + check (accepted_status_code = 'accepted'), + received_at timestamptz not null default clock_timestamp() +); + +create index if not exists analysis_run_tepp_receipt_received_idx + on analysis_run_tepp_receipt (received_at); diff --git a/migrations/rollback/0217_analysis_run_tepp_receipt.sql b/migrations/rollback/0217_analysis_run_tepp_receipt.sql new file mode 100644 index 000000000..143172d68 --- /dev/null +++ b/migrations/rollback/0217_analysis_run_tepp_receipt.sql @@ -0,0 +1 @@ +drop table if exists analysis_run_tepp_receipt; diff --git a/tests/test_analysis_run_start.py b/tests/test_analysis_run_start.py index 8f508e954..a3e317470 100644 --- a/tests/test_analysis_run_start.py +++ b/tests/test_analysis_run_start.py @@ -10,6 +10,8 @@ from backend.app.analysis_run_ingestion import reconstructed_edge_is_visible from backend.app.analysis_run_start import ( AnalysisRunStartError, + _persist_tepp_receipt, + _persist_tepp_terminal_result, _persist_tepp_result, configured_tepp_client, reconstruction_member_ids, @@ -216,6 +218,279 @@ def _tepp_request() -> AnalysisRunRequest: ) +def test_tepp_delivery_separates_acceptance_from_terminal_measurement() -> None: + locked = { + "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", + "remote_run_id": None, + } + plan = analysis_run_start._DeliveryPlan( + "analysis_run_tepp", datetime(2026, 1, 12, tzinfo=timezone.utc), locked + ) + accepted = analysis_run_start._execute_delivery_plan( + plan, + TeppClient( + transport=lambda _payload: { + "contract_version": 1, + "run_id": "remote-run-1", + "run_state": "accepted", + "idempotency_key": "buyer-tepp-2026-w07", + } + ), + None, + ) + + assert accepted.status_code == "analysis_status_running" + assert accepted.persist_receipt + assert accepted.request == _tepp_request() + + +def test_tepp_delivery_reads_a_stored_remote_run_without_resubmitting() -> None: + request = _tepp_request() + status = { + "contract_version": 1, + "run_id": "remote-run-1", + "run_state": "running", + "idempotency_key": request.idempotency_key, + "terminal_result": None, + } + locked = { + "idempotency_key": request.idempotency_key, + "snapshot_sha256": request.snapshot_id, + "knowledge_cutoff": datetime(2026, 1, 12, 12, 0, tzinfo=timezone.utc), + "corporate_entity_id": request.tenant_workspace_id, + "remote_run_id": "remote-run-1", + } + outcome = analysis_run_start._execute_delivery_plan( + analysis_run_start._DeliveryPlan( + "analysis_run_tepp", datetime(2026, 1, 12, tzinfo=timezone.utc), locked + ), + TeppClient( + transport=lambda _payload: pytest.fail("accepted work was resubmitted"), + status_transport=lambda _run_id: status, + ), + None, + ) + + assert outcome.status_code == "analysis_status_running" + assert not outcome.persist_receipt + + +def test_tepp_delivery_keeps_the_full_terminal_status_for_persistence() -> None: + request = _tepp_request() + terminal = { + "contract_version": 1, + "run_id": "remote-run-1", + "run_state": "succeeded", + "idempotency_key": request.idempotency_key, + "tenant_workspace_id": request.tenant_workspace_id, + "snapshot_id": request.snapshot_id, + "knowledge_cutoff": request.knowledge_cutoff, + "model_contract_version": request.model_contract_version, + "output_profile": request.output_profile, + "result_artifact_id": "artifact-1", + "result_sha256": "ab" * 32, + "result_schema_version": "tepp-result-v1", + "completed_at": "2026-01-13T00:00:00Z", + "summary": { + "analysis_family": "temporal_topic_measurement", + "evidence_count": 1, + "statistic_count": 1, + "validation_status": "validated", + }, + "failure_code": None, + } + status = { + "contract_version": 1, + "run_id": "remote-run-1", + "run_state": "succeeded", + "idempotency_key": request.idempotency_key, + "terminal_result": terminal, + } + outcome = analysis_run_start._execute_delivery_plan( + analysis_run_start._DeliveryPlan( + "analysis_run_tepp", + datetime(2026, 1, 12, tzinfo=timezone.utc), + { + "idempotency_key": request.idempotency_key, + "snapshot_sha256": request.snapshot_id, + "knowledge_cutoff": datetime(2026, 1, 12, 12, tzinfo=timezone.utc), + "corporate_entity_id": request.tenant_workspace_id, + "remote_run_id": "remote-run-1", + }, + ), + TeppClient(status_transport=lambda _run_id: status), + None, + ) + + assert outcome.status_code == "analysis_status_succeeded" + assert outcome.persist_terminal_result + assert outcome.envelope == status + + +def test_tepp_terminal_result_replay_must_match() -> None: + class _Transaction: + async def __aenter__(self): + return self + + async def __aexit__(self, *_args): + return False + + class _Connection: + def __init__(self) -> None: + self.existing = None + self.inserted: tuple[object, ...] | None = None + + def transaction(self): + return _Transaction() + + async def fetchrow(self, _query: str, *_args: object): + return self.existing + + async def execute(self, _query: str, *args: object): + self.inserted = args + + envelope = { + "contract_version": 1, + "run_id": "remote-run-1", + "run_state": "succeeded", + "terminal_result": {"result_artifact_id": "artifact-1"}, + } + conn = _Connection() + assert asyncio.run( + _persist_tepp_terminal_result( + conn, + analysis_run_id="11111111-1111-1111-1111-111111111111", + envelope=envelope, + ) + ) + assert conn.inserted is not None + conn.existing = { + "remote_run_id": conn.inserted[1], + "result_sha256": conn.inserted[3], + } + assert asyncio.run( + _persist_tepp_terminal_result( + conn, + analysis_run_id="11111111-1111-1111-1111-111111111111", + envelope=envelope, + ) + ) + conn.existing["result_sha256"] = "0" * 64 + assert not asyncio.run( + _persist_tepp_terminal_result( + conn, + analysis_run_id="11111111-1111-1111-1111-111111111111", + envelope=envelope, + ) + ) + + +def test_tepp_acceptance_receipt_replay_must_match() -> None: + """A provider replay cannot replace the remote identity or evidence digest.""" + + class _Transaction: + async def __aenter__(self): + return self + + async def __aexit__(self, *_args): + return False + + class _Connection: + def __init__(self) -> None: + self.existing = None + self.inserted: tuple[object, ...] | None = None + + def transaction(self): + return _Transaction() + + async def fetchrow(self, _query: str, *_args: object): + return self.existing + + async def execute(self, _query: str, *args: object): + self.inserted = args + + request = _tepp_request() + receipt = { + "contract_version": 1, + "run_id": "remote-run-1", + "run_state": "accepted", + "idempotency_key": request.idempotency_key, + } + conn = _Connection() + assert asyncio.run( + _persist_tepp_receipt( + conn, + analysis_run_id="11111111-1111-1111-1111-111111111111", + request=request, + envelope=receipt, + ) + ) + assert conn.inserted is not None + conn.existing = { + "remote_run_id": conn.inserted[1], + "request_sha256": conn.inserted[2], + "receipt_sha256": conn.inserted[3], + } + assert asyncio.run( + _persist_tepp_receipt( + conn, + analysis_run_id="11111111-1111-1111-1111-111111111111", + request=request, + envelope=receipt, + ) + ) + conn.existing["remote_run_id"] = "changed-run" + assert not asyncio.run( + _persist_tepp_receipt( + conn, + analysis_run_id="11111111-1111-1111-1111-111111111111", + request=request, + envelope=receipt, + ) + ) + + +def test_tepp_persistence_conflict_rolls_back_savepoint() -> None: + """A provider identity conflict fails closed without aborting its caller.""" + + class _Transaction: + async def __aenter__(self): + return self + + async def __aexit__(self, *_args): + return False + + class _Connection: + def transaction(self): + return _Transaction() + + async def fetchrow(self, _query: str, *_args: object): + return None + + async def execute(self, _query: str, *_args: object): + raise analysis_run_start.asyncpg.UniqueViolationError("duplicate remote run") + + request = _tepp_request() + assert not asyncio.run( + _persist_tepp_receipt( + _Connection(), + analysis_run_id="11111111-1111-1111-1111-111111111111", + request=request, + envelope={"run_id": "remote-run-1", "run_state": "accepted"}, + ) + ) + assert not asyncio.run( + _persist_tepp_terminal_result( + _Connection(), + analysis_run_id="11111111-1111-1111-1111-111111111111", + envelope={"run_id": "remote-run-1", "run_state": "succeeded"}, + ) + ) + + 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() @@ -279,6 +554,9 @@ def transaction(self): async def execute(self, query: str, *args: object): self.queries.append((query, args)) + async def fetchrow(self, _query: str, *_args: object): + return None + cutoff = datetime(2026, 1, 12, 12, 0, tzinfo=timezone.utc) conn = _Connection() envelope = { @@ -393,6 +671,28 @@ def __init__(self) -> None: assert failure == "tepp_result_not_persisted" +def test_topic_lineage_submit_outcome_rejects_strict_async_acceptance() -> None: + """Topic lineage has no receipt/resume path, so acceptance fails closed.""" + + class _Accepting(TeppClient): + def __init__(self) -> None: + super().__init__( + transport=lambda payload: { + "contract_version": 1, + "run_id": "topic-run-1", + "run_state": "accepted", + "idempotency_key": payload["idempotency_key"], + } + ) + + status, failure, envelope = topic_lineage_submit_outcome( + _Accepting(), _topic_lineage_request() + ) + assert status == "analysis_status_failed" + assert failure == "tepp_result_not_persisted" + assert envelope is None + + def test_topic_lineage_submit_outcome_rejects_a_contentless_completed_envelope() -> None: """A 'completed' envelope missing the topic-identity/CHRONOS contract is Failed. diff --git a/tests/test_migration_replay.py b/tests/test_migration_replay.py index 95c5c4fe4..af7f6d1fa 100644 --- a/tests/test_migration_replay.py +++ b/tests/test_migration_replay.py @@ -196,6 +196,36 @@ def test_topic_lineage_result_migration_is_idempotent_for_replay() -> None: assert "create index if not exists" in migration +def test_tepp_receipt_migration_is_replayable_and_digest_bound() -> None: + """Accepted transport evidence survives every-start migration replay.""" + migration_name = "0217_analysis_run_tepp_receipt.sql" + sql = ( + Path(__file__).resolve().parents[1] / "migrations" / migration_name + ).read_text(encoding="utf-8").casefold() + + assert re.fullmatch(r"[0-9]{4}_.+\.sql", migration_name) + assert "create table if not exists analysis_run_tepp_receipt" in sql + assert "remote_run_id text not null unique" in sql + assert "request_sha256 ~ '^[0-9a-f]{64}$'" in sql + assert "receipt_sha256 ~ '^[0-9a-f]{64}$'" in sql + assert "accepted_status_code = 'accepted'" in sql + assert "create index if not exists" in sql + + +def test_tepp_receipt_read_requires_the_replayed_schema() -> None: + """A missing required table must fail before it poisons a claim transaction.""" + source = ( + Path(__file__).resolve().parents[1] + / "backend" + / "app" + / "analysis_run_ingestion.py" + ).read_text(encoding="utf-8") + receipt_block = source.split('if row["run_kind_code"] == _TEPP_RUN_KIND:', 1)[1] + receipt_block = receipt_block.split("return detail", 1)[0] + + assert "from analysis_run_tepp_receipt" in receipt_block + assert "UndefinedTableError" not in receipt_block + def test_global_ask_job_migrations_are_idempotent_for_replay() -> None: """Existing volumes must replay the queue and authorization scope safely.""" migrations = Path(__file__).resolve().parents[1] / "migrations" diff --git a/tests/test_tepp_client.py b/tests/test_tepp_client.py index 01fc91a94..9824a684f 100644 --- a/tests/test_tepp_client.py +++ b/tests/test_tepp_client.py @@ -3,7 +3,12 @@ import pytest from backend.app.analysis_run_start import configured_tepp_client -from lineageweave.tepp_client import AnalysisRunRequest, TeppClient, TeppNotAvailable +from lineageweave.tepp_client import ( + AnalysisRunRequest, + TeppClient, + TeppInvalidResponse, + TeppNotAvailable, +) def _sample_request() -> AnalysisRunRequest: @@ -11,7 +16,7 @@ def _sample_request() -> AnalysisRunRequest: idempotency_key="demo-run-1", tenant_workspace_id="demo-workspace", snapshot_id="demo-snapshot-1", - knowledge_cutoff="2026-01-01", + knowledge_cutoff="2026-01-01T00:00:00Z", model_contract_version="v1", output_profile="graphml", ) @@ -25,7 +30,7 @@ def test_to_json_matches_tepp_published_schema_shape() -> None: "idempotency_key": "demo-run-1", "tenant_workspace_id": "demo-workspace", "snapshot_id": "demo-snapshot-1", - "knowledge_cutoff": "2026-01-01", + "knowledge_cutoff": "2026-01-01T00:00:00Z", "model_contract_version": "v1", "output_profile": "graphml", } @@ -35,6 +40,68 @@ def test_default_transport_fails_closed_until_tepp_ships_http() -> None: client = TeppClient() with pytest.raises(TeppNotAvailable): client.submit_analysis_run(_sample_request()) + with pytest.raises(TeppNotAvailable): + client.read_analysis_run_status("tepp-run-1", _sample_request()) + + +def _terminal_status() -> dict: + request = _sample_request() + return { + "contract_version": 1, + "run_id": "tepp-run-1", + "run_state": "succeeded", + "idempotency_key": request.idempotency_key, + "terminal_result": { + "contract_version": 1, + "run_id": "tepp-run-1", + "run_state": "succeeded", + "idempotency_key": request.idempotency_key, + "tenant_workspace_id": request.tenant_workspace_id, + "snapshot_id": request.snapshot_id, + "knowledge_cutoff": request.knowledge_cutoff, + "model_contract_version": request.model_contract_version, + "output_profile": request.output_profile, + "result_artifact_id": "artifact-1", + "result_sha256": "ab" * 32, + "result_schema_version": "tepp-result-v1", + "completed_at": "2026-01-02T03:04:05Z", + "summary": { + "analysis_family": "temporal_topic_measurement", + "evidence_count": 12, + "statistic_count": 4, + "validation_status": "validated", + }, + "failure_code": None, + }, + } + + +def test_status_reader_accepts_only_request_bound_terminal_results() -> None: + status = _terminal_status() + client = TeppClient(status_transport=lambda _run_id: status) + assert client.read_analysis_run_status("tepp-run-1", _sample_request()) == status + + status["terminal_result"]["snapshot_id"] = "other-snapshot" + with pytest.raises(TeppInvalidResponse): + client.read_analysis_run_status("tepp-run-1", _sample_request()) + + +def test_status_reader_requires_strict_rfc3339_terminal_time() -> None: + status = _terminal_status() + status["terminal_result"]["completed_at"] = "2026-01-02 03:04:05+00:00" + client = TeppClient(status_transport=lambda _run_id: status) + with pytest.raises(TeppInvalidResponse): + client.read_analysis_run_status("tepp-run-1", _sample_request()) + + +@pytest.mark.parametrize( + "status", + [None, {}, {"unencodable": {1}}, {"result_artifact_id": "x" * (64 * 1024)}], +) +def test_status_reader_rejects_invalid_or_oversized_payloads(status) -> None: + client = TeppClient(status_transport=lambda _run_id: status) + with pytest.raises(TeppInvalidResponse): + client.read_analysis_run_status("tepp-run-1", _sample_request()) def test_custom_transport_receives_the_exact_wire_payload() -> None: @@ -52,6 +119,20 @@ def fake_transport(payload: dict) -> dict: assert received["snapshot_id"] == "demo-snapshot-1" +def test_accepted_response_rejects_oversized_provider_identity() -> None: + client = TeppClient( + transport=lambda _payload: { + "contract_version": 1, + "run_id": "x" * (64 * 1024), + "run_state": "accepted", + "idempotency_key": _sample_request().idempotency_key, + } + ) + + with pytest.raises(TeppInvalidResponse): + client.submit_analysis_run(_sample_request()) + + def test_configured_transport_sends_tepp_consumer_contract_headers(monkeypatch: pytest.MonkeyPatch) -> None: received = {}