From 96b4dbd2f5d4adf09ef331ef8ab31e5d8563ea4d Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 03:14:51 +0900 Subject: [PATCH 01/11] feat: persist TEPP asynchronous lifecycle evidence --- CHANGELOG.d/2.20.0-tepp-terminal-lifecycle.md | 7 + backend/app/analysis_run_ingestion.py | 18 +++ backend/app/analysis_run_start.py | 139 +++++++++++++++- .../0213-tepp-terminal-result-lifecycle.md | 80 ++++++++++ docs/product-technical-gap-baseline.md | 17 +- frontend/src/App.test.tsx | 12 ++ frontend/src/App.tsx | 6 + frontend/src/api.ts | 7 + .../TeppAcceptedReceipt.stories.tsx | 22 +++ .../src/components/TeppAcceptedReceipt.tsx | 13 ++ lineageweave/tepp_client.py | 151 +++++++++++++++++- migrations/0210_analysis_run_tepp_receipt.sql | 14 ++ .../0210_analysis_run_tepp_receipt.sql | 1 + tests/test_analysis_run_start.py | 119 ++++++++++++++ tests/test_migration_replay.py | 16 ++ tests/test_tepp_client.py | 73 ++++++++- 16 files changed, 686 insertions(+), 9 deletions(-) create mode 100644 CHANGELOG.d/2.20.0-tepp-terminal-lifecycle.md create mode 100644 docs/adr/0213-tepp-terminal-result-lifecycle.md create mode 100644 frontend/src/components/TeppAcceptedReceipt.stories.tsx create mode 100644 frontend/src/components/TeppAcceptedReceipt.tsx create mode 100644 migrations/0210_analysis_run_tepp_receipt.sql create mode 100644 migrations/rollback/0210_analysis_run_tepp_receipt.sql 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..f91532c2c 100644 --- a/backend/app/analysis_run_ingestion.py +++ b/backend/app/analysis_run_ingestion.py @@ -422,6 +422,24 @@ 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: + try: + 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, + ) + except asyncpg.UndefinedTableError: + receipt = None + 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..41a1d53a3 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,8 @@ 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 def reconstruction_result_digest(edges: list[Edge]) -> str: @@ -252,16 +259,94 @@ def _tepp_submission( return _FAILED, "tepp_not_available", 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"]), terminal + return _SUCCEEDED, "", terminal + + +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() + 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, + ) + return True + + def tepp_submit_outcome( client: TeppClient, request: AnalysisRunRequest, @@ -332,6 +417,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 +997,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 +1103,18 @@ def _execute_delivery_plan( knowledge_cutoff=plan.locked["knowledge_cutoff"], corporate_entity_id=str(plan.locked["corporate_entity_id"]), ) + persist_receipt = 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"]) + ) 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 +1123,8 @@ 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, ) @@ -1044,6 +1152,29 @@ 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 diff --git a/docs/adr/0213-tepp-terminal-result-lifecycle.md b/docs/adr/0213-tepp-terminal-result-lifecycle.md new file mode 100644 index 000000000..8e3875608 --- /dev/null +++ b/docs/adr/0213-tepp-terminal-result-lifecycle.md @@ -0,0 +1,80 @@ +# ADR 0213 — 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/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 9a65c2eb6..3519de48c 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,5 +1,20 @@ # Product & Technical Gap Baseline +> TEPP lifecycle continuation snapshot: 2026-08-26. Protected `main` is +> `04e6b610655d0db91d5f7ba9486bdda1440e0b19`; the nine open main-target PRs +> are #644, #643, #640, #639, #636, #632, #631, #629, and #579. Eight were +> auto-merge armed; updated #632 (`679df1d7`) had fresh checks queued and no +> auto-merge request at the exact-head refresh. None had the required +> independent exact-head approval. ADR 0213 now carries +> the current-main implementation of issue #277 after TEPP PR #157 published +> the terminal DTO. TEPP issue #249 owns the still-missing executable HTTP +> status service. No protected merge or live measurement is claimed here. +> Focused backend lifecycle/authorization/schema verification is 76 passed; +> frontend verification is 382 passed plus lint and production build. The +> broader backend run reached 1,195 passed and 17 skipped before 127 existing +> live-PostgreSQL fixture errors (duplicate seeded snapshot digest) prevented a +> green full-suite claim. + > Dashboard delivery snapshot: 2026-08-25 21:34 KST. Protected `main` was > `d7d5eeb310b055b5e138060cf2dfb929b03090a6`. This local branch is not > protected-main release evidence. @@ -346,7 +361,7 @@ this file per §3.5 of the prior snapshot). | #271 | Evidence-honest knowledge-cutoff scope on Global Ask | Ask stack | | #272 | Verify Global Ask KG/ontology/semantic claims with public SearXNG evidence | Ask stack | | #274 | Persist and explain Event Lineage channel evidence | #387 | -| #277 | TEPP: persist accepted receipts, poll completed results, keep measurement authority distinct | #468, #417 | +| #277 | TEPP: persist accepted receipts, read completed results, keep measurement authority distinct | ADR 0213 current-main continuation; provider HTTP route tracked by TEPP #249 | | #280 | Full project-lifecycle history and handover intervals | Tracked with issue #284; no active delivery PR confirmed | | #284 | Authoritative lifecycle ingestion and idempotent reconciliation | No active delivery PR confirmed | | #289 | Activate the optional lineage LLM channel through a bounded asynchronous rebuild | #434 | diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 2dee4513d..46b59c381 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,9 @@ describe("App, authenticated", () => { expect( await screen.findByText("These posts are the cutoff corpus this TEPP run measured."), ).toBeInTheDocument(); + expect(screen.getByLabelText("TEPP accepted receipt")).toHaveTextContent( + "TEPP accepted remote run tepp-remote-run-1", + ); expect(screen.queryByText(/replace Failed/i)).not.toBeInTheDocument(); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 76ff51dec..ae54d08ac 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -96,6 +96,7 @@ import { OntologyExplorer } from "./components/OntologyExplorer"; import { AskEvidenceLayerPopup } from "./components/AskEvidenceLayerPopup"; 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"; @@ -3178,6 +3179,11 @@ 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..182239974 --- /dev/null +++ b/frontend/src/components/TeppAcceptedReceipt.stories.tsx @@ -0,0 +1,22 @@ +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, + args: { remoteRunId: "tepp-run-synthetic-001" }, + parameters: { layout: "padded" }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Accepted: Story = { + play: async ({ canvasElement }) => { + const receipt = within(canvasElement).getByLabelText("TEPP accepted receipt"); + await expect(receipt).toHaveTextContent("tepp-run-synthetic-001"); + await expect(receipt).not.toHaveTextContent(/measurement|succeeded/i); + }, +}; diff --git a/frontend/src/components/TeppAcceptedReceipt.tsx b/frontend/src/components/TeppAcceptedReceipt.tsx new file mode 100644 index 000000000..1bc7327f4 --- /dev/null +++ b/frontend/src/components/TeppAcceptedReceipt.tsx @@ -0,0 +1,13 @@ +interface TeppAcceptedReceiptProps { + remoteRunId: string; +} + +/** Provider acceptance evidence; it deliberately makes no measurement claim. */ +export function TeppAcceptedReceipt({ remoteRunId }: TeppAcceptedReceiptProps) { + return ( +

+ TEPP accepted remote run {remoteRunId}. Use this identifier when reconciling provider + evidence. +

+ ); +} diff --git a/lineageweave/tepp_client.py b/lineageweave/tepp_client.py index 7dbfd886f..2ed6a1e46 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,145 @@ 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()) + + 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 _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/0210_analysis_run_tepp_receipt.sql b/migrations/0210_analysis_run_tepp_receipt.sql new file mode 100644 index 000000000..1cbbfaa8f --- /dev/null +++ b/migrations/0210_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/0210_analysis_run_tepp_receipt.sql b/migrations/rollback/0210_analysis_run_tepp_receipt.sql new file mode 100644 index 000000000..143172d68 --- /dev/null +++ b/migrations/rollback/0210_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..71e7a8531 100644 --- a/tests/test_analysis_run_start.py +++ b/tests/test_analysis_run_start.py @@ -10,6 +10,7 @@ from backend.app.analysis_run_ingestion import reconstructed_edge_is_visible from backend.app.analysis_run_start import ( AnalysisRunStartError, + _persist_tepp_receipt, _persist_tepp_result, configured_tepp_client, reconstruction_member_ids, @@ -216,6 +217,121 @@ 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_acceptance_receipt_replay_must_match() -> None: + """A provider replay cannot replace the remote identity or evidence digest.""" + + class _Connection: + def __init__(self) -> None: + self.existing = None + self.inserted: tuple[object, ...] | None = None + + 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_run_request_is_the_published_wire_shape() -> None: """Start builds TEPP's seven-field request from the frozen run.""" request = _tepp_request() @@ -279,6 +395,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 = { diff --git a/tests/test_migration_replay.py b/tests/test_migration_replay.py index c170f73e8..8f98edcfb 100644 --- a/tests/test_migration_replay.py +++ b/tests/test_migration_replay.py @@ -182,3 +182,19 @@ def test_topic_lineage_result_migration_is_idempotent_for_replay() -> None: assert "create table if not exists analysis_run_topic_lineage_result" in migration 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 = "0210_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 diff --git a/tests/test_tepp_client.py b/tests/test_tepp_client.py index 01fc91a94..59c149eec 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: From c7d812138ef64b77dabad59e38eaefd22b669edf Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 03:18:28 +0900 Subject: [PATCH 02/11] fix: persist TEPP terminal status envelopes --- backend/app/analysis_run_start.py | 58 ++++++++++++++++- tests/test_analysis_run_start.py | 101 ++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+), 3 deletions(-) diff --git a/backend/app/analysis_run_start.py b/backend/app/analysis_run_start.py index 41a1d53a3..1b8f6e5e8 100644 --- a/backend/app/analysis_run_start.py +++ b/backend/app/analysis_run_start.py @@ -117,6 +117,7 @@ class _DeliveryOutcome: 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: @@ -297,8 +298,50 @@ def _tepp_status( return _RUNNING, "", response terminal = response["terminal_result"] if response["run_state"] == "failed": - return _FAILED, str(terminal["failure_code"]), terminal - return _SUCCEEDED, "", terminal + 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() + 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 + ) + try: + 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( @@ -1104,6 +1147,7 @@ def _execute_delivery_plan( 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 @@ -1112,6 +1156,7 @@ def _execute_delivery_plan( 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 @@ -1125,6 +1170,7 @@ def _execute_delivery_plan( knowledge_cutoff=plan.locked["knowledge_cutoff"], request=request, persist_receipt=persist_receipt, + persist_terminal_result=persist_terminal_result, ) @@ -1180,7 +1226,13 @@ async def _persist_delivery_outcome( 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/tests/test_analysis_run_start.py b/tests/test_analysis_run_start.py index 71e7a8531..1c974da6e 100644 --- a/tests/test_analysis_run_start.py +++ b/tests/test_analysis_run_start.py @@ -11,6 +11,7 @@ from backend.app.analysis_run_start import ( AnalysisRunStartError, _persist_tepp_receipt, + _persist_tepp_terminal_result, _persist_tepp_result, configured_tepp_client, reconstruction_member_ids, @@ -277,6 +278,106 @@ def test_tepp_delivery_reads_a_stored_remote_run_without_resubmitting() -> None: 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 _Connection: + def __init__(self) -> None: + self.existing = None + self.inserted: tuple[object, ...] | None = None + + 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.""" From 41ff3eadc487e20b66489f49d130089c33bb8a11 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 03:20:39 +0900 Subject: [PATCH 03/11] docs: refresh TEPP lifecycle verification --- docs/product-technical-gap-baseline.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 3519de48c..095769b96 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -9,7 +9,7 @@ > the current-main implementation of issue #277 after TEPP PR #157 published > the terminal DTO. TEPP issue #249 owns the still-missing executable HTTP > status service. No protected merge or live measurement is claimed here. -> Focused backend lifecycle/authorization/schema verification is 76 passed; +> Focused backend lifecycle/authorization/schema verification is 78 passed; > frontend verification is 382 passed plus lint and production build. The > broader backend run reached 1,195 passed and 17 skipped before 127 existing > live-PostgreSQL fixture errors (duplicate seeded snapshot digest) prevented a From 3f4fb53c504c662eaebb68885b1aeb029cecf06e Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 03:22:34 +0900 Subject: [PATCH 04/11] docs: refresh protected PR queue snapshot --- docs/product-technical-gap-baseline.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 095769b96..24fc4d435 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,11 +1,10 @@ # Product & Technical Gap Baseline > TEPP lifecycle continuation snapshot: 2026-08-26. Protected `main` is -> `04e6b610655d0db91d5f7ba9486bdda1440e0b19`; the nine open main-target PRs -> are #644, #643, #640, #639, #636, #632, #631, #629, and #579. Eight were -> auto-merge armed; updated #632 (`679df1d7`) had fresh checks queued and no -> auto-merge request at the exact-head refresh. None had the required -> independent exact-head approval. ADR 0213 now carries +> `04e6b610655d0db91d5f7ba9486bdda1440e0b19`; the eleven open main-target PRs +> are #658, #657, #644, #643, #640, #639, #636, #632, #631, #629, and #579. +> All were auto-merge armed after fresh review-thread repair; none had the +> required independent exact-head approval. ADR 0213 now carries > the current-main implementation of issue #277 after TEPP PR #157 published > the terminal DTO. TEPP issue #249 owns the still-missing executable HTTP > status service. No protected merge or live measurement is claimed here. From 4977372883dc59db529750dd852b9e81523cc2bf Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 03:24:16 +0900 Subject: [PATCH 05/11] fix: bound TEPP acceptance evidence --- backend/app/analysis_run_start.py | 2 ++ docs/product-technical-gap-baseline.md | 2 +- lineageweave/tepp_client.py | 34 +++++++++++++++++++++++++- tests/test_tepp_client.py | 14 +++++++++++ 4 files changed, 50 insertions(+), 2 deletions(-) diff --git a/backend/app/analysis_run_start.py b/backend/app/analysis_run_start.py index 1b8f6e5e8..8aa1d9329 100644 --- a/backend/app/analysis_run_start.py +++ b/backend/app/analysis_run_start.py @@ -258,6 +258,8 @@ 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 state = response.get("status") or response.get("run_state") diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 24fc4d435..3ff9a12ae 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -8,7 +8,7 @@ > the current-main implementation of issue #277 after TEPP PR #157 published > the terminal DTO. TEPP issue #249 owns the still-missing executable HTTP > status service. No protected merge or live measurement is claimed here. -> Focused backend lifecycle/authorization/schema verification is 78 passed; +> Focused backend lifecycle/authorization/schema verification is 79 passed; > frontend verification is 382 passed plus lint and production build. The > broader backend run reached 1,195 passed and 17 skipped before 127 existing > live-PostgreSQL fixture errors (duplicate seeded snapshot digest) prevented a diff --git a/lineageweave/tepp_client.py b/lineageweave/tepp_client.py index 2ed6a1e46..0dbc51c33 100644 --- a/lineageweave/tepp_client.py +++ b/lineageweave/tepp_client.py @@ -99,7 +99,14 @@ def __init__( 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 @@ -118,6 +125,31 @@ def read_analysis_run_status( ) +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 ( diff --git a/tests/test_tepp_client.py b/tests/test_tepp_client.py index 59c149eec..9824a684f 100644 --- a/tests/test_tepp_client.py +++ b/tests/test_tepp_client.py @@ -119,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 = {} From 12337aceac9762504a63dfe687ced47fad13ce52 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 03:24:37 +0900 Subject: [PATCH 06/11] docs: reserve distinct TEPP lifecycle ADR id --- ...lt-lifecycle.md => 0219-tepp-terminal-result-lifecycle.md} | 2 +- docs/product-technical-gap-baseline.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) rename docs/adr/{0213-tepp-terminal-result-lifecycle.md => 0219-tepp-terminal-result-lifecycle.md} (98%) diff --git a/docs/adr/0213-tepp-terminal-result-lifecycle.md b/docs/adr/0219-tepp-terminal-result-lifecycle.md similarity index 98% rename from docs/adr/0213-tepp-terminal-result-lifecycle.md rename to docs/adr/0219-tepp-terminal-result-lifecycle.md index 8e3875608..f917faf49 100644 --- a/docs/adr/0213-tepp-terminal-result-lifecycle.md +++ b/docs/adr/0219-tepp-terminal-result-lifecycle.md @@ -1,4 +1,4 @@ -# ADR 0213 — Persist TEPP acceptance and consume terminal results +# 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 diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 3ff9a12ae..cc0ec977f 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -4,7 +4,7 @@ > `04e6b610655d0db91d5f7ba9486bdda1440e0b19`; the eleven open main-target PRs > are #658, #657, #644, #643, #640, #639, #636, #632, #631, #629, and #579. > All were auto-merge armed after fresh review-thread repair; none had the -> required independent exact-head approval. ADR 0213 now carries +> required independent exact-head approval. ADR 0219 now carries > the current-main implementation of issue #277 after TEPP PR #157 published > the terminal DTO. TEPP issue #249 owns the still-missing executable HTTP > status service. No protected merge or live measurement is claimed here. @@ -360,7 +360,7 @@ this file per §3.5 of the prior snapshot). | #271 | Evidence-honest knowledge-cutoff scope on Global Ask | Ask stack | | #272 | Verify Global Ask KG/ontology/semantic claims with public SearXNG evidence | Ask stack | | #274 | Persist and explain Event Lineage channel evidence | #387 | -| #277 | TEPP: persist accepted receipts, read completed results, keep measurement authority distinct | ADR 0213 current-main continuation; provider HTTP route tracked by TEPP #249 | +| #277 | TEPP: persist accepted receipts, read completed results, keep measurement authority distinct | ADR 0219 current-main continuation; provider HTTP route tracked by TEPP #249 | | #280 | Full project-lifecycle history and handover intervals | Tracked with issue #284; no active delivery PR confirmed | | #284 | Authoritative lifecycle ingestion and idempotent reconciliation | No active delivery PR confirmed | | #289 | Activate the optional lineage LLM channel through a bounded asynchronous rebuild | #434 | From f4f7f1852bfcb5a87b4246bd2bdfe9f9972e5113 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 03:28:21 +0900 Subject: [PATCH 07/11] fix(tepp): isolate receipt persistence conflicts --- backend/app/analysis_run_start.py | 107 +++++++++--------- .../0219-tepp-terminal-result-lifecycle.md | 4 +- docs/product-technical-gap-baseline.md | 4 + tests/test_analysis_run_start.py | 58 ++++++++++ 4 files changed, 120 insertions(+), 53 deletions(-) diff --git a/backend/app/analysis_run_start.py b/backend/app/analysis_run_start.py index 8aa1d9329..dd1564303 100644 --- a/backend/app/analysis_run_start.py +++ b/backend/app/analysis_run_start.py @@ -316,31 +316,32 @@ async def _persist_tepp_terminal_result( return False result_json = json.dumps(envelope, separators=(",", ":"), sort_keys=True) result_sha256 = hashlib.sha256(result_json.encode("utf-8")).hexdigest() - 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 - ) try: - 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, - ) + 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 @@ -362,33 +363,37 @@ async def _persist_tepp_receipt( 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() - 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, - ) + 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 diff --git a/docs/adr/0219-tepp-terminal-result-lifecycle.md b/docs/adr/0219-tepp-terminal-result-lifecycle.md index f917faf49..e100ab858 100644 --- a/docs/adr/0219-tepp-terminal-result-lifecycle.md +++ b/docs/adr/0219-tepp-terminal-result-lifecycle.md @@ -7,8 +7,8 @@ ## Context -TEPP's `AnalysisRunAccepted` is transport evidence, not measurement. TEPP PR -#157 merged strict `AnalysisRunStatus` and `AnalysisRunTerminalResult` v1 Rust +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. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index cc0ec977f..e6911258b 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,5 +1,7 @@ # Product & Technical Gap Baseline +## Current TEPP lifecycle continuation snapshot + > TEPP lifecycle continuation snapshot: 2026-08-26. Protected `main` is > `04e6b610655d0db91d5f7ba9486bdda1440e0b19`; the eleven open main-target PRs > are #658, #657, #644, #643, #640, #639, #636, #632, #631, #629, and #579. @@ -14,6 +16,8 @@ > live-PostgreSQL fixture errors (duplicate seeded snapshot digest) prevented a > green full-suite claim. +## Historical dashboard delivery snapshot + > Dashboard delivery snapshot: 2026-08-25 21:34 KST. Protected `main` was > `d7d5eeb310b055b5e138060cf2dfb929b03090a6`. This local branch is not > protected-main release evidence. diff --git a/tests/test_analysis_run_start.py b/tests/test_analysis_run_start.py index 1c974da6e..d93e4eaa1 100644 --- a/tests/test_analysis_run_start.py +++ b/tests/test_analysis_run_start.py @@ -331,11 +331,21 @@ def test_tepp_delivery_keeps_the_full_terminal_status_for_persistence() -> None: 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 @@ -381,11 +391,21 @@ async def execute(self, _query: str, *args: object): 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 @@ -433,6 +453,44 @@ async def execute(self, _query: str, *args: object): ) +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() From 91f41410ed7f9e3894eb96f2b6e605cc33466e8b Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 03:44:19 +0900 Subject: [PATCH 08/11] fix(tepp): fail closed on missing receipt schema --- backend/app/analysis_run_ingestion.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py index f91532c2c..aa04aa23d 100644 --- a/backend/app/analysis_run_ingestion.py +++ b/backend/app/analysis_run_ingestion.py @@ -423,17 +423,14 @@ async def fetch_visible_analysis_run( ) detail["topic_lineage_result_sha256"] = topic_result["result_sha256"] if row["run_kind_code"] == _TEPP_RUN_KIND: - try: - 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, - ) - except asyncpg.UndefinedTableError: - receipt = None + 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"]), From f8eed06e1fa8ed5a47e004233b13354b08d88987 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 03:48:44 +0900 Subject: [PATCH 09/11] fix(tepp): reserve nonconflicting receipt migration --- backend/app/analysis_run_ingestion.py | 19 ++++++++----------- ...sql => 0217_analysis_run_tepp_receipt.sql} | 0 ...sql => 0217_analysis_run_tepp_receipt.sql} | 0 tests/test_migration_replay.py | 17 ++++++++++++++++- 4 files changed, 24 insertions(+), 12 deletions(-) rename migrations/{0210_analysis_run_tepp_receipt.sql => 0217_analysis_run_tepp_receipt.sql} (100%) rename migrations/rollback/{0210_analysis_run_tepp_receipt.sql => 0217_analysis_run_tepp_receipt.sql} (100%) diff --git a/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py index f91532c2c..aa04aa23d 100644 --- a/backend/app/analysis_run_ingestion.py +++ b/backend/app/analysis_run_ingestion.py @@ -423,17 +423,14 @@ async def fetch_visible_analysis_run( ) detail["topic_lineage_result_sha256"] = topic_result["result_sha256"] if row["run_kind_code"] == _TEPP_RUN_KIND: - try: - 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, - ) - except asyncpg.UndefinedTableError: - receipt = None + 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"]), diff --git a/migrations/0210_analysis_run_tepp_receipt.sql b/migrations/0217_analysis_run_tepp_receipt.sql similarity index 100% rename from migrations/0210_analysis_run_tepp_receipt.sql rename to migrations/0217_analysis_run_tepp_receipt.sql diff --git a/migrations/rollback/0210_analysis_run_tepp_receipt.sql b/migrations/rollback/0217_analysis_run_tepp_receipt.sql similarity index 100% rename from migrations/rollback/0210_analysis_run_tepp_receipt.sql rename to migrations/rollback/0217_analysis_run_tepp_receipt.sql diff --git a/tests/test_migration_replay.py b/tests/test_migration_replay.py index 8f98edcfb..f2c3c1261 100644 --- a/tests/test_migration_replay.py +++ b/tests/test_migration_replay.py @@ -186,7 +186,7 @@ def test_topic_lineage_result_migration_is_idempotent_for_replay() -> None: def test_tepp_receipt_migration_is_replayable_and_digest_bound() -> None: """Accepted transport evidence survives every-start migration replay.""" - migration_name = "0210_analysis_run_tepp_receipt.sql" + migration_name = "0217_analysis_run_tepp_receipt.sql" sql = ( Path(__file__).resolve().parents[1] / "migrations" / migration_name ).read_text(encoding="utf-8").casefold() @@ -198,3 +198,18 @@ def test_tepp_receipt_migration_is_replayable_and_digest_bound() -> None: 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 From a59a2023c9584e839d1720eff6634ee515b6cf14 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 07:20:51 +0900 Subject: [PATCH 10/11] fix(tepp): fail closed on untracked topic acceptance --- backend/app/analysis_run_start.py | 2 ++ tests/test_analysis_run_start.py | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/backend/app/analysis_run_start.py b/backend/app/analysis_run_start.py index dd1564303..44da4a69e 100644 --- a/backend/app/analysis_run_start.py +++ b/backend/app/analysis_run_start.py @@ -444,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) ): diff --git a/tests/test_analysis_run_start.py b/tests/test_analysis_run_start.py index d93e4eaa1..a3e317470 100644 --- a/tests/test_analysis_run_start.py +++ b/tests/test_analysis_run_start.py @@ -671,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. From 355a5796620c43f2b2a80fa6102ad73a3ff5cbfb Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 27 Aug 2026 06:23:27 +0900 Subject: [PATCH 11/11] fix(ui): keep measurement receipt customer-facing --- frontend/src/App.test.tsx | 5 +++-- frontend/src/App.tsx | 4 +--- .../src/components/TeppAcceptedReceipt.stories.tsx | 7 +++---- frontend/src/components/TeppAcceptedReceipt.tsx | 11 +++-------- 4 files changed, 10 insertions(+), 17 deletions(-) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 46b59c381..d5d5f6792 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -3811,9 +3811,10 @@ describe("App, authenticated", () => { expect( await screen.findByText("These posts are the cutoff corpus this TEPP run measured."), ).toBeInTheDocument(); - expect(screen.getByLabelText("TEPP accepted receipt")).toHaveTextContent( - "TEPP accepted remote run tepp-remote-run-1", + 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 c6d2deac6..ac86e6b59 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3181,9 +3181,7 @@ function AnalysisRunsPanel({ Requested {selected.requested_at.slice(0, 10)}

{selected.tepp_accepted_receipt && ( - + )} ; @@ -15,8 +14,8 @@ type Story = StoryObj; export const Accepted: Story = { play: async ({ canvasElement }) => { - const receipt = within(canvasElement).getByLabelText("TEPP accepted receipt"); - await expect(receipt).toHaveTextContent("tepp-run-synthetic-001"); - await expect(receipt).not.toHaveTextContent(/measurement|succeeded/i); + 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 index 1bc7327f4..dcd430a86 100644 --- a/frontend/src/components/TeppAcceptedReceipt.tsx +++ b/frontend/src/components/TeppAcceptedReceipt.tsx @@ -1,13 +1,8 @@ -interface TeppAcceptedReceiptProps { - remoteRunId: string; -} - /** Provider acceptance evidence; it deliberately makes no measurement claim. */ -export function TeppAcceptedReceipt({ remoteRunId }: TeppAcceptedReceiptProps) { +export function TeppAcceptedReceipt() { return ( -

- TEPP accepted remote run {remoteRunId}. Use this identifier when reconciling provider - evidence. +

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

); }