Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,10 @@ expose purge on a public HTTP route.
v2.7.1). TEPP and period-report kinds 422 before any snapshot write.
`POST /api/analysis-runs/{id}/start` reconstructs a Pending lineage
cutoff bag through `reconstruct()` / `lineage_edge_specs` (ADR 0021 /
v0.88.0). Do not invent a theta.
v0.88.0). Do not invent a theta. Status `recorded_at` is
`greatest(clock_timestamp(), occurred_at)` so a Python-ahead
occurrence does not fail `occurred_at <= recorded_at` (ADR 0013 /
v2.12.7). Do not clamp `occurred_at` down.
Opening a cutoff-rewritten title shows **Body this run knew** from
`source_post_revision` beside the live rewrite (ADR 0025 / v2.1.0).
Do not invent the earlier sentence when no revision covers the cutoff.
Expand Down
3 changes: 3 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,9 @@ more than one), then open the Pending row to confirm the cutoff corpus.
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.
Status `recorded_at` is `greatest(clock_timestamp(), occurred_at)` so a
Python-ahead occurrence does not fail `occurred_at <= recorded_at`
(ADR 0013 / v2.12.7).
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 /
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.d/2.12.7-analysis-run-status-write-clock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# 2.12.7 Analysis-run status write clock covers Python-ahead occurrence

Starting an analysis run against live PostgreSQL no longer fails
`occurred_at <= recorded_at`. The status trigger stamps `recorded_at`
as the later of `clock_timestamp()` and `occurred_at`. Occurrence
time is not clamped down. Same pattern as TEPP accepted clocks
(ADR 0013 follow-up).
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,23 @@ All notable changes to this project are documented here. Format follows

## [Unreleased]

## [2.12.7] - 2026-08-21

### Fixed

- Starting an analysis run against live PostgreSQL no longer fails
`analysis_run_status_time_check` (`occurred_at <= recorded_at`).
The 0018 BEFORE INSERT trigger now stamps `recorded_at` as
`greatest(clock_timestamp(), occurred_at)`, so a Python-ahead
`datetime.now(timezone.utc)` (~15-20ms after Postgres
`clock_timestamp()`) still satisfies the check. Occurrence time is
**not** clamped down: that would break monotonicity against
previously stored Python-ahead status events. Client-supplied
`recorded_at` is still discarded. Same pattern as TEPP accepted
clocks (ADR 0013 follow-up). Additive migration 0104 updates
existing volumes; fresh installs pick it up from 0018. After this
lands, Demo Analyst can start a Pending lineage run on the live
stack.
- `make smoke` and `make seed` now run through the locked project `uv`
environment, so local OIDC and synthetic-data workflows resolve the same
pinned dependencies as CI.
Expand All @@ -33,6 +48,9 @@ All notable changes to this project are documented here. Format follows

### Fixed

- `make smoke` and `make seed` now run through the locked project `uv`
environment, so local OIDC and synthetic-data workflows resolve the same
pinned dependencies as CI.
Comment on lines +51 to +53

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Unreleased changelog item reattributed to already-released 2.12.5

The make smoke/make seed uv-environment entry was moved out of [Unreleased] and into the already-dated [2.12.5] - 2026-08-18 section (CHANGELOG.md). Since 2.12.5 was previously published without this item, attributing an unreleased change to an already-released version is a documentation inaccuracy. Not a code bug, but worth confirming this was intentional rather than a merge artifact.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +51 to +53

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changelog entry duplicated across two releases

The make smoke/make seed uv-environment bullet is added under [2.12.5] while the identical bullet already sits under [2.12.7] (CHANGELOG.md:26-28). One fix is now attributed to two released versions.

Prompt for agents
The make smoke / make seed uv-environment bullet is listed twice in CHANGELOG.md: once under [2.12.7] (lines 26-28) and again under [2.12.5] (lines 51-53). A single fix should appear under only one release. Decide which version actually shipped this change and remove the duplicate bullet from the other section.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

- Migrations 0019 and 0025 (R&R role-catalog identity backfills) both
used `min(uuid_column)` to pick "the" value from a `having count(*)
= 1` group -- Postgres has no built-in `min(uuid)` aggregate, so
Expand Down Expand Up @@ -75,6 +93,7 @@ All notable changes to this project are documented here. Format follows
as out of scope for this migration-catchup change (a different
feature area -- analysis-run/TEPP lifecycle, not R&R/summary/
verification) rather than rushed. 553 other tests unaffected.
Fixed in 2.12.7.

