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(
- 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 = (