diff --git a/CHANGELOG.d/2.12.2-tepp-accepted-clocks.md b/CHANGELOG.d/2.12.2-tepp-accepted-clocks.md new file mode 100644 index 000000000..56af89e3a --- /dev/null +++ b/CHANGELOG.d/2.12.2-tepp-accepted-clocks.md @@ -0,0 +1,7 @@ +# 2.12.2 TEPP accepted evidence stores distinct receipt and row-write clocks + +Accepted transport evidence persists `received_at` as the +transport-response receipt and `recorded_at` as the row-write +instant. Measurement evidence shows the second clock only when those +instants differ. Digest recomputation is unchanged. No invented +theta (ADR 0035 follow-up). diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bb3e2230..1f0862b8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.12.2] - 2026-08-18 + +### Fixed + +- Accepted TEPP transport evidence now stores **received** (transport + response) and **recorded** (row write) as distinct clocks when those + instants differ (ADR 0035 follow-up). After `make seed`, Demo Analyst + opens **TEPP measurement · Failed · Demo Corp** Measurement evidence + and sees one Received clock when seed receipt and persist share an + instant. A later start that persists in a later minute shows both + clocks. Digest recomputation is unchanged. Hidden runs stay 404. + Never invent a theta. + ## [2.12.1] - 2026-08-17 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index bfc9fc2e8..2ffd5241a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,7 +27,8 @@ unpublished envelope is Failed (`tepp_not_available` / `tepp_result_not_persisted`). A published accepted acknowledgement is Failed (`tepp_completed_result_unsupported`) and is shown as aggregate transport evidence. Do not stamp Succeeded from that ack. Do not invent -a theta or a local psychometric substitute. +a theta or a local psychometric substitute. Measurement evidence shows +Received, and recorded only when that row-write instant differs. The home list caption stays `kind · status · entity`; the machine failure code is detail-only (ADR 0014). Open a Failed TEPP row, then connect a live TEPP transport or read aggregate transport evidence. diff --git a/backend/app/analysis_run_start.py b/backend/app/analysis_run_start.py index 62c90bd83..6cf3b657e 100644 --- a/backend/app/analysis_run_start.py +++ b/backend/app/analysis_run_start.py @@ -5,8 +5,10 @@ so a crash after Running does not lose the item. ADR 0035 stores a published TEPP accepted acknowledgement as aggregate transport evidence and never stamps Succeeded from that ack or from a -LineageWeave-local completed envelope. Period-report stays another -path. Neither start invents a theta or a calibrated report score. +LineageWeave-local completed envelope. Accepted evidence stores +transport-response receipt and row-write time as distinct clocks +when those instants differ. Period-report stays another path. +Neither start invents a theta or a calibrated report score. """ from __future__ import annotations @@ -130,6 +132,25 @@ def tepp_run_request( ) +def tepp_accepted_clocks( + *, + started_at: datetime, + received_at: datetime, + recorded_at: datetime, +) -> tuple[datetime, datetime]: + """Return receipt then row-write clocks, monotonic versus start. + + ``received_at`` is the transport-response receipt. ``recorded_at`` + is the later row-write instant. A clock that runs backward is + clamped forward so ``started_at <= received_at <= recorded_at``. + Equal instants stay equal; this helper does not invent a later + recorded clock. + """ + receipt = received_at if received_at >= started_at else started_at + recorded = recorded_at if recorded_at >= receipt else receipt + return receipt, recorded + + def tepp_submit_outcome( client: TeppClient, request: AnalysisRunRequest, @@ -709,9 +730,16 @@ async def _persist_tepp_accepted( conn: asyncpg.Connection, analysis_run_id: str, evidence: TeppAcceptedEvidence, + received_at: datetime, recorded_at: datetime, ) -> bool: - """Store published accepted evidence. Missing table is not success.""" + """Store published accepted evidence with receipt and row-write clocks. + + Missing table is not success. Callers pass transport-response + receipt as ``received_at`` and the row-write instant as + ``recorded_at``. This function binds those two values as given and + does not invent a later recorded clock when they are equal. + """ try: await conn.execute( """ @@ -726,7 +754,7 @@ async def _persist_tepp_accepted( evidence.run_state, evidence.idempotency_key, evidence.evidence_sha256(), - recorded_at, + received_at, recorded_at, ) except asyncpg.UndefinedTableError: @@ -742,7 +770,7 @@ async def _deliver_tepp_measurement( tepp_client: TeppClient, ) -> None: """Submit the frozen snapshot through ``tepp_client``. Never persist a theta.""" - now = datetime.now(timezone.utc) + started_at = datetime.now(timezone.utc) request = tepp_run_request( idempotency_key=str(locked["idempotency_key"]), snapshot_sha256=str(locked["snapshot_sha256"]), @@ -750,12 +778,16 @@ async def _deliver_tepp_measurement( corporate_entity_id=str(locked["corporate_entity_id"]), ) status_code, failure_code, accepted = tepp_submit_outcome(tepp_client, request) - finished = datetime.now(timezone.utc) - if finished < now: - finished = now + received_at = datetime.now(timezone.utc) + recorded_at = datetime.now(timezone.utc) + receipt, recorded = tepp_accepted_clocks( + started_at=started_at, + received_at=received_at, + recorded_at=recorded_at, + ) if accepted is not None: stored = await _persist_tepp_accepted( - conn, analysis_run_id, accepted, finished + conn, analysis_run_id, accepted, receipt, recorded ) if not stored: status_code, failure_code = _FAILED, "tepp_result_not_persisted" @@ -764,6 +796,6 @@ async def _deliver_tepp_measurement( analysis_run_id, await _next_status_ordinal(conn, analysis_run_id), status_code, - finished, + recorded, failure_code, ) diff --git a/docs/adr/0035-tepp-accepted-transport-evidence.md b/docs/adr/0035-tepp-accepted-transport-evidence.md index 207a87a69..ef7e706ce 100644 --- a/docs/adr/0035-tepp-accepted-transport-evidence.md +++ b/docs/adr/0035-tepp-accepted-transport-evidence.md @@ -98,6 +98,19 @@ Existing volumes apply `0029_analysis_run_tepp_accepted.sql` after 0028. Granted retention purge empties the accepted table when it exists. +## Follow-up — v2.12.2 distinct receipt and row-write clocks + +Decision 3 already named `received_at` (transport-response receipt) +and `recorded_at` (row persistence). v2.12.1 bound one application +instant into both columns, so Measurement evidence copy always showed +two clocks. v2.12.2 passes the post-transport instant as +`received_at` and the row-write instant as `recorded_at`, clamped so +start ≤ receipt ≤ row-write (National Institute of Standards and +Technology, 2015). Authorized copy shows the second clock only when +the displayed instants differ. Digest recomputation is unchanged and +still excludes clocks. Hidden runs stay 404 (ADR 0014). Migration +0029 is not rewritten; the two columns already exist. + ## References — APA 7th American Educational Research Association, American Psychological diff --git a/frontend/package.json b/frontend/package.json index 1051b23c9..72e864e31 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.12.1", + "version": "2.12.2", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 2d1528192..c2691e341 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -74,6 +74,8 @@ describe("App, authenticated", () => { succeededReportRun?: boolean; succeededTeppRun?: boolean; acceptedTeppRun?: boolean; + distinctTeppClocks?: boolean; + omitTeppRecordedAt?: boolean; pendingTeppRun?: boolean; hiddenAnalysisRun?: boolean; pluralAffiliations?: boolean; @@ -325,7 +327,13 @@ describe("App, authenticated", () => { tepp_idempotency_key: "demo-tepp-seed-2026-w02-succeeded", tepp_evidence_sha256: "a".repeat(64), tepp_received_at: "2026-01-12T12:45:00Z", - tepp_recorded_at: "2026-01-12T12:45:00Z", + ...(options?.omitTeppRecordedAt + ? {} + : { + tepp_recorded_at: options?.distinctTeppClocks + ? "2026-01-12T12:46:00Z" + : "2026-01-12T12:45:00Z", + }), tepp_completed_artifact_available: false, } : {}; @@ -722,7 +730,13 @@ describe("App, authenticated", () => { tepp_run_state: "accepted", tepp_evidence_sha256: "a".repeat(64), tepp_received_at: "2026-01-12T12:45:00Z", - tepp_recorded_at: "2026-01-12T12:45:00Z", + ...(options?.omitTeppRecordedAt + ? {} + : { + tepp_recorded_at: options?.distinctTeppClocks + ? "2026-01-12T12:46:00Z" + : "2026-01-12T12:45:00Z", + }), tepp_completed_artifact_available: false, } : {}), @@ -2944,6 +2958,8 @@ describe("App, authenticated", () => { expect(screen.getAllByText("aggregate transport evidence").length).toBeGreaterThan(0); expect(screen.getByText("a".repeat(64))).toBeInTheDocument(); expect(screen.getByText(/accepted run demo-tepp-accepted-opaque/)).toBeInTheDocument(); + expect(screen.getByText("Received 2026-01-12 12:45")).toBeInTheDocument(); + expect(screen.queryByText(/recorded 2026-01-12/)).not.toBeInTheDocument(); expect(screen.getByText(/completed-artifact identity/i)).toBeInTheDocument(); expect(screen.queryByText(/validated multilevel estimate/i)).toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: "Copy evidence SHA-256" })); @@ -2952,6 +2968,34 @@ describe("App, authenticated", () => { expect(screen.queryByText(/2 affiliations/)).not.toBeInTheDocument(); }); + it("shows two TEPP clocks only when receipt and row-write differ", async () => { + stubBackend({ acceptedTeppRun: true, distinctTeppClocks: true }); + render(); + + await userEvent.click( + await screen.findByRole("button", { + name: "Open analysis run: TEPP measurement · Failed · Demo Corp. Open this run to read aggregate transport evidence. Completed TEPP measurement identity is unavailable until TEPP publishes a versioned completed-result contract.", + }), + ); + expect( + await screen.findByText("Received 2026-01-12 12:45 · recorded 2026-01-12 12:46"), + ).toBeInTheDocument(); + expect(screen.queryByText(/theta/i)).not.toBeInTheDocument(); + }); + + it("shows only the receipt clock when recorded time is absent", async () => { + stubBackend({ acceptedTeppRun: true, omitTeppRecordedAt: true }); + render(); + + await userEvent.click( + await screen.findByRole("button", { + name: "Open analysis run: TEPP measurement · Failed · Demo Corp. Open this run to read aggregate transport evidence. Completed TEPP measurement identity is unavailable until TEPP publishes a versioned completed-result contract.", + }), + ); + expect(await screen.findByText("Received 2026-01-12 12:45")).toBeInTheDocument(); + expect(screen.queryByText(/recorded 2026-01-12/)).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 722cab747..0e9099931 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2037,6 +2037,34 @@ function analysisRunCorpusHint(run: AnalysisRun): string | null { } } +/** + * Buyer-visible TEPP receipt clock. Minute precision matches other run clocks. + */ +function formatTeppEvidenceClock(iso: string): string { + return iso.slice(0, 16).replace("T", " "); +} + +/** + * Authorized TEPP clocks. A second clock appears only when instants differ. + * + * Equal receipt and row-write values stay one sentence so the copy does + * not invent a second clock. Missing recorded time is receipt only. + */ +function teppAcceptedClockCopy( + receivedAt: string, + recordedAt: string | undefined, +): string { + const received = formatTeppEvidenceClock(receivedAt); + if (recordedAt === undefined) { + return `Received ${received}`; + } + const recorded = formatTeppEvidenceClock(recordedAt); + if (recorded === received) { + return `Received ${received}`; + } + return `Received ${received} · recorded ${recorded}`; +} + /** * Authorized TEPP transport evidence. Never a validated multilevel estimate. * @@ -2071,10 +2099,7 @@ function TeppMeasurementEvidence({ run }: { run: AnalysisRun }) {