- `get_or_create_corporate_entity`'s post-lock duplicate-create re-check
fuzzy-matched against every cataloged entity, not just an exact
Expand Down
11 changes: 8 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,14 @@ are 422. The Request button waits until affiliated corps load; choose
a corp if the token walks more than one. `POST /api/analysis-runs/{id}/start`
commits Running plus a durable outbox row, then reconstructs that
frozen cutoff bag (ADR 0021 / ADR 0023) or submits TEPP through
`tepp_client` (ADR 0022). A missing transport or unused accepted
envelope is Failed. Failed TEPP is terminal — connect a TEPP
transport from that Failed row. Create does not invent a Pending
`tepp_client` (ADR 0022 / ADR 0035). Status `recorded_at` is
`greatest(clock_timestamp(), occurred_at)` so a Python-ahead
occurrence does not fail the write-clock check (ADR 0013 / v2.12.7).
A missing transport or unpublished
envelope is Failed. A published accepted acknowledgement is Failed
transport evidence, not a completed measurement. Failed TEPP is
terminal — connect a TEPP transport from that Failed row or read the
stored evidence. Create does not invent a Pending
TEPP row. Do not invent a theta. Hover the Result prefix to read
the parent-choice digest.
After `make seed`, open **Period report · Succeeded · Demo Corp**,
Expand Down
50 changes: 23 additions & 27 deletions backend/app/analysis_run_start.py

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Status write-clock fix is inert against the real insert paths

The fix targets a Python-ahead occurred_at, but every live insert sets occurred_at to clock_timestamp() in the same statement: _append_status (analysis_run_start.py, whose occurred_at parameter is unused) and the pending insert (backend/app/analysis_run_ingestion.py:949-951). In a BEFORE INSERT trigger the VALUES clock is read first, so the trigger's later database_now is always >= new.occurred_at. greatest(database_now, new.occurred_at) therefore always returns database_now, matching the old assignment, and the one-minute future check never fires. The Python finished/now values handed to _append_status are dead. The described 15-20ms race was already removed when _append_status switched to clock_timestamp(). The migration is a safe no-op here; confirm whether any path still feeds a client-supplied occurred_at that the new bound is meant to guard.

(Refers to this code)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

import hashlib
import json
from datetime import datetime, timezone
from datetime import UTC, datetime
from typing import Any
from uuid import UUID

Expand All @@ -21,15 +21,15 @@
AnalysisRunCreateError,
fetch_visible_analysis_run,
)
from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL
from backend.app.analysis_run_outbox import (
latest_outbox_delivery_is_claimed,
latest_outbox_delivery_is_delivered,
outbox_request_digest,
)
from backend.app.lineage_ingestion import records_from_source_posts
from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL
from lineageweave.adjudication_client import AdjudicationClient
from lineageweave.http_client import HttpClientError, post_json
from lineageweave.http_client import post_json
from lineageweave.lineage_persistence import lineage_edge_specs
from lineageweave.models import Edge
from lineageweave.tepp_client import AnalysisRunRequest, TeppClient, TeppNotAvailable
Expand Down Expand Up @@ -103,8 +103,8 @@ def transport(payload: dict[str, Any]) -> dict[str, Any]:
try:
headers = {"authorization": f"Bearer {api_key}"} if api_key.strip() else {}
return post_json(url, payload, headers=headers, timeout=30.0)
except (HttpClientError, OSError, ValueError, TypeError) as exc:
raise TeppNotAvailable("TEPP transport unavailable") from exc
except Exception as exc:
raise TeppNotAvailable("TEPP transport request failed") from exc

return TeppClient(transport=transport)

