Skip to content
Closed
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
7 changes: 5 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -482,8 +482,11 @@ unavailable, so that run is Failed rather than a fabricated score.
The home list is clickable: `GET /api/analysis-runs/{id}` fills a
labeled detail (cutoff, requested date, 12-character digest prefixes
with full digests on hover, counts, status history)
without exposing a DSN or raw record. Opening a cutoff title warns
that the live body may have changed after the run. Status history is detail-only
without exposing a DSN or raw record. Opening a cutoff title still
shows the live body and names both clocks when the title was
rewritten after the run. A marked title also shows the body that
run knew (`GET /api/posts/{id}?as_of=`) so the operator can compare
two texts, not two clocks. Status history is detail-only
and uses lookup labels plus occurrence times; a failure event keeps
its machine `failure_code` rather than an invented caption. Failed
TEPP list rows add a next-action line (open the run, then connect the
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.d/0.87.1-analysis-run-live-write-clock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# 0.87.1 Analysis-run live write clock

In-cutoff titles now say whether the live row was rewritten after the
run. Open Demo public post as the edited counter-example; Demo private
post still matches the January cutoff. The opened live body names both
clocks. Bodies stay live.
5 changes: 5 additions & 0 deletions CHANGELOG.d/0.87.2-source-post-revision.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# 0.87.2 Source-post revision at cutoff

Open a marked Demo public post: the January sentence is **Body this run
knew**; the live body is the later delivery window. Compare those two
texts. Analysis-run detail still has no post body.
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,30 @@ 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).

## [0.87.2] - 2026-08-16

### Added

- Opening a title marked **Updated after cutoff** now shows the body
that run knew beside the live rewrite. After `make seed`, open Demo
public post from the Demo Corp lineage run: **Body this run knew** is
the January follow-up; the live body names the later delivery window.
`GET /api/posts/{id}?as_of=` reads `source_post_revision`. Analysis-run
detail stays titles and clocks. A missing revision is omitted — never
a fabricated cutoff sentence or a TEPP theta (ADR 0022).

## [0.87.1] - 2026-08-16

### Added

- Analysis-run detail now compares each in-cutoff title's live
`updated_at` with that run's knowledge cutoff. After `make seed`,
open the Demo Corp lineage run: Demo public post is marked
**Updated after cutoff**; Demo private post is not. Opening a
marked title still shows the live body and names both clocks.
Cutoff body versioning stays a later slice (ADR 0016). The list
stays aggregates-only. No TEPP theta is invented.

## [0.87.0] - 2026-08-16

### Added
Expand Down
7 changes: 5 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ mention TEPP. A failed period-report row rebuilds the report. A
pending TEPP row does not claim a calibrated measurement. A pending
lineage row says reconstruction has not started yet.
Digest prefixes stay audible; hover a prefix to read the full digest.
Opening a cutoff title shows the live post -- compare it with the
cutoff before treating the body as reconstructed evidence (ADR 0016).
Opening a cutoff title shows the live post. Titles marked updated
after cutoff were rewritten after the run; the opened body names
both clocks and shows **Body this run knew** beside the live
rewrite. Compare those two texts before treating the live body as
reconstructed evidence (ADR 0016 / 0021 / 0022).
`POST /api/analysis-runs` records Pending on an authorized
cutoff capture (ADR 0017) and does not reconstruct lineage.
46 changes: 39 additions & 7 deletions backend/app/analysis_run_ingestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,22 @@ def _iso(value: Any) -> str:
return value.isoformat() if hasattr(value, "isoformat") else str(value)


def _as_utc(value: datetime) -> datetime:
"""Treat a naive clock as UTC so cutoff comparison stays timezone-aware."""
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)


def live_write_after_cutoff(updated_at: datetime, knowledge_cutoff: datetime) -> bool:
"""True when the live row was rewritten after the run's analysis clock.

``created_at <= knowledge_cutoff`` admits the title. ``updated_at`` is
the live write clock (ADR 0016). Equal times stay in-cutoff evidence.
"""
return _as_utc(updated_at) > _as_utc(knowledge_cutoff)


async def _counts_by_run(
conn: asyncpg.Connection,
run_ids: list[str],
Expand Down Expand Up @@ -258,15 +274,21 @@ async def fetch_visible_scope_posts(
scope_key: str | None,
affiliated_entity_ids: list[str],
knowledge_cutoff: Any,
) -> list[dict[str, str]]:
) -> list[dict[str, Any]]:
"""ABAC-visible post titles known at the run cutoff -- never a hidden body.

``knowledge_cutoff`` is the analysis clock (W3C Time / ISO 8601-1:2019;
ADR 0013/0016). A later live post must not appear inside an earlier run.
``updated_at`` is compared separately so the operator can see which
in-cutoff titles were rewritten after that clock. The live body is
still not returned.
"""
columns = (
"post_id, post_title, visibility_code, corporate_entity_id, updated_at"
)
if scope_kind_code == "analysis_scope_corporate_entity" and corporate_entity_id:
rows = await conn.fetch(
"select post_id, post_title, visibility_code, corporate_entity_id "
f"select {columns} "
"from source_post where corporate_entity_id = $1 "
"and created_at <= $2 "
"order by created_at, post_title",
Expand All @@ -275,7 +297,7 @@ async def fetch_visible_scope_posts(
)
elif scope_kind_code == "analysis_scope_process_unit" and process_unit_id:
rows = await conn.fetch(
"select post_id, post_title, visibility_code, corporate_entity_id "
f"select {columns} "
"from source_post where process_unit_id = $1 "
"and created_at <= $2 "
"order by created_at, post_title",
Expand All @@ -284,7 +306,7 @@ async def fetch_visible_scope_posts(
)
elif scope_kind_code == "analysis_scope_thread_group" and scope_key:
rows = await conn.fetch(
"select post_id, post_title, visibility_code, corporate_entity_id "
f"select {columns} "
"from source_post where thread_group_key = $1 "
"and created_at <= $2 "
"order by created_at, post_title",
Expand All @@ -293,20 +315,30 @@ async def fetch_visible_scope_posts(
)
elif scope_kind_code == "analysis_scope_all_visible":
rows = await conn.fetch(
"select post_id, post_title, visibility_code, corporate_entity_id "
f"select {columns} "
"from source_post where created_at <= $1 "
"order by created_at, post_title",
knowledge_cutoff,
)
else:
return []
affiliated = {str(entity_id) for entity_id in affiliated_entity_ids}
posts: list[dict[str, str]] = []
posts: list[dict[str, Any]] = []
for row in rows:
visible = row["visibility_code"] == "public" or str(row["corporate_entity_id"]) in affiliated
if not visible:
continue
posts.append({"post_id": str(row["post_id"]), "post_title": row["post_title"]})
updated_at = row["updated_at"]
posts.append(
{
"post_id": str(row["post_id"]),
"post_title": row["post_title"],
"updated_at": _iso(updated_at),
"live_after_cutoff": live_write_after_cutoff(
updated_at, knowledge_cutoff
),
}
)
return posts


Expand Down
29 changes: 27 additions & 2 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
fetch_visible_analysis_run,
fetch_visible_analysis_runs,
)
from backend.app.source_post_revision import fetch_known_at_revision, parse_as_of_clock
from backend.app.activity_stream import (
create_valkey_client,
get_valkey,
Expand Down Expand Up @@ -368,11 +369,29 @@ async def list_posts(
@app.get("/api/posts/{post_id}")
async def read_post(
post_id: str,
as_of: str | None = None,
account: CurrentAccount = Depends(get_current_account),
pool: asyncpg.Pool = Depends(get_pool),
) -> dict[str, Any]:
"""Return one source_post, or 404 / 403 if it is missing or out of scope."""
"""Return one source_post, or 404 / 403 if it is missing or out of scope.

``as_of`` adds ``known_at`` when a ``source_post_revision`` covers that
clock (ADR 0022). The live ``post_body`` stays the live row. A missing
cover is omitted -- never a fabricated cutoff sentence. Next action:
pass the analysis-run cutoff, then compare ``known_at`` with the live
body before treating the live text as reconstructed evidence.
"""
_require_post_read(account)
as_of_clock = None
if as_of is not None:
try:
as_of_clock = parse_as_of_clock(as_of)
except ValueError as exc:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
"as_of must be an ISO-8601 timestamp. Use the run cutoff, "
"then compare the known body with the live body.",
) from exc
async with pool.acquire() as conn:
row = await conn.fetchrow(
"select post_id, post_title, post_body, voc_type_code, visibility_code, corporate_entity_id, created_at "
Expand All @@ -384,7 +403,13 @@ async def read_post(
if not _can_see_post(account, row):
raise HTTPException(status.HTTP_403_FORBIDDEN, "not authorized to view this post")
labels = await _lookup_post_labels(conn, [row])
return {**_serialize_post(row, labels), "post_body": row["post_body"]}
known_at = None
if as_of_clock is not None:
known_at = await fetch_known_at_revision(conn, post_id, as_of_clock)
payload = {**_serialize_post(row, labels), "post_body": row["post_body"]}
if known_at is not None:
payload["known_at"] = known_at
return payload


async def _load_visible_post(
Expand Down
92 changes: 92 additions & 0 deletions backend/app/source_post_revision.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""Source-post valid-time revisions for cutoff-known bodies (ADR 0022).

The analysis-run registry stays aggregates-only. Callers that need the
sentence a run knew must read ``source_post_revision`` through an
authorized post fetch with ``as_of``. A missing cover is omitted --
never a fabricated cutoff body or a TEPP theta.
"""

from __future__ import annotations

from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
import asyncpg


def _as_utc(value: datetime) -> datetime:
"""Treat a naive clock as UTC so interval tests stay timezone-aware."""
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)


def parse_as_of_clock(value: str) -> datetime:
"""Parse an ISO-8601 as-of clock.

Next action: pass the analysis-run cutoff, then compare ``known_at``
with the live body. Empty or unparseable values raise ``ValueError``.
"""
text = value.strip()
if not text:
raise ValueError("as_of is empty")
if text.endswith("Z"):
text = text[:-1] + "+00:00"
parsed = datetime.fromisoformat(text)
return _as_utc(parsed)


def revision_covers_clock(
written_at: datetime,
superseded_at: datetime | None,
as_of: datetime,
) -> bool:
"""True when this revision was current at ``as_of``.

The interval is half-open: ``written_at <= as_of < superseded_at``.
A null ``superseded_at`` means the revision is still current.
"""
start = _as_utc(written_at)
clock = _as_utc(as_of)
if start > clock:
return False
if superseded_at is None:
return True
return _as_utc(superseded_at) > clock


def _iso(value: Any) -> str:
"""Serialize a timestamptz the same way post detail already does."""
return value.isoformat() if hasattr(value, "isoformat") else str(value)


async def fetch_known_at_revision(
conn: "asyncpg.Connection",
post_id: str,
as_of: datetime,
) -> dict[str, str] | None:
"""Return the title/body current at ``as_of``, or None when none exists.

Does not invent a sentence. Does not return a live body under a
cutoff label when no revision covers the clock.
"""
row = await conn.fetchrow(
"select post_title, post_body, written_at "
"from source_post_revision "
"where post_id = $1 "
"and written_at <= $2 "
"and (superseded_at is null or superseded_at > $2) "
"order by written_at desc "
"limit 1",
post_id,
as_of,
)
if row is None:
return None
return {
"post_title": row["post_title"],
"post_body": row["post_body"],
"written_at": _iso(row["written_at"]),
"as_of": _iso(as_of),
}
Loading