{run.tepp_received_at && (

- Received {run.tepp_received_at.slice(0, 16).replace("T", " ")} - {run.tepp_recorded_at - ? ` · recorded ${run.tepp_recorded_at.slice(0, 16).replace("T", " ")}` - : ""} + {teppAcceptedClockCopy(run.tepp_received_at, run.tepp_recorded_at)}

)}

diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 687eb5d06..d1fff5078 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "2.12.1" +__version__ = "2.12.2" diff --git a/pyproject.toml b/pyproject.toml index 7360226aa..ade603e14 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.12.1" +version = "2.12.2" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/tests/test_analysis_run_start.py b/tests/test_analysis_run_start.py index 7973c339b..2fcccd0b1 100644 --- a/tests/test_analysis_run_start.py +++ b/tests/test_analysis_run_start.py @@ -1,17 +1,22 @@ """Start-reconstruction contracts: digest, freeze, 422/409, designed tree.""" +import asyncio from datetime import datetime, timezone +import asyncpg import pytest from backend.app.analysis_run_ingestion import reconstructed_edge_is_visible from backend.app.analysis_run_start import ( AnalysisRunStartError, + _deliver_tepp_measurement, + _persist_tepp_accepted, configured_tepp_client, reconstruction_member_ids, reconstruction_result_digest, start_kind_rejection, start_write_conflict_error, + tepp_accepted_clocks, tepp_run_request, tepp_submit_outcome, ) @@ -20,7 +25,9 @@ from lineageweave.lineage_persistence import lineage_edge_specs from lineageweave.tepp_client import AnalysisRunRequest, TeppClient, TeppNotAvailable from lineageweave.tepp_result import ( + TeppAcceptedEvidence, accepted_tepp_seed_envelope, + parse_tepp_accepted_evidence, persistable_tepp_seed_envelope, ) @@ -193,6 +200,231 @@ def test_configured_tepp_client_stays_unavailable_without_http() -> None: client.submit_analysis_run(_tepp_request()) +def test_tepp_accepted_clocks_keep_distinct_receipt_and_row_write() -> None: + """Transport receipt and row write stay two values when they differ.""" + started = datetime(2026, 1, 12, 12, 44, tzinfo=timezone.utc) + received = datetime(2026, 1, 12, 12, 45, tzinfo=timezone.utc) + recorded = datetime(2026, 1, 12, 12, 46, tzinfo=timezone.utc) + assert tepp_accepted_clocks( + started_at=started, + received_at=received, + recorded_at=recorded, + ) == (received, recorded) + + +def test_tepp_accepted_clocks_clamp_backward_receipt_to_start() -> None: + """A receipt earlier than start is not stored as a later invention.""" + started = datetime(2026, 1, 12, 12, 45, tzinfo=timezone.utc) + earlier = datetime(2026, 1, 12, 12, 44, tzinfo=timezone.utc) + assert tepp_accepted_clocks( + started_at=started, + received_at=earlier, + recorded_at=earlier, + ) == (started, started) + + +def test_tepp_accepted_clocks_clamp_backward_row_write_to_receipt() -> None: + """A row-write earlier than receipt stays the receipt, not invented later.""" + started = datetime(2026, 1, 12, 12, 44, tzinfo=timezone.utc) + received = datetime(2026, 1, 12, 12, 45, tzinfo=timezone.utc) + earlier = datetime(2026, 1, 12, 12, 44, 30, tzinfo=timezone.utc) + assert tepp_accepted_clocks( + started_at=started, + received_at=received, + recorded_at=earlier, + ) == (received, received) + + +def test_tepp_accepted_clocks_do_not_invent_a_second_instant() -> None: + """Equal receipt and persist stay one stored instant.""" + instant = datetime(2026, 1, 12, 12, 45, tzinfo=timezone.utc) + assert tepp_accepted_clocks( + started_at=instant, + received_at=instant, + recorded_at=instant, + ) == (instant, instant) + + +def _accepted_evidence() -> TeppAcceptedEvidence: + """Published Demo Corp accepted envelope used by persist-path tests.""" + parsed = parse_tepp_accepted_evidence( + accepted_tepp_seed_envelope(idempotency_key="buyer-key"), + expected_idempotency_key="buyer-key", + ) + assert parsed is not None + return parsed + + +def test_persist_tepp_accepted_stores_two_clock_values() -> None: + """The insert binds transport receipt and row-write as distinct values.""" + received = datetime(2026, 1, 12, 12, 45, tzinfo=timezone.utc) + recorded = datetime(2026, 1, 12, 12, 46, tzinfo=timezone.utc) + + class _Conn: + def __init__(self) -> None: + self.bound: tuple[object, ...] | None = None + + async def execute(self, _sql: str, *args: object) -> str: + self.bound = args + return "INSERT 0 1" + + conn = _Conn() + stored = asyncio.run( + _persist_tepp_accepted(conn, "run-id", _accepted_evidence(), received, recorded) + ) + assert stored is True + assert conn.bound is not None + assert conn.bound[6] == received + assert conn.bound[7] == recorded + assert conn.bound[6] != conn.bound[7] + + +def test_persist_tepp_accepted_keeps_equal_clocks_equal() -> None: + """Same-instant receipt and persist are stored once each, not rewritten later.""" + instant = datetime(2026, 1, 12, 12, 45, tzinfo=timezone.utc) + + class _Conn: + def __init__(self) -> None: + self.bound: tuple[object, ...] | None = None + + async def execute(self, _sql: str, *args: object) -> str: + self.bound = args + return "INSERT 0 1" + + conn = _Conn() + stored = asyncio.run( + _persist_tepp_accepted(conn, "run-id", _accepted_evidence(), instant, instant) + ) + assert stored is True + assert conn.bound is not None + assert conn.bound[6] == instant + assert conn.bound[7] == instant + + +def test_persist_tepp_accepted_fails_closed_without_the_table() -> None: + """A missing accepted-evidence table is not success.""" + instant = datetime(2026, 1, 12, 12, 45, tzinfo=timezone.utc) + + class _Conn: + async def execute(self, _sql: str, *_args: object) -> str: + raise asyncpg.UndefinedTableError("undefined_table") + + stored = asyncio.run( + _persist_tepp_accepted(_Conn(), "run-id", _accepted_evidence(), instant, instant) + ) + assert stored is False + + +class _DeliverConn: + """In-memory start connection for TEPP persist and status append.""" + + def __init__(self, *, persist_ok: bool = True) -> None: + self.persist_ok = persist_ok + self.accepted_args: tuple[object, ...] | None = None + self.status_args: tuple[object, ...] | None = None + + async def fetchval(self, _sql: str, *_args: object) -> int: + return 2 + + async def execute(self, sql: str, *args: object) -> str: + if "analysis_run_tepp_accepted" in sql: + if not self.persist_ok: + raise asyncpg.UndefinedTableError("undefined_table") + self.accepted_args = args + return "INSERT 0 1" + if "analysis_run_status_event" in sql: + self.status_args = args + return "INSERT 0 1" + raise AssertionError(sql) + + +def _locked_tepp_row() -> dict[str, object]: + """Frozen Demo Corp TEPP start row. Never invents a theta.""" + return { + "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", + } + + +def test_deliver_tepp_measurement_persists_distinct_clocks() -> None: + """Accepted evidence stores receipt then row-write, and stays Failed.""" + request = _tepp_request() + + class _Accepted(TeppClient): + def __init__(self) -> None: + super().__init__( + transport=lambda _payload: accepted_tepp_seed_envelope( + idempotency_key=request.idempotency_key + ) + ) + + conn = _DeliverConn() + asyncio.run( + _deliver_tepp_measurement( + conn, + analysis_run_id="run-id", + locked=_locked_tepp_row(), + tepp_client=_Accepted(), + ) + ) + assert conn.accepted_args is not None + received_at = conn.accepted_args[6] + recorded_at = conn.accepted_args[7] + assert isinstance(received_at, datetime) + assert isinstance(recorded_at, datetime) + assert received_at <= recorded_at + assert conn.status_args is not None + assert conn.status_args[2] == "analysis_status_failed" + assert conn.status_args[4] == "tepp_completed_result_unsupported" + assert conn.status_args[3] == recorded_at + + +def test_deliver_tepp_measurement_fails_closed_when_table_is_missing() -> None: + """A missing accepted table is Failed, never a fabricated measurement.""" + request = _tepp_request() + + class _Accepted(TeppClient): + def __init__(self) -> None: + super().__init__( + transport=lambda _payload: accepted_tepp_seed_envelope( + idempotency_key=request.idempotency_key + ) + ) + + conn = _DeliverConn(persist_ok=False) + asyncio.run( + _deliver_tepp_measurement( + conn, + analysis_run_id="run-id", + locked=_locked_tepp_row(), + tepp_client=_Accepted(), + ) + ) + assert conn.accepted_args is None + assert conn.status_args is not None + assert conn.status_args[2] == "analysis_status_failed" + assert conn.status_args[4] == "tepp_result_not_persisted" + + +def test_deliver_tepp_measurement_does_not_persist_a_missing_transport() -> None: + """A missing TEPP transport writes no accepted evidence row.""" + conn = _DeliverConn() + asyncio.run( + _deliver_tepp_measurement( + conn, + analysis_run_id="run-id", + locked=_locked_tepp_row(), + tepp_client=TeppClient(), + ) + ) + assert conn.accepted_args is None + assert conn.status_args is not None + assert conn.status_args[2] == "analysis_status_failed" + assert conn.status_args[4] == "tepp_not_available" + + def test_hidden_run_start_is_not_found() -> None: """Operators get a 404 next action, not an internal exception name.""" error = AnalysisRunStartError(404, "This analysis run is not visible.") diff --git a/tests/test_tepp_public_content.py b/tests/test_tepp_public_content.py index 7f070f0b0..546438553 100644 --- a/tests/test_tepp_public_content.py +++ b/tests/test_tepp_public_content.py @@ -13,6 +13,7 @@ _ROOT / "migrations" / "0029_analysis_run_tepp_accepted.sql", _ROOT / "docs" / "adr" / "0035-tepp-accepted-transport-evidence.md", _ROOT / "CHANGELOG.d" / "2.12.1-tepp-accepted-transport-evidence.md", + _ROOT / "CHANGELOG.d" / "2.12.2-tepp-accepted-clocks.md", _ROOT / "tests" / "test_tepp_result.py", _ROOT / "tests" / "test_analysis_run_tepp_accepted_schema.py", ) diff --git a/tests/test_tepp_transport_evidence.py b/tests/test_tepp_transport_evidence.py index 0e8192412..764cc16d3 100644 --- a/tests/test_tepp_transport_evidence.py +++ b/tests/test_tepp_transport_evidence.py @@ -12,6 +12,30 @@ ) +def test_project_tepp_transport_evidence_keeps_digest_independent_of_clocks() -> None: + """Clock split must not change the published accepted-field digest.""" + expected = tepp_accepted_evidence_sha256( + contract_version=1, + accepted_run_id="demo-tepp-accepted-opaque", + run_state="accepted", + idempotency_key="buyer-key", + ) + later = { + "contract_version": 1, + "accepted_run_id": "demo-tepp-accepted-opaque", + "run_state": "accepted", + "idempotency_key": "buyer-key", + "evidence_sha256": expected, + "received_at": "2026-01-12T12:45:00Z", + "recorded_at": "2026-01-12T12:46:00Z", + } + projected = project_tepp_transport_evidence(later) + assert projected is not None + assert projected["tepp_evidence_sha256"] == expected + assert projected["tepp_received_at"] == "2026-01-12T12:45:00Z" + assert projected["tepp_recorded_at"] == "2026-01-12T12:46:00Z" + + def test_project_tepp_transport_evidence_recomputes_the_exact_digest() -> None: """The API digest must match an independent SHA-256 recomputation.""" parsed = parse_tepp_accepted_evidence( @@ -57,6 +81,18 @@ def test_project_tepp_transport_evidence_fails_closed_on_digest_mismatch() -> No assert project_tepp_transport_evidence(row) is None +def test_persist_path_binds_received_and_recorded_as_distinct_arguments() -> None: + """Start must not write one timestamp into both accepted-evidence clocks.""" + source = ( + Path(__file__).resolve().parents[1] + / "backend" + / "app" + / "analysis_run_start.py" + ).read_text(encoding="utf-8") + assert "received_at,\n recorded_at," in source + assert source.count("recorded_at,\n recorded_at,") == 0 + + def test_tepp_accepted_query_binds_authorized_run_ids_only() -> None: """Hidden runs never enter the evidence query parameter list.""" source = (