Expand All @@ -119,12 +119,12 @@ def tepp_run_request(
"""Build TEPP's published request from the frozen run, never a theta."""
cutoff = knowledge_cutoff
if cutoff.tzinfo is None:
cutoff = cutoff.replace(tzinfo=timezone.utc)
cutoff = cutoff.replace(tzinfo=UTC)
return AnalysisRunRequest(
idempotency_key=idempotency_key,
tenant_workspace_id=str(corporate_entity_id),
snapshot_id=snapshot_sha256,
knowledge_cutoff=cutoff.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
knowledge_cutoff=cutoff.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
model_contract_version=_TEPP_MODEL_CONTRACT,
output_profile=_TEPP_OUTPUT_PROFILE,
)
Expand Down Expand Up @@ -610,7 +610,7 @@ async def deliver_queued_analysis_run(
return await _visible_or_404(
conn, analysis_run_id, account_id, affiliated_entity_ids
)
now = datetime.now(timezone.utc)
now = datetime.now(UTC)
try:
if not latest_outbox_delivery_is_claimed(latest):
await _append_outbox_delivery(
Expand All @@ -636,9 +636,8 @@ 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
finished = datetime.now(UTC)
finished = max(finished, now)
await _append_outbox_delivery(
conn,
analysis_run_id,
Expand Down Expand Up @@ -699,7 +698,7 @@ async def _deliver_lineage_reconstruction(
adjudication_client: AdjudicationClient | None = None,
) -> None:
"""Persist ThreadWeave parent choices for the frozen bag."""
now = datetime.now(timezone.utc)
now = datetime.now(UTC)
member_rows = await _snapshot_member_posts(
conn,
locked["analysis_source_snapshot_id"],
Expand All @@ -715,9 +714,8 @@ async def _deliver_lineage_reconstruction(
)
edges = lineage_edge_specs(records_from_source_posts(rows), llm=adjudication_client)
digest = reconstruction_result_digest(edges)
finished = datetime.now(timezone.utc)
if finished < now:
finished = now
finished = datetime.now(UTC)
finished = max(finished, now)
await conn.execute(
"""
insert into analysis_run_reconstruction
Expand Down Expand Up @@ -759,25 +757,23 @@ 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)
now = datetime.now(UTC)
request = tepp_run_request(
idempotency_key=str(locked["idempotency_key"]),
snapshot_sha256=str(locked["snapshot_sha256"]),
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:
if not await _persist_tepp_result(
conn,
analysis_run_id=analysis_run_id,
envelope=envelope,
):
status_code = _FAILED
failure_code = "tepp_result_not_persisted"
finished = datetime.now(timezone.utc)
if finished < now:
finished = now
if status_code == _SUCCEEDED and envelope is not None and not await _persist_tepp_result(
conn,
analysis_run_id=analysis_run_id,
envelope=envelope,
):
status_code = _FAILED
failure_code = "tepp_result_not_persisted"
finished = datetime.now(UTC)
finished = max(finished, now)
await _append_status(
conn,
analysis_run_id,
Expand Down
4 changes: 4 additions & 0 deletions backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@
/ "migrations"
/ "0102_project_bound_summary_event.sql"
)
_STATUS_WRITE_CLOCK_MIGRATION = (
Path(__file__).resolve().parents[2] / "migrations" / "0104_analysis_run_status_write_clock.sql"
)


def _postgres_available() -> bool:
Expand Down Expand Up @@ -226,6 +229,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(_STATUS_WRITE_CLOCK_MIGRATION.read_text())
cur.execute(
"insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values "
"('corporate_entity_level', 'group', 'Group'), "
Expand Down
2 changes: 2 additions & 0 deletions docker/postgres-init/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ COPY migrations/0038_source_named_hints.sql /docker-entrypoint-initdb.d/40-sourc
COPY migrations/0039_source_org_named_hints.sql /docker-entrypoint-initdb.d/41-source-org-named-hints.sql
COPY migrations/0040_post_summary_contract.sql /docker-entrypoint-initdb.d/42-post-summary-contract.sql

COPY migrations/0104_analysis_run_status_write_clock.sql /docker-entrypoint-initdb.d/43-analysis-run-status-write-clock.sql

COPY migrations/ /opt/lineageweave/migrations/
COPY docker/postgres-init/migrate.sh /usr/local/bin/lineageweave-migrate
USER root
Expand Down
2 changes: 1 addition & 1 deletion docker/postgres-init/migrate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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_*|0103_*|0104_*) ;;
*) continue ;;
esac
printf 'Applying %s\n' "$migration_name"
Expand Down
21 changes: 18 additions & 3 deletions docs/adr/0013-normalized-analysis-run-registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,15 @@ succeeded | failed | cancelled -> terminal

The first event must be `pending`, requires an immutable scope, and cannot predate
the run request. Failed events require a lowercase machine-code identifier; raw
exception text is prohibited. `recorded_at` is overwritten with database system
time on every insert and cannot precede `occurred_at`.
exception text is prohibited. `recorded_at` is overwritten on every insert with
the later of one captured database clock reading and `occurred_at` so database
write time never precedes occurrence. Client-supplied `recorded_at` is
discarded. A client occurrence may be at most one minute ahead of the database
clock; larger values are rejected as
`analysis_run_status_time_too_far_in_future` rather than manufacturing future
audit history. Do not clamp `occurred_at` down: a Python-ahead occurrence
within that bounded skew must stay monotonic against previously stored status
events (v2.12.7).
`analysis_run_current_status` is a view, not a second mutable state authority.

### Authorization scope
Expand Down Expand Up @@ -225,7 +232,8 @@ Acceptance requires:
- account-scoped idempotency;
- snapshot, count, run, and authorization-scope immutability;
- deletion resistance for request and scope audit evidence;
- scope-required lifecycle, request-time ordering, and database-owned record time;
- scope-required lifecycle, request-time ordering, and recorded time at least
as late as occurrence;
- canonical idempotency and bounded machine-code failure identifiers;
- count/run concurrency serialization;
- pending-first, contiguous, monotonic, legal status transitions;
Expand Down Expand Up @@ -262,6 +270,13 @@ Acceptance requires:
5. Execute private actual-data analysis and store only signed aggregate and
reproducibility manifests outside public source control.
6. Run browser E2E through real OIDC, product navigation, and evidence drill-down.
7. v2.12.7: stamp status `recorded_at` as the later of one captured database
clock reading and `occurred_at` so a bounded Python-ahead occurrence does
not fail `occurred_at <= recorded_at`; reject occurrences more than one
minute ahead as `analysis_run_status_time_too_far_in_future`. Do not clamp
`occurred_at` down. Additive migration 0104 updates existing volumes; 0018
carries the same assignment for fresh installs. This is not a new ADR
number -- ADR 0036 is reserved on the #258 stack.

## References — APA 7th

Expand Down
4 changes: 3 additions & 1 deletion docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ general-purpose bitemporal database:
- `knowledge_cutoff` answers what a specific analysis was allowed to know;
- `requested_at` answers when that analysis was requested;
- `occurred_at` and `recorded_at` distinguish lifecycle occurrence from durable
database recording.
database recording. `recorded_at` is `greatest(clock_timestamp(), occurred_at)`
so a Python-ahead occurrence still satisfies `occurred_at <= recorded_at`
(v2.12.7). Do not clamp `occurred_at` down.

The database requires the aggregate leakage boundary:

Expand Down
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "2.12.6",
"version": "2.12.7",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
2 changes: 1 addition & 1 deletion lineageweave/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,4 @@
"sentence_excerpts",
]

__version__ = "2.12.6"
__version__ = "2.12.7"
10 changes: 8 additions & 2 deletions lineageweave/tepp_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@

from __future__ import annotations

from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, Callable
from typing import Any


class TeppNotAvailable(RuntimeError):
Expand Down Expand Up @@ -80,4 +81,9 @@ def __init__(self, transport: Callable[[dict[str, Any]], dict[str, Any]] = _no_t

def submit_analysis_run(self, request: AnalysisRunRequest) -> dict[str, Any]:
"""Submit a request; returns TEPP's ``AnalysisRunAccepted`` envelope."""
return self._transport(request.to_json())
try:
return self._transport(request.to_json())
except TeppNotAvailable:
raise
except Exception as exc:
raise TeppNotAvailable("TEPP transport request failed") from exc
Comment on lines +84 to +89

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Broadened transport exception handling hides original error type

configured_tepp_client's transport now catches bare Exception and re-raises TeppNotAvailable("TEPP transport request failed") (analysis_run_start.py), and TeppClient.submit_analysis_run similarly wraps any non-TeppNotAvailable exception (tepp_client.py). This is intentional (tests assert provider error text is not exposed) and correctly re-raises TeppNotAvailable unchanged, but it also now swallows programming errors like KeyboardInterrupt-adjacent issues would not be caught since those are BaseException, so only Exception subclasses are converted — acceptable. Noting the widened catch changes which failures map to tepp_not_available in _tepp_submission.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

13 changes: 11 additions & 2 deletions migrations/0018_analysis_run_registry.sql
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,7 @@ declare
previous_status_code text;
previous_occurred_at timestamptz;
run_requested_at timestamptz;
database_now timestamptz;
begin
-- The immutable parent row is a per-run serialization lock. It prevents
-- concurrent writers from both accepting the same next ordinal.
Expand All @@ -492,7 +493,14 @@ begin
if new.occurred_at < run_requested_at then
raise exception 'analysis_run_status_before_request';
end if;
new.recorded_at := clock_timestamp();
-- Permit bounded client clock skew, but do not accept arbitrary future
-- events that would manufacture audit time. Do not clamp occurred_at
-- down: that would break monotonicity against Python-ahead events.
database_now := clock_timestamp();
if new.occurred_at > database_now + interval '1 minute' then
raise exception 'analysis_run_status_time_too_far_in_future';
end if;
new.recorded_at := greatest(database_now, new.occurred_at);

select status_ordinal, status_code, occurred_at
into previous_ordinal, previous_status_code, previous_occurred_at
Expand Down Expand Up @@ -541,7 +549,8 @@ $$;

comment on function enforce_analysis_run_status_transition() is
'Serializes status appends and requires immutable scope, request-time '
'ordering, database-recorded time, legal transitions, and terminal finality.';
'ordering, recorded time at least as late as occurrence, legal transitions, '
'and terminal finality.';

drop trigger if exists analysis_run_status_transition_guard
on analysis_run_status_event;
Expand Down
Loading
Loading