diff --git a/CHANGELOG.d/2.20.0-tepp-status-read-client.md b/CHANGELOG.d/2.20.0-tepp-status-read-client.md new file mode 100644 index 000000000..77714bc94 --- /dev/null +++ b/CHANGELOG.d/2.20.0-tepp-status-read-client.md @@ -0,0 +1,5 @@ +# Added + +- Add a fail-closed TEPP analysis-run status/read client boundary so durable + lifecycle work can poll the upstream Rust-owned contract without treating an + accepted receipt as measurement. diff --git a/backend/app/analysis_run_start.py b/backend/app/analysis_run_start.py index c08810078..4b6476081 100644 --- a/backend/app/analysis_run_start.py +++ b/backend/app/analysis_run_start.py @@ -38,7 +38,11 @@ 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, + TeppNotAvailable, +) _LINEAGE_KIND = "analysis_run_lineage" _TEPP_KIND = "analysis_run_tepp" diff --git a/docs/adr/0217-tepp-terminal-status-read-boundary.md b/docs/adr/0217-tepp-terminal-status-read-boundary.md new file mode 100644 index 000000000..69dd70af2 --- /dev/null +++ b/docs/adr/0217-tepp-terminal-status-read-boundary.md @@ -0,0 +1,34 @@ +# ADR 0217 — Read TEPP status without treating transport as measurement + +**Decision status:** Accepted on this branch; not protected-main truth until merge +**Date:** 2026-08-26 +**Depends on:** ADR 0022; issue #277; ContextualWisdomLab/TEPP PR #157 + +## Context + +TEPP now publishes a versioned status/read contract for accepted, running, +succeeded, and failed analysis runs. LineageWeave can submit a request but its +client has no read operation, so issue #277 cannot poll a remote run without +bypassing the existing provider boundary. + +## Decision + +TeppClient.get_analysis_run_status reads one opaque remote run identity +through a separately injected status transport. TEPP PR #157 publishes wire +types but no executable HTTP status route. The configured HTTP client therefore +keeps status reads unavailable instead of deriving an item URL from the submit +collection URL. A later owning-repository route contract may inject a transport +without changing or locally interpreting the opaque identity. + +The method returns the unmodified status envelope. This slice does not poll, +persist, validate a terminal digest, append Succeeded, or interpret any +accepted/running envelope as measurement. Those lifecycle operations remain +issue #277 work and must bind the terminal result to the persisted request and +accepted receipt in one transaction. + +## Consequences + +The later durable worker can reuse the same client instead of introducing a +second client abstraction. Missing transport and malformed identity cannot +manufacture a result; route construction remains unavailable until TEPP owns +and publishes it. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 9a65c2eb6..e555bec40 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -346,7 +346,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, poll completed results, keep measurement authority distinct; TEPP PR #157 is merged and this exact head adds the fail-closed status/read client boundary, while durable polling and terminal persistence remain open | #468, #417, ADR 0217 | | #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/lineageweave/tepp_client.py b/lineageweave/tepp_client.py index 7dbfd886f..7f18ff09a 100644 --- a/lineageweave/tepp_client.py +++ b/lineageweave/tepp_client.py @@ -8,12 +8,9 @@ lineage scores as TEPP's calibrated psychometric measurement (they answer different questions -- see docs/lineage-bi-research-notes.md). -TEPP does not expose a live HTTP endpoint yet (as of this writing it is -Rust-crate-only; see ``docs/API_CONTRACT.md`` in that repo). This client -builds and validates the exact wire shape TEPP has published -(``schemas/analysis_run_request_v1.json``) so wiring in a real transport is -a one-line change (:meth:`TeppClient.__init__`'s ``transport`` argument) once -that endpoint exists, instead of a redesign. +TEPP publishes versioned submit and status/read contracts. This client keeps +those transports separate so an accepted receipt cannot be mistaken for a +terminal measurement result. """ from __future__ import annotations @@ -21,6 +18,8 @@ from dataclasses import dataclass from typing import Any, Callable +ANALYSIS_RUN_CONTRACT_VERSION = 1 + class TeppNotAvailable(RuntimeError): """Raised by the default transport: TEPP has no live REST API yet.""" @@ -35,6 +34,11 @@ def _no_transport(request: dict[str, Any]) -> dict[str, Any]: ) +def _no_status_transport(run_id: str) -> dict[str, Any]: + """Fail closed when no TEPP status/read transport is configured.""" + raise TeppNotAvailable("TEPP status transport unavailable") + + @dataclass(frozen=True) class AnalysisRunRequest: """Mirrors TEPP's ``schemas/analysis_run_request_v1.json`` exactly. @@ -49,7 +53,7 @@ class AnalysisRunRequest: knowledge_cutoff: str model_contract_version: str output_profile: str - contract_version: int = 1 + contract_version: int = ANALYSIS_RUN_CONTRACT_VERSION def to_json(self) -> dict[str, Any]: """Serialize the accepted TEPP result into its wire representation.""" @@ -65,19 +69,22 @@ def to_json(self) -> dict[str, Any]: class TeppClient: - """Submits :class:`AnalysisRunRequest` through a pluggable transport. - - The default transport always raises :class:`TeppNotAvailable` -- this - class exists so the rest of LineageWeave can be written against a - stable interface today, and gains a real TEPP integration by supplying - a ``transport`` (an HTTP POST to TEPP's future ``/v1/analysis-runs``, or - an in-process call into the ``tepp_api`` Rust crate via FFI) without - touching any other module. - """ + """Submit and read TEPP analysis runs through separate transports.""" - 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 get_analysis_run_status(self, run_id: str) -> dict[str, Any]: + """Read TEPP's status envelope for one opaque remote run id.""" + if not isinstance(run_id, str) or not run_id.strip(): + raise ValueError("run_id must be a non-empty string") + return self._status_transport(run_id) diff --git a/tests/test_tepp_client.py b/tests/test_tepp_client.py index 01fc91a94..57c611302 100644 --- a/tests/test_tepp_client.py +++ b/tests/test_tepp_client.py @@ -35,6 +35,8 @@ 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, match="status transport unavailable"): + client.get_analysis_run_status("remote-run-1") def test_custom_transport_receives_the_exact_wire_payload() -> None: @@ -86,6 +88,27 @@ def fake_post_json( assert received["service_peer_name"] == "tepp" +def test_injected_status_transport_receives_opaque_remote_run_id_unchanged() -> None: + received: list[str] = [] + + def fake_status_transport(run_id: str) -> dict: + received.append(run_id) + return {"contract_version": 1, "run_state": "running"} + + client = TeppClient(status_transport=fake_status_transport) + + status = client.get_analysis_run_status(" remote/run 1 ") + + assert status["run_state"] == "running" + assert received == [" remote/run 1 "] + + +@pytest.mark.parametrize("run_id", ["", " ", None]) +def test_status_read_rejects_missing_remote_run_identity(run_id) -> None: + with pytest.raises(ValueError, match="run_id must be a non-empty string"): + TeppClient().get_analysis_run_status(run_id) + + def test_configured_transport_hides_raw_provider_exception_chain( monkeypatch: pytest.MonkeyPatch, ) -> None: