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
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,9 @@ settings on the signed-out login shell.
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). Status events stamp `occurred_at` and `recorded_at` from
one PostgreSQL `clock_timestamp()` (ADR 0171 / v2.12.19). Do not
bind Python `datetime.now` as occurrence. Do not invent a theta.
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
9 changes: 9 additions & 0 deletions CHANGELOG.d/2.12.19-analysis-run-status-same-clock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
## 2.12.19 — Analysis-run status same PostgreSQL clock

- Status events now stamp `occurred_at` and `recorded_at` from one
PostgreSQL `clock_timestamp()` (ADR 0171). The transition trigger
still records database time and raises `recorded_at` when a caller
clock is already ahead, so `analysis_run_status_time_check` no
longer rejects Start on a 15–20 ms Python-vs-PostgreSQL skew.
After `make seed`, Open the Demo Corp lineage run and Start still
recovers the designed A-100 fork. Never invent a theta.
21 changes: 17 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,19 @@ All notable changes to this project are documented here. Format follows
shell. The production frontend build type-checks again.


## [2.12.19] - 2026-08-24

### Fixed
Comment on lines +216 to +218

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Duplicate 2.12.19 changelog section

A second ## [2.12.19] - 2026-08-24 heading is added while an identical one already exists for the Log in fix (CHANGELOG.md). One version now has two separate release sections with the same date. The new note belongs in the existing section's ### Fixed list.

Prompt for agents
CHANGELOG.md contains two consecutive release sections both headed `## [2.12.19] - 2026-08-24`: one at line 206 (the Log in / OIDC return-path fix) and the new one added at line 216 (the analysis-run same-clock fix). Having the same version appear as two separate sections is malformed. Merge the new bullet into the existing 2.12.19 section's `### Fixed` list and remove the duplicate `## [2.12.19] - 2026-08-24` and `### Fixed` heading.
Open in Devin Review

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


- Starting a Pending lineage reconstruction or TEPP measurement no
longer fails on `analysis_run_status_time_check` when the process
clock is 15–20 ms ahead of PostgreSQL (ADR 0171). Status events
stamp `occurred_at` and `recorded_at` from one `clock_timestamp()`,
and the transition trigger raises `recorded_at` if a caller
occurrence is already ahead. After `make seed`, Open the Demo Corp
lineage run and Start still recovers the designed A-100 fork.
Never invent a theta.

## [2.12.6] - 2026-08-20

### Added
Expand Down Expand Up @@ -269,10 +282,10 @@ All notable changes to this project are documented here. Format follows
concurrency/load artifact) and entirely pre-existing (verified via
`git diff` that no file in this change touches
`analysis_run_start.py` or the 0018 migration that defines this
constraint). Root cause not yet conclusively identified; deferred
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.
constraint). Root cause identified in ADR 0171 / v2.12.19: the
supplied occurrence can sit ahead of the trigger write clock, so
`recorded_at` must share that clock or be raised to `occurred_at`.
Fixed in [2.12.19](#21219---2026-08-24). 553 other tests unaffected.

- `get_or_create_corporate_entity`'s post-lock duplicate-create re-check
fuzzy-matched against every cataloged entity, not just an exact
Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ 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
`tepp_client` (ADR 0022). Status events share one PostgreSQL
`clock_timestamp()` for `occurred_at` and `recorded_at` (ADR 0171). 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 row. Do not invent a theta. Hover the Result prefix to read
Expand Down
6 changes: 4 additions & 2 deletions backend/app/analysis_run_ingestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -947,8 +947,10 @@ async def create_pending_analysis_run(
await conn.execute(
"""
insert into analysis_run_status_event
(analysis_run_id, status_ordinal, status_code, occurred_at)
values ($1, 1, 'analysis_status_pending', clock_timestamp())
(analysis_run_id, status_ordinal, status_code,
occurred_at, recorded_at)
select $1, 1, 'analysis_status_pending', write_clock, write_clock
from (select clock_timestamp() as write_clock) same_clock
""",
run_id,
)
Expand Down
21 changes: 10 additions & 11 deletions backend/app/analysis_run_start.py
Comment thread
seonghobae marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -326,15 +326,21 @@ async def _append_status(
analysis_run_id: str,
status_ordinal: int,
status_code: str,
occurred_at: datetime,
failure_code: str | None = None,
) -> None:
"""Append one legal lifecycle event. Failed rows carry a machine code."""
"""Append one legal lifecycle event. Failed rows carry a machine code.

Occurrence and recording share one PostgreSQL ``clock_timestamp()``
so ``analysis_run_status_time_check`` cannot see a Python clock that
is ahead of the trigger write clock (ADR 0171).
"""
await conn.execute(
"""
insert into analysis_run_status_event
(analysis_run_id, status_ordinal, status_code, occurred_at, failure_code)
values ($1, $2, $3, clock_timestamp(), $4)
(analysis_run_id, status_ordinal, status_code,
occurred_at, recorded_at, failure_code)
select $1, $2, $3, write_clock, write_clock, $4
from (select clock_timestamp() as write_clock) same_clock
Comment thread
seonghobae marked this conversation as resolved.
""",
analysis_run_id,
status_ordinal,
Comment on lines 337 to 346

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: Status-event monotonicity preserved by uniform DB clock

The transition trigger raises recorded_at to occurrence but still enforces monotonic occurred_at against the prior event. Mixing a Python-ahead pending occurrence with a clock_timestamp() running occurrence could trip analysis_run_status_time_not_monotonic. This is avoided: both create_pending_analysis_run (analysis_run_ingestion.py) and _append_status now stamp occurrence from clock_timestamp(), which advances across statements, keeping occurrence monotonic.

(Refers to this code)

Open in Devin Review

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

Expand Down Expand Up @@ -570,7 +576,6 @@ async def enqueue_pending_analysis_run(
analysis_run_id,
await _next_status_ordinal(conn, analysis_run_id),
_RUNNING,
now,
)
await conn.execute(
"""
Expand Down Expand Up @@ -789,7 +794,6 @@ async def _deliver_lineage_reconstruction(
analysis_run_id,
await _next_status_ordinal(conn, analysis_run_id),
_SUCCEEDED,
finished,
)


Expand All @@ -801,7 +805,6 @@ 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)
request = tepp_run_request(
idempotency_key=str(locked["idempotency_key"]),
snapshot_sha256=str(locked["snapshot_sha256"]),
Expand All @@ -817,14 +820,10 @@ async def _deliver_tepp_measurement(
):
status_code = _FAILED
failure_code = "tepp_result_not_persisted"
finished = datetime.now(timezone.utc)
if finished < now:
finished = now
await _append_status(
conn,
analysis_run_id,
await _next_status_ordinal(conn, analysis_run_id),
status_code,
finished,
failure_code,
)
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_*|0103_*|0104_*|0105_*|0106_*|0107_*|0108_*|0109_*|0110_*|0111_*|0112_*|0113_*|0114_*|0130_*|0133_*|0134_*|0136_*|0137_*|0138_*|0139_*|0176_*) ;;
0060_*|0100_*|0101_*|0102_*|0103_*|0104_*|0105_*|0106_*|0107_*|0108_*|0109_*|0110_*|0111_*|0112_*|0113_*|0114_*|0130_*|0133_*|0134_*|0136_*|0137_*|0138_*|0139_*|0173_*|0176_*) ;;
*) continue ;;
esac
printf 'Applying %s\n' "$migration_name"
Expand Down
4 changes: 3 additions & 1 deletion docs/adr/0013-normalized-analysis-run-registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,9 @@ 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`.
time on every insert and cannot precede `occurred_at`. When a caller supplies
an occurrence already ahead of that write clock, `recorded_at` is raised to
`occurred_at` so the check holds without rewriting occurrence (ADR 0171).
`analysis_run_current_status` is a view, not a second mutable state authority.

### Authorization scope
Expand Down
68 changes: 68 additions & 0 deletions docs/adr/0171-analysis-run-status-same-clock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# ADR 0171 — Analysis-run status events share one PostgreSQL write clock

**Decision status:** Accepted
**Date:** 2026-08-24

Amends [ADR 0013](0013-normalized-analysis-run-registry.md). Independent of
leftover-map persist, leftover UI, leftover two-axis distance, TEPP
arithmetic, and Valkey outbox payload shape.

## Context

ADR 0013 distinguishes lifecycle `occurred_at` from durable
`recorded_at` and requires `occurred_at <= recorded_at`. The 0018
trigger overwrote `recorded_at` with `clock_timestamp()` after a
`FOR UPDATE`. Callers that bound Python `datetime.now(timezone.utc)`
as `occurred_at` reproducibly landed 15–20 ms *after* that write
clock, so `analysis_run_status_time_check` rejected the row
(`CheckViolationError`). Live
`test_start_analysis_run_recovers_the_a100_fork` and
`test_tepp_start_persists_published_accepted_evidence` then could not
Start a Pending lineage or TEPP run against a real PostgreSQL.

The product start path later switched `occurred_at` to
`clock_timestamp()` while still omitting `recorded_at` (DEFAULT plus
trigger). That still leaves a two-clock window: VALUES vs DEFAULT vs
trigger each call `clock_timestamp()` separately, and any remaining
Python-ahead caller (seed, live test helper, or a future bind) fails
the same check.

## Decision

1. Application inserts of `analysis_run_status_event` stamp
`occurred_at` and `recorded_at` from **one** PostgreSQL
`clock_timestamp()` (a `SELECT ... FROM (SELECT clock_timestamp()
AS write_clock)` row). Do not bind Python `datetime.now` as
occurrence.
2. `enforce_analysis_run_status_transition` still overwrites
`recorded_at` with database `clock_timestamp()`. If the supplied
`occurred_at` is already ahead of that clock, raise `recorded_at`
to `occurred_at` so the check holds. Do not rewrite occurrence:
monotonicity and "cannot predate the request" stay on the
caller-supplied instant.
3. Migration 0173 is the single source of the trigger replacement; shipped
migration 0018 remains immutable. The Compose migration service applies
0173 after the initial schema on fresh installs and replays it on existing
volumes.

Do not invent a leftover score. Do not invent a theta.

## Consequences

Starting a Pending lineage reconstruction or TEPP measurement no
longer fails closed on a 15–20 ms Python-vs-PostgreSQL skew. After
`make seed`, Open the Demo Corp lineage run and Start still recovers
the designed A-100 fork. A synthetic insert whose `occurred_at` is
50 ms ahead of `clock_timestamp()` persists with
`recorded_at >= occurred_at`.

## References

Allen, J. F. (1983). Maintaining knowledge about temporal intervals.
*Communications of the ACM, 26*(11), 832–843.
https://doi.org/10.1145/182.358434

Lebo, T., Sahoo, S., McGuinness, D., Belhajjame, K., Cheney, J.,
Corsar, D., Garijo, D., Soiland-Reyes, S., Zednik, S., & Zhao, J.
(2013). *PROV-O: The PROV ontology* (W3C Recommendation). World Wide
Web Consortium. https://www.w3.org/TR/prov-o/
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. Product inserts stamp both from one PostgreSQL
`clock_timestamp()`; the transition trigger raises `recorded_at` when a
caller occurrence is already ahead (ADR 0171).

The database requires the aggregate leakage boundary:

Expand Down
2 changes: 2 additions & 0 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ describe("App, unauthenticated", () => {
});

it("shows a login button that starts the real OIDC redirect", async () => {
window.sessionStorage.clear();
render(<App showLabPanels />);
const button = screen.getByRole("button", { name: /log in/i });
await userEvent.click(button);
Expand All @@ -55,6 +56,7 @@ describe("App, unauthenticated", () => {
state: expect.objectContaining({ returnUrl: expect.stringMatching(/^\//) }),
}),
);
expect(window.sessionStorage.getItem("lineageweave.oidc.returnUrl")).toBe("/");
});

it("remembers a same-origin post deep link before the OIDC redirect", async () => {
Expand Down
99 changes: 99 additions & 0 deletions migrations/0173_analysis_run_status_same_clock.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
-- Analysis-run status events share one PostgreSQL write clock (ADR 0171).
--
-- Replaces enforce_analysis_run_status_transition so recorded_at cannot
-- precede occurred_at when a caller supplies a clock that is slightly
-- ahead of PostgreSQL (the live CheckViolationError on
-- analysis_run_status_time_check). Occurrence is not rewritten; the
-- durable write clock is raised to the supplied occurrence when needed.
-- Idempotent migration 0173: create or replace. Does not invent a theta.

create or replace function enforce_analysis_run_status_transition()
returns trigger
language plpgsql
as $$
declare
previous_ordinal integer;
previous_status_code text;
previous_occurred_at timestamptz;
run_requested_at timestamptz;
write_clock timestamptz;
begin
-- The immutable parent row is a per-run serialization lock. It prevents
-- concurrent writers from both accepting the same next ordinal.
select requested_at
into run_requested_at
from analysis_run
where analysis_run_id = new.analysis_run_id
for update;

if not found then
raise exception 'analysis_run_not_found';
end if;
if not exists (
select 1 from analysis_run_scope
where analysis_run_id = new.analysis_run_id
) then
raise exception 'analysis_run_scope_required';
end if;
if new.occurred_at < run_requested_at then
raise exception 'analysis_run_status_before_request';
end if;
-- One database clock for the durable write. If the supplied occurrence
-- is already ahead of that clock (Python datetime.now skew), raise
-- recorded_at to occurred_at so analysis_run_status_time_check holds
-- without rewriting occurrence or breaking monotonicity.
write_clock := clock_timestamp();
new.recorded_at := write_clock;
if new.recorded_at < new.occurred_at then
new.recorded_at := new.occurred_at;
end if;
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

select status_ordinal, status_code, occurred_at
into previous_ordinal, previous_status_code, previous_occurred_at
from analysis_run_status_event
where analysis_run_id = new.analysis_run_id
order by status_ordinal desc
limit 1;

if previous_ordinal is null then
if new.status_ordinal <> 1
or new.status_code <> 'analysis_status_pending' then
raise exception 'analysis_run_first_status_must_be_pending';
end if;
return new;
end if;

if new.status_ordinal <> previous_ordinal + 1 then
raise exception 'analysis_run_status_ordinal_not_contiguous';
end if;
if new.occurred_at < previous_occurred_at then
raise exception 'analysis_run_status_time_not_monotonic';
end if;

if previous_status_code = 'analysis_status_pending' then
if new.status_code not in (
'analysis_status_running',
'analysis_status_cancelled'
) then
raise exception 'analysis_run_status_transition_invalid';
end if;
elsif previous_status_code = 'analysis_status_running' then
if new.status_code not in (
'analysis_status_succeeded',
'analysis_status_failed',
'analysis_status_cancelled'
) then
raise exception 'analysis_run_status_transition_invalid';
end if;
else
raise exception 'analysis_run_terminal_status_has_no_successor';
end if;

return new;
end
$$;

comment on function enforce_analysis_run_status_transition() is
'Serializes status appends and requires immutable scope, request-time '
'ordering, database-recorded time that cannot precede occurrence, '
'legal transitions, and terminal finality.';
Loading
Loading