From 5c0de352a4186d0ae8f22af476a6e87b3584a3f3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 14:24:19 +0000 Subject: [PATCH 01/16] feat: persist TEPP accepted receipts as transport evidence (v2.12.11) A live AnalysisRunAccepted envelope with a remote run id stays Running and is stored as transport evidence. It is not a measurement and does not invent a theta. Empty accepted envelopes and missing transport stay Failed. Login no longer mounts Admin settings with an undefined token. Refs: #277 --- ARCHITECTURE.md | 6 +- CHANGELOG.d/2.12.11-tepp-accepted-receipt.md | 8 + CHANGELOG.md | 18 ++ backend/app/analysis_run_ingestion.py | 32 +++ backend/app/analysis_run_start.py | 253 +++++++++++++++--- backend/tests/test_api.py | 6 + docker/postgres-init/migrate.sh | 2 +- docs/adr/0022-authorized-tepp-start.md | 2 + docs/adr/0157-tepp-accepted-receipt.md | 104 +++++++ frontend/package.json | 2 +- frontend/src/App.test.tsx | 156 +++++++---- frontend/src/App.tsx | 8 +- frontend/src/api.ts | 8 + ...106_analysis_run_tepp_accepted_receipt.sql | 19 ++ ...106_analysis_run_tepp_accepted_receipt.sql | 25 ++ pyproject.toml | 2 +- tests/test_analysis_run_start.py | 23 +- tests/test_migration_replay.py | 1 + tests/test_tepp_accepted_receipt.py | 171 ++++++++++++ uv.lock | 2 +- 20 files changed, 753 insertions(+), 95 deletions(-) create mode 100644 CHANGELOG.d/2.12.11-tepp-accepted-receipt.md create mode 100644 docs/adr/0157-tepp-accepted-receipt.md create mode 100644 migrations/0106_analysis_run_tepp_accepted_receipt.sql create mode 100644 migrations/rollback/0106_analysis_run_tepp_accepted_receipt.sql create mode 100644 tests/test_tepp_accepted_receipt.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d0280ff97..3bd895e39 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -487,11 +487,13 @@ more than one), then open the Pending row to confirm the cutoff corpus. `POST /api/analysis-runs/{id}/start` then commits Running plus a durable outbox row, wakes Valkey, and delivers ThreadWeave on that frozen bag (ADR 0021 / ADR 0023) or submits TEPP through -`tepp_client` (ADR 0022). It does not invent a TEPP score. +`tepp_client` (ADR 0022 / ADR 0157). It does not invent a TEPP score. Request a lineage reconstruction from the home list, open the Pending row, then start reconstruction. A Pending TEPP row starts a measurement; a missing transport stays Failed / -`tepp_not_available`. Hover the Result digest +`tepp_not_available`. A live `accepted` envelope with a remote run id +persists as transport evidence and keeps the run Running — that +receipt is not a calibrated result. Hover the Result digest prefix, then confirm the designed A-100 fork before treating the live Event Lineage panel as that run's tree. `make seed` also records a TEPP measurement run through diff --git a/CHANGELOG.d/2.12.11-tepp-accepted-receipt.md b/CHANGELOG.d/2.12.11-tepp-accepted-receipt.md new file mode 100644 index 000000000..f722398ae --- /dev/null +++ b/CHANGELOG.d/2.12.11-tepp-accepted-receipt.md @@ -0,0 +1,8 @@ +# 2.12.11 TEPP accepted receipt + +A live TEPP `accepted` / `queued` / `running` envelope with a remote +run id persists as transport evidence and leaves the local analysis +run Running. The receipt is not a measurement and does not invent a +theta. Empty accepted envelopes and missing transport stay Failed. +Completed envelopes still persist `analysis_run_tepp_result`. Login +no longer mounts Admin settings with an undefined token. diff --git a/CHANGELOG.md b/CHANGELOG.md index c8ed1a099..a4742f01e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,24 @@ All notable changes to this project are documented here. Format follows environment, so local OIDC and synthetic-data workflows resolve the same pinned dependencies as CI. +## [2.12.11] - 2026-08-23 + +### Added + +- A live TEPP `accepted` / `queued` / `running` envelope that carries a + remote run id is stored as transport evidence + (`analysis_run_tepp_accepted_receipt`). The local analysis run stays + Running. The receipt is not a calibrated measurement and does not + invent a theta. Empty accepted envelopes and a missing transport stay + Failed. Completed envelopes still persist `analysis_run_tepp_result`. + Completed-result polling remains blocked on TEPP#156. + +### Fixed + +- Unauthenticated login no longer mounts Admin settings with an + undefined access token, and login persists a validated OIDC return + URL before the redirect. + ## [2.12.6] - 2026-08-20 ### Added diff --git a/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py index f7da2969b..8d657115d 100644 --- a/backend/app/analysis_run_ingestion.py +++ b/backend/app/analysis_run_ingestion.py @@ -403,6 +403,9 @@ async def fetch_visible_analysis_run( if digest is not None: detail["reconstruction_result_sha256"] = digest detail["reconstructed_edges"] = edges + receipt = await fetch_tepp_accepted_receipt(conn, analysis_run_id) + if receipt is not None: + detail["tepp_accepted_receipt"] = receipt return detail @@ -427,6 +430,35 @@ def reconstructed_edge_is_visible( return parent_visible and child_visible +async def fetch_tepp_accepted_receipt( + conn: asyncpg.Connection, + analysis_run_id: str, +) -> dict[str, Any] | None: + """Transport receipt for one already-visible run, or None. + + Missing table (migration 0106 not applied) is not a 500. The + receipt is not a measurement and never includes a theta. + """ + try: + row = await conn.fetchrow( + """ + select remote_run_id, accepted_status_code, received_at + from analysis_run_tepp_accepted_receipt + where analysis_run_id = $1 + """, + analysis_run_id, + ) + except asyncpg.UndefinedTableError: + return None + if row is None: + return None + return { + "remote_run_id": str(row["remote_run_id"]), + "accepted_status_code": str(row["accepted_status_code"]), + "received_at": _iso(row["received_at"]), + } + + async def fetch_reconstructed_edges( conn: asyncpg.Connection, analysis_run_id: str, diff --git a/backend/app/analysis_run_start.py b/backend/app/analysis_run_start.py index 2387d940b..7fd791337 100644 --- a/backend/app/analysis_run_start.py +++ b/backend/app/analysis_run_start.py @@ -2,15 +2,17 @@ ADR 0021 reconstructs lineage. ADR 0022 starts TEPP through ``tepp_client`` only. ADR 0023 enqueues that work on a durable outbox -so a crash after Running does not lose the item. Period-report stays -another path. Neither start invents a theta or a calibrated report -score. +so a crash after Running does not lose the item. ADR 0157 persists a +TEPP accepted envelope as transport evidence and keeps the local run +Running. Period-report stays another path. Neither start invents a +theta or a calibrated report score. """ from __future__ import annotations import hashlib import json +from dataclasses import dataclass from datetime import datetime, timezone from typing import Any from uuid import UUID @@ -43,12 +45,31 @@ _FAILED = "analysis_status_failed" _TEPP_MODEL_CONTRACT = "tepp-analysis-run-v1" _TEPP_OUTPUT_PROFILE = "calibrated_event_measurement" +_TEPP_TRANSPORT_STATES = frozenset({"accepted", "queued", "running"}) +_TEPP_COMPLETED_STATES = frozenset({"completed", "succeeded"}) +_PERSIST_RESULT = "result" +_PERSIST_RECEIPT = "receipt" class AnalysisRunStartError(AnalysisRunCreateError): """Fail-closed start: HTTP status plus a next-action detail string.""" +@dataclass(frozen=True) +class TeppSubmissionOutcome: + """Classified TEPP envelope: local status, optional persist kind. + + ``persist_kind`` is ``result`` (completed measurement), ``receipt`` + (accepted transport evidence), or empty when nothing may be stored. + A receipt is never a measurement and never invents a theta. + """ + + status_code: str + failure_code: str + envelope: dict[str, Any] | None + persist_kind: str + + def reconstruction_result_digest(edges: list[Edge]) -> str: """SHA-256 of the ordered parent choices. Never hashes a post body.""" material = json.dumps( @@ -130,30 +151,96 @@ def tepp_run_request( ) -def _tepp_submission( +def tepp_envelope_status(envelope: dict[str, Any]) -> str: + """Normalize TEPP ``status`` or ``run_state``. Empty when neither is a string.""" + raw = envelope.get("status") + if not isinstance(raw, str) or not raw.strip(): + raw = envelope.get("run_state") + if isinstance(raw, str): + return raw.strip().casefold() + return "" + + +def tepp_remote_run_id(envelope: dict[str, Any]) -> str: + """Remote run identity from TEPP's published envelope keys.""" + for key in ("analysis_run_id", "run_id", "remote_run_id"): + value = envelope.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return "" + + +def tepp_accepted_status_code(envelope: dict[str, Any]) -> str: + """Transport state when the envelope is accepted/queued/running.""" + status = tepp_envelope_status(envelope) + return status if status in _TEPP_TRANSPORT_STATES else "" + + +def tepp_request_digest(request: AnalysisRunRequest) -> str: + """SHA-256 of the published seven-field request. Never hashes a theta.""" + material = json.dumps(request.to_json(), separators=(",", ":"), sort_keys=True) + return hashlib.sha256(material.encode("utf-8")).hexdigest() + + +def tepp_receipt_digest( + *, + remote_run_id: str, + accepted_status_code: str, + model_contract_version: str, + snapshot_id: str, + knowledge_cutoff: str, +) -> str: + """SHA-256 of persisted receipt columns. Never hashes a result body.""" + material = json.dumps( + { + "accepted_status_code": accepted_status_code, + "knowledge_cutoff": knowledge_cutoff, + "model_contract_version": model_contract_version, + "remote_run_id": remote_run_id, + "snapshot_id": snapshot_id, + }, + separators=(",", ":"), + sort_keys=True, + ) + return hashlib.sha256(material.encode("utf-8")).hexdigest() + + +def classify_tepp_submission( client: TeppClient, request: AnalysisRunRequest, -) -> tuple[str, str, dict[str, Any] | None]: - """Submit through ``tepp_client`` and require a completed result envelope. +) -> TeppSubmissionOutcome: + """Classify a TEPP envelope without inventing a measurement. - TEPP's target HTTP contract is asynchronous. An ``accepted`` response is - therefore not a measurement and remains ``tepp_result_not_persisted``. - Only a provider-authoritative completed envelope can enter the database. + Accepted/queued/running with a remote run id is transport evidence. + Only a completed envelope with a result dict may persist a + measurement. An empty accepted envelope stays unpersistable. """ try: response = client.submit_analysis_run(request) except TeppNotAvailable: - return _FAILED, "tepp_not_available", None + return TeppSubmissionOutcome(_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"}: - 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 + return TeppSubmissionOutcome(_FAILED, "tepp_result_not_persisted", None, "") + status = tepp_envelope_status(response) + remote_run_id = tepp_remote_run_id(response) + if status in _TEPP_COMPLETED_STATES: + if isinstance(response.get("result"), dict) and remote_run_id: + return TeppSubmissionOutcome(_SUCCEEDED, "", response, _PERSIST_RESULT) + return TeppSubmissionOutcome(_FAILED, "tepp_result_not_persisted", None, "") + if status in _TEPP_TRANSPORT_STATES: + if remote_run_id: + return TeppSubmissionOutcome(_RUNNING, "", response, _PERSIST_RECEIPT) + return TeppSubmissionOutcome(_FAILED, "tepp_result_not_persisted", None, "") + return TeppSubmissionOutcome(_FAILED, "tepp_result_not_persisted", None, "") + + +def _tepp_submission( + client: TeppClient, + request: AnalysisRunRequest, +) -> tuple[str, str, dict[str, Any] | None]: + """Compatibility projection used by older start tests.""" + outcome = classify_tepp_submission(client, request) + return outcome.status_code, outcome.failure_code, outcome.envelope def tepp_submit_outcome( @@ -161,8 +248,8 @@ def tepp_submit_outcome( request: AnalysisRunRequest, ) -> tuple[str, str]: """Compatibility projection of the TEPP submission outcome.""" - status_code, failure_code, _ = _tepp_submission(client, request) - return status_code, failure_code + outcome = classify_tepp_submission(client, request) + return outcome.status_code, outcome.failure_code async def _persist_tepp_result( @@ -196,6 +283,69 @@ async def _persist_tepp_result( return True +async def _persist_tepp_accepted_receipt( + conn: asyncpg.Connection, + *, + analysis_run_id: str, + envelope: dict[str, Any], + request: AnalysisRunRequest, + knowledge_cutoff: datetime, +) -> bool: + """Persist transport evidence. Never writes a result or a theta.""" + remote_run_id = tepp_remote_run_id(envelope) + accepted_status_code = tepp_accepted_status_code(envelope) + if not remote_run_id or not accepted_status_code: + return False + cutoff = knowledge_cutoff + if cutoff.tzinfo is None: + cutoff = cutoff.replace(tzinfo=timezone.utc) + cutoff_iso = cutoff.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + request_sha256 = tepp_request_digest(request) + receipt_sha256 = tepp_receipt_digest( + remote_run_id=remote_run_id, + accepted_status_code=accepted_status_code, + model_contract_version=request.model_contract_version, + snapshot_id=request.snapshot_id, + knowledge_cutoff=cutoff_iso, + ) + try: + existing = await conn.fetchrow( + """ + select remote_run_id, receipt_sha256 + from analysis_run_tepp_accepted_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["receipt_sha256"]) == receipt_sha256 + ) + await conn.execute( + """ + insert into analysis_run_tepp_accepted_receipt + (analysis_run_id, remote_run_id, request_sha256, receipt_sha256, + accepted_status_code, model_contract_version, snapshot_id, + knowledge_cutoff) + values ($1, $2, $3, $4, $5, $6, $7, $8) + """, + analysis_run_id, + remote_run_id, + request_sha256, + receipt_sha256, + accepted_status_code, + request.model_contract_version, + request.snapshot_id, + cutoff, + ) + except asyncpg.UndefinedTableError: + return False + except (asyncpg.PostgresError, TypeError, ValueError): + return False + return True + + def start_write_conflict_error() -> AnalysisRunStartError: """Next action when a concurrent start already wrote this run.""" return AnalysisRunStartError( @@ -565,7 +715,8 @@ async def deliver_queued_analysis_run( A delivered row replays the stored result. Missing work is 409. TEPP stays Failed when the transport is missing or the envelope is - not persistable. No theta is invented. + not persistable. An accepted receipt keeps the run Running and + leaves the outbox claimed. No theta is invented. """ try: UUID(analysis_run_id) @@ -622,7 +773,7 @@ async def deliver_queued_analysis_run( valkey_stream_entry_id, ) if outbox["work_kind_code"] == _TEPP_KIND: - await _deliver_tepp_measurement( + terminal = await _deliver_tepp_measurement( conn, analysis_run_id=analysis_run_id, locked=outbox, @@ -636,17 +787,19 @@ async def deliver_queued_analysis_run( affiliated_entity_ids=affiliated_entity_ids, adjudication_client=adjudication_client, ) - finished = datetime.now(timezone.utc) - if finished < now: - finished = now - await _append_outbox_delivery( - conn, - analysis_run_id, - await _next_outbox_delivery_ordinal(conn, analysis_run_id), - "analysis_outbox_delivered", - finished, - valkey_stream_entry_id, - ) + terminal = True + if terminal: + finished = datetime.now(timezone.utc) + if finished < now: + finished = now + await _append_outbox_delivery( + conn, + analysis_run_id, + await _next_outbox_delivery_ordinal(conn, analysis_run_id), + "analysis_outbox_delivered", + finished, + valkey_stream_entry_id, + ) except asyncpg.UniqueViolationError as exc: raise start_write_conflict_error() from exc return await _visible_or_404( @@ -757,8 +910,14 @@ async def _deliver_tepp_measurement( analysis_run_id: str, locked: asyncpg.Record, tepp_client: TeppClient, -) -> None: - """Submit the frozen snapshot through ``tepp_client``. Never persist a theta.""" +) -> bool: + """Submit the frozen snapshot through ``tepp_client``. Never persist a theta. + + Returns True when a terminal status was appended. An accepted + receipt leaves the run Running and returns False so the outbox + stays claimed until a completed result (TEPP#156) or a typed + failure arrives. + """ now = datetime.now(timezone.utc) request = tepp_run_request( idempotency_key=str(locked["idempotency_key"]), @@ -766,15 +925,28 @@ async def _deliver_tepp_measurement( knowledge_cutoff=locked["knowledge_cutoff"], corporate_entity_id=str(locked["corporate_entity_id"]), ) - status_code, failure_code, envelope = _tepp_submission(tepp_client, request) - if status_code == _SUCCEEDED and envelope is not None: + outcome = classify_tepp_submission(tepp_client, request) + status_code = outcome.status_code + failure_code = outcome.failure_code + if outcome.persist_kind == _PERSIST_RESULT and outcome.envelope is not None: if not await _persist_tepp_result( conn, analysis_run_id=analysis_run_id, - envelope=envelope, + envelope=outcome.envelope, ): status_code = _FAILED failure_code = "tepp_result_not_persisted" + elif outcome.persist_kind == _PERSIST_RECEIPT and outcome.envelope is not None: + if await _persist_tepp_accepted_receipt( + conn, + analysis_run_id=analysis_run_id, + envelope=outcome.envelope, + request=request, + knowledge_cutoff=locked["knowledge_cutoff"], + ): + return False + status_code = _FAILED + failure_code = "tepp_receipt_not_persisted" finished = datetime.now(timezone.utc) if finished < now: finished = now @@ -784,5 +956,6 @@ async def _deliver_tepp_measurement( await _next_status_ordinal(conn, analysis_run_id), status_code, finished, - failure_code, + failure_code or None, ) + return True diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 438b4786a..37b8c140e 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -113,6 +113,11 @@ / "migrations" / "0102_project_bound_summary_event.sql" ) +_TEPP_ACCEPTED_RECEIPT_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0106_analysis_run_tepp_accepted_receipt.sql" +) def _postgres_available() -> bool: @@ -226,6 +231,7 @@ def seeded_db(demo_analyst_token): cur.execute(_MAJOR_EVENT_ACTION_MIGRATION.read_text()) cur.execute(_PROJECT_BOUND_ACTION_MIGRATION.read_text()) cur.execute(_PROJECT_BOUND_EVENT_MIGRATION.read_text()) + cur.execute(_TEPP_ACCEPTED_RECEIPT_MIGRATION.read_text()) cur.execute( "insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values " "('corporate_entity_level', 'group', 'Group'), " diff --git a/docker/postgres-init/migrate.sh b/docker/postgres-init/migrate.sh index f329117d6..641472948 100644 --- a/docker/postgres-init/migrate.sh +++ b/docker/postgres-init/migrate.sh @@ -18,7 +18,7 @@ for migration in /opt/lineageweave/migrations/*.sql; do migration_name=${migration##*/} case "$migration_name" in 0012_*|0013_*|0014_*|0015_*|0016_*|0017_*|0018_*|0019_*|0020_*|0021_*|0022_*|0023_*|0024_*|0025_*|0026_*|0027_*|0028_*|0029_*|0030_*|0031_*|0032_*|0033_*|0034_*|0035_*|0036_*|0037_*|0038_*|0039_*|0040_*|0041_*|0042_*|0043_*|0044_*|0045_*|0046_*|0047_*|0048_*|0049_*|0050_*) ;; - 0060_*|0100_*|0101_*|0102_*) ;; + 0060_*|0100_*|0101_*|0102_*|0106_*) ;; *) continue ;; esac printf 'Applying %s\n' "$migration_name" diff --git a/docs/adr/0022-authorized-tepp-start.md b/docs/adr/0022-authorized-tepp-start.md index 84fc71635..839105c1c 100644 --- a/docs/adr/0022-authorized-tepp-start.md +++ b/docs/adr/0022-authorized-tepp-start.md @@ -1,6 +1,8 @@ # ADR 0022 — Operators start a pending TEPP measurement through tepp_client **Decision status:** Accepted on this active PR; not protected-main truth until merge +**Amended by:** ADR 0157 (accepted receipts with a remote run id stay +Running as transport evidence; empty accepted envelopes remain Failed) **Date:** 2026-08-17 **Depends on:** ADR 0013 registry; ADR 0017 authorized create; ADR 0021 authorized lineage start diff --git a/docs/adr/0157-tepp-accepted-receipt.md b/docs/adr/0157-tepp-accepted-receipt.md new file mode 100644 index 000000000..87350b337 --- /dev/null +++ b/docs/adr/0157-tepp-accepted-receipt.md @@ -0,0 +1,104 @@ +# ADR 0157 — Persist TEPP AnalysisRunAccepted as transport evidence + +**Decision status:** Accepted on this active PR; not protected-main truth until merge +**Date:** 2026-08-23 +**Depends on:** ADR 0022 authorized TEPP start; ADR 0023 analysis-run outbox +**Amends:** ADR 0022 (accepted envelopes are no longer Failed / +`tepp_result_not_persisted` when they carry a remote run id) +**Refs:** Issue #277; blocked completed-result poll remains +[TEPP#156](https://github.com/ContextualWisdomLab/TEPP/issues/156) + +## Context + +TEPP's published start response is `AnalysisRunAccepted`: a durable +submission receipt with a remote `run_id`. It is not a temporal +measurement. ADR 0022 treated any non-completed envelope as Failed / +`tepp_result_not_persisted` so LineageWeave would not stamp Succeeded +from transport evidence. That is honest about measurement authority +and dishonest about operator progress: a normal live TEPP accept +looks like a product failure. + +Issue #277 requires a split lifecycle. This slice covers the +unblocked half: persist the accepted receipt, keep the local run +`Running`, and never invent a theta. Polling TEPP's versioned +completed-result contract stays blocked on TEPP#156. + +## Decision + +Classify a `TeppClient.submit_analysis_run` envelope as follows: + +1. `TeppNotAvailable` → Failed / `tepp_not_available`. Seed keeps + this default. Do not change seed to Running. +2. `status` or `run_state` in `{completed, succeeded}` plus a result + object plus a remote run id → Succeeded, persist + `analysis_run_tepp_result` (migration 0027). +3. `status` or `run_state` in `{accepted, queued, running}` plus a + remote run id (`analysis_run_id`, `run_id`, or `remote_run_id`) → + persist `analysis_run_tepp_accepted_receipt`, leave the local run + Running, do not append a terminal status, and do not mark the + outbox delivered. +4. `accepted` / `queued` / `running` without a remote run id → Failed / + `tepp_result_not_persisted` (empty envelopes stay unpersistable). +5. Anything else → Failed / `tepp_result_not_persisted`. + +The receipt table stores transport evidence only: remote run id, +request digest, receipt digest, accepted status code, model contract +version, snapshot id, knowledge cutoff, and received time. It does +not store a result JSON, a theta, or a calibrated score. A changed +receipt digest for the same local run fails closed. Duplicate +identical receipts are idempotent. + +`GET /api/analysis-runs/{id}` may attach `tepp_accepted_receipt` +`{remote_run_id, accepted_status_code, received_at}` so the operator +can see that TEPP accepted the work. Missing migration 0106 is an +empty attachment, not a 500. Global Ask must not promote this +receipt into an answer claim. + +Completed-result polling, bounded backoff, and request-binding +revalidation remain TEPP#156. This ADR does not invent a local +psychometric substitute while waiting. + +```mermaid +sequenceDiagram + participant Operator + participant API + participant TeppClient + participant Registry + Operator->>API: POST /api/analysis-runs/{id}/start + API->>Registry: Running + outbox + API->>TeppClient: AnalysisRunRequest v1 + alt TeppNotAvailable + Registry->>Registry: Failed tepp_not_available + Registry->>Registry: outbox delivered + else accepted with remote run id + Registry->>Registry: persist accepted receipt + Note over Registry: stay Running, outbox stays claimed + API-->>Operator: 200 Running + receipt + else completed with result + Registry->>Registry: persist tepp result + Succeeded + Registry->>Registry: outbox delivered + else accepted without remote run id + Registry->>Registry: Failed tepp_result_not_persisted + Registry->>Registry: outbox delivered + end +``` + +## Consequences + +A connected TEPP transport that returns `accepted` plus a remote run +id no longer looks like a product failure. The operator refreshes a +Running run. Refresh may replay the same idempotent submit; it still +must not stamp Succeeded from the receipt. Seed and an empty +`accepted` envelope stay Failed. Do not invent a theta. + +## References — APA 7th + +Hohpe, G., & Woolf, B. (2003). *Enterprise integration patterns: +Designing, building, and deploying messaging solutions*. Addison-Wesley. + +International Organization for Standardization. (2019). *ISO 8601-1:2019: +Date and time—Representations for information interchange—Part 1: Basic +rules* (confirmed 2024; Amendment 1:2022). + +Moreau, L., & Missier, P. (Eds.). (2013). *PROV-DM: The PROV data model*. +World Wide Web Consortium. https://www.w3.org/TR/prov-dm/ diff --git a/frontend/package.json b/frontend/package.json index e2e996bbe..dcaaebadb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.12.6", + "version": "2.12.11", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 7462abd2c..db01f0637 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import App from "./App"; import { setLocale } from "./i18n"; +import { OIDC_RETURN_URL_STORAGE_KEY } from "./oidcReturnUrl"; const signinRedirect = vi.fn(); const signoutRedirect = vi.fn(); @@ -28,11 +29,22 @@ beforeEach(() => { afterEach(() => { vi.unstubAllGlobals(); + try { + window.sessionStorage.removeItem(OIDC_RETURN_URL_STORAGE_KEY); + } catch { + // jsdom storage may be unavailable in some test hosts. + } + try { + window.localStorage.removeItem(OIDC_RETURN_URL_STORAGE_KEY); + } catch { + // jsdom storage may be unavailable in some test hosts. + } }); describe("App, unauthenticated", () => { it("shows a login button that starts the real OIDC redirect", async () => { render(); + expect(screen.queryByRole("heading", { name: /admin settings/i })).toBeNull(); const button = screen.getByRole("button", { name: /log in/i }); await userEvent.click(button); expect(signinRedirect).toHaveBeenCalledTimes(1); @@ -41,6 +53,8 @@ describe("App, unauthenticated", () => { state: expect.objectContaining({ returnUrl: expect.stringMatching(/^\//) }), }), ); + expect(window.sessionStorage.getItem(OIDC_RETURN_URL_STORAGE_KEY)).toMatch(/^\//); + expect(window.localStorage.getItem(OIDC_RETURN_URL_STORAGE_KEY)).toMatch(/^\//); }); }); @@ -82,6 +96,7 @@ describe("App, authenticated", () => { succeededReportRun?: boolean; succeededTeppRun?: boolean; pendingTeppRun?: boolean; + acceptedTeppRun?: boolean; pluralAffiliations?: boolean; deferMe?: boolean; meFailed?: boolean; @@ -115,6 +130,27 @@ describe("App, authenticated", () => { let createdPendingTepp: Record | null = null; let resolvedHintCode: string | null = null; let contentRequests = 0; + const teppStatus = options?.acceptedTeppRun + ? "analysis_status_running" + : options?.succeededTeppRun + ? "analysis_status_succeeded" + : options?.pendingTeppRun + ? "analysis_status_pending" + : "analysis_status_failed"; + const teppLabel = options?.acceptedTeppRun + ? "Running" + : options?.succeededTeppRun + ? "Succeeded" + : options?.pendingTeppRun + ? "Pending" + : "Failed"; + const teppAcceptedReceipt = options?.acceptedTeppRun + ? { + remote_run_id: "tepp-run-accepted-1", + accepted_status_code: "accepted" as const, + received_at: "2026-01-12T12:36:30Z", + } + : undefined; let releaseMe = () => {}; const meReady = options?.deferMe @@ -324,16 +360,6 @@ describe("App, authenticated", () => { ); } if (url.endsWith("/api/analysis-runs/run-demo-tepp")) { - const teppStatus = options?.succeededTeppRun - ? "analysis_status_succeeded" - : options?.pendingTeppRun - ? "analysis_status_pending" - : "analysis_status_failed"; - const teppLabel = options?.succeededTeppRun - ? "Succeeded" - : options?.pendingTeppRun - ? "Pending" - : "Failed"; return Promise.resolve( jsonResponse({ analysis_run_id: "run-demo-tepp", @@ -354,6 +380,7 @@ describe("App, authenticated", () => { }, ], visible_posts: [{ post_id: "post-1", post_title: "Public post" }], + ...(teppAcceptedReceipt ? { tepp_accepted_receipt: teppAcceptedReceipt } : {}), status_history: options?.pendingTeppRun ? [ { @@ -363,31 +390,46 @@ describe("App, authenticated", () => { occurred_at: "2026-01-12T12:35:00Z", }, ] - : [ - { - status_ordinal: 1, - status_code: "analysis_status_pending", - status_label: "Pending", - occurred_at: "2026-01-12T12:35:00Z", - }, - { - status_ordinal: 2, - status_code: "analysis_status_running", - status_label: "Running", - occurred_at: "2026-01-12T12:36:00Z", - }, - { - status_ordinal: 3, - status_code: options?.succeededTeppRun - ? "analysis_status_succeeded" - : "analysis_status_failed", - status_label: options?.succeededTeppRun ? "Succeeded" : "Failed", - occurred_at: "2026-01-12T12:37:00Z", - ...(options?.succeededTeppRun - ? {} - : { failure_code: "tepp_not_available" }), - }, - ], + : options?.acceptedTeppRun + ? [ + { + status_ordinal: 1, + status_code: "analysis_status_pending", + status_label: "Pending", + occurred_at: "2026-01-12T12:35:00Z", + }, + { + status_ordinal: 2, + status_code: "analysis_status_running", + status_label: "Running", + occurred_at: "2026-01-12T12:36:00Z", + }, + ] + : [ + { + status_ordinal: 1, + status_code: "analysis_status_pending", + status_label: "Pending", + occurred_at: "2026-01-12T12:35:00Z", + }, + { + status_ordinal: 2, + status_code: "analysis_status_running", + status_label: "Running", + occurred_at: "2026-01-12T12:36:00Z", + }, + { + status_ordinal: 3, + status_code: options?.succeededTeppRun + ? "analysis_status_succeeded" + : "analysis_status_failed", + status_label: options?.succeededTeppRun ? "Succeeded" : "Failed", + occurred_at: "2026-01-12T12:37:00Z", + ...(options?.succeededTeppRun + ? {} + : { failure_code: "tepp_not_available" }), + }, + ], }), ); } @@ -686,16 +728,8 @@ describe("App, authenticated", () => { scope_kind_code: "analysis_scope_corporate_entity", scope_kind_label: "Corporate entity", scope_entity_name: "Demo Corp", - status_code: options?.succeededTeppRun - ? "analysis_status_succeeded" - : options?.pendingTeppRun - ? "analysis_status_pending" - : "analysis_status_failed", - status_label: options?.succeededTeppRun - ? "Succeeded" - : options?.pendingTeppRun - ? "Pending" - : "Failed", + status_code: teppStatus, + status_label: teppLabel, knowledge_cutoff: "2026-01-12T12:00:00Z", requested_at: "2026-01-12T12:34:00Z", source_counts: [ @@ -705,6 +739,7 @@ describe("App, authenticated", () => { count_value: 3, }, ], + ...(teppAcceptedReceipt ? { tepp_accepted_receipt: teppAcceptedReceipt } : {}), }, { analysis_run_id: "run-demo-report", @@ -3191,6 +3226,37 @@ describe("App, authenticated", () => { expect(screen.queryByText(/replace Failed/i)).not.toBeInTheDocument(); }); + it("keeps an accepted TEPP receipt Running and does not invent a theta", async () => { + stubBackend({ acceptedTeppRun: true }); + render(); + + const listButton = await screen.findByRole("button", { + name: "Open analysis run: TEPP measurement · Running · Demo Corp", + }); + expect(listButton).toHaveTextContent( + "TEPP accepted this measurement. Refresh to see when the completed result arrives. This receipt is not a calibrated score.", + ); + expect(listButton).not.toHaveTextContent("theta"); + expect(listButton).not.toHaveTextContent("calibrated result"); + + await userEvent.click(listButton); + expect( + await screen.findByRole("heading", { name: "TEPP measurement · Running · Demo Corp" }), + ).toBeInTheDocument(); + expect( + screen.getAllByText( + "TEPP accepted this measurement. Refresh to see when the completed result arrives. This receipt is not a calibrated score.", + ).length, + ).toBeGreaterThan(0); + const history = screen.getByRole("list", { name: "Analysis run status history" }); + expect(history).toHaveTextContent("Running 2026-01-12 12:36"); + expect(history).not.toHaveTextContent("Succeeded"); + expect(history).not.toHaveTextContent("Failed"); + expect(history).not.toHaveTextContent("tepp_not_available"); + expect(screen.queryByText(/theta/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/tepp-run-accepted-1/)).not.toBeInTheDocument(); + }); + it("records a pending lineage run and opens the authorized detail", async () => { const fetchMock = stubBackend(); render(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6fba0dd41..4e6751108 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2455,6 +2455,9 @@ function analysisRunNextAction(run: AnalysisRun): string | null { } } case "analysis_status_running": + if (run.run_kind_code === "analysis_run_tepp" && run.tepp_accepted_receipt) { + return "TEPP accepted this measurement. Refresh to see when the completed result arrives. This receipt is not a calibrated score."; + } return "Refresh this run. Start already queued the work on the durable outbox."; case "analysis_status_succeeded": case "analysis_status_cancelled": @@ -4610,8 +4613,8 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean
@@ -4620,7 +4623,6 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean Enterprise SSO Authentication
- {destination === "admin" ? : null}