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.
);
}