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
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ expose purge on a public HTTP route.
`POST /api/analysis-runs/{id}/start` reconstructs a Pending lineage
cutoff bag through `reconstruct()` / `lineage_edge_specs` (ADR 0021 /
v0.88.0). TEPP and period-report start stay 422. 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.

## CI gates

Expand Down
6 changes: 4 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -491,8 +491,10 @@ 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 still
shows the live body; titles rewritten after the run are marked
updated after cutoff. Status history is detail-only
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
5 changes: 5 additions & 0 deletions CHANGELOG.d/2.1.0-source-post-revision.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# 2.1.0 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.
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@ 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.1.0] - 2026-08-17

### 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 0025).

## [2.0.0] - 2026-08-17

### Added
Expand Down
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,10 @@ 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. Titles marked updated
after cutoff were rewritten after the run; compare those bodies
before treating them as reconstructed evidence (ADR 0016).
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 / 0025).
`POST /api/analysis-runs` records Pending on an authorized
cutoff capture (ADR 0017). `POST /api/analysis-runs/{id}/start`
commits Running plus a durable outbox row, then reconstructs that
Expand Down
29 changes: 27 additions & 2 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
deliver_queued_analysis_run,
enqueue_pending_analysis_run,
)
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 @@ -375,11 +376,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 0025). 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 @@ -391,7 +410,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 0025).

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),
}
57 changes: 54 additions & 3 deletions backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@
_OUTBOX_MIGRATION = (
Path(__file__).resolve().parents[2] / "migrations" / "0023_analysis_run_outbox.sql"
)
_REVISION_MIGRATION = (
Path(__file__).resolve().parents[2] / "migrations" / "0024_source_post_revision.sql"
)


def _postgres_available() -> bool:
Expand Down Expand Up @@ -129,6 +132,7 @@ def seeded_db(demo_analyst_token):
cur.execute(_RECONSTRUCTION_MIGRATION.read_text())
cur.execute(_SNAPSHOT_MEMBER_MIGRATION.read_text())
cur.execute(_OUTBOX_MIGRATION.read_text())
cur.execute(_REVISION_MIGRATION.read_text())
cur.execute(
"insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values "
"('corporate_entity_level', 'group', 'Group'), "
Expand Down Expand Up @@ -351,13 +355,21 @@ def _insert_post(
"A follow-up written after the January 2026 run cutoff.",
created_at="2026-01-20T12:00:00Z",
)
_insert_post(
edited_own_post_id = _insert_post(
"Edited own-corp private post",
own_corp_id,
"private",
"A January post rewritten after the run cutoff.",
"A January post before the rewrite.",
created_at="2026-01-10T12:00:00Z",
updated_at="2026-01-13T09:00:00Z",
updated_at="2026-01-10T12:00:00Z",
)
cur.execute(
"update source_post set post_body = %s, updated_at = %s where post_id = %s",
(
"A January post rewritten after the run cutoff.",
"2026-01-13T09:00:00Z",
edited_own_post_id,
),
)

cur.execute(
Expand Down Expand Up @@ -445,6 +457,7 @@ def _insert_post(
"other_corp_id": str(other_corp_id),
"own_private_post_id": own_private_post_id,
"late_own_private_post_id": late_own_private_post_id,
"edited_own_post_id": edited_own_post_id,
"other_private_post_id": other_private_post_id,
"our_person_id": our_person_id,
"counterpart_person_id": counterpart_person_id,
Expand Down Expand Up @@ -974,6 +987,44 @@ def test_post_detail_uses_lookup_labels_not_raw_codes(client, demo_analyst_token
assert body["visibility_label"] == "Public"


def test_post_detail_as_of_returns_the_cutoff_known_body(
client, demo_analyst_token, seeded_db
) -> None:
"""Opened marked titles compare two real sentences, not two clocks."""
headers = {"Authorization": f"Bearer {demo_analyst_token}"}
live = client.get(f"/api/posts/{seeded_db['edited_own_post_id']}", headers=headers)
assert live.status_code == 200
assert live.json()["post_body"] == "A January post rewritten after the run cutoff."
assert "known_at" not in live.json()

known = client.get(
f"/api/posts/{seeded_db['edited_own_post_id']}",
params={"as_of": "2026-01-12T12:00:00Z"},
headers=headers,
)
assert known.status_code == 200
body = known.json()
assert body["post_body"] == "A January post rewritten after the run cutoff."
assert body["known_at"]["post_body"] == "A January post before the rewrite."
assert body["known_at"]["written_at"].startswith("2026-01-10")
assert "postgresql://" not in str(body)

missing = client.get(
f"/api/posts/{seeded_db['edited_own_post_id']}",
params={"as_of": "2026-01-01T00:00:00Z"},
headers=headers,
)
assert missing.status_code == 200
assert "known_at" not in missing.json()

invalid = client.get(
f"/api/posts/{seeded_db['edited_own_post_id']}",
params={"as_of": "not-a-clock"},
headers=headers,
)
assert invalid.status_code == 422


def test_persisted_summary_is_returned_without_an_llm(client, demo_analyst_token, seeded_db) -> None:
"""GET /api/posts/{id}/summary must serve a stored row even when the
orchestrator is off -- otherwise a seeded demo popup stays empty.
Expand Down
1 change: 1 addition & 0 deletions docker/postgres-init/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ COPY migrations/0020_analysis_run_retention_purge.sql /docker-entrypoint-initdb.
COPY migrations/0021_analysis_run_reconstruction.sql /docker-entrypoint-initdb.d/22-analysis-run-reconstruction.sql
COPY migrations/0022_analysis_source_snapshot_member.sql /docker-entrypoint-initdb.d/23-analysis-source-snapshot-member.sql
COPY migrations/0023_analysis_run_outbox.sql /docker-entrypoint-initdb.d/24-analysis-run-outbox.sql
COPY migrations/0024_source_post_revision.sql /docker-entrypoint-initdb.d/25-source-post-revision.sql
# Official image already drops to this account at runtime; declare it so
# the Dockerfile itself satisfies DS-0002 (explicit non-root USER).
USER postgres
32 changes: 16 additions & 16 deletions docs/adr/0016-analysis-run-knowledge-cutoff-posts.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,14 @@ account can see today."
`fetch_visible_scope_posts` filters `created_at <= knowledge_cutoff` on
every scope branch (corporate entity, process unit, thread group, and
all-visible). ABAC visibility is applied after that temporal gate.
Click-through still opens the live post body -- post versioning is a
later slice -- but the run list itself must not advertise a post the
run was not allowed to know. Detail compares the live `updated_at`
write clock with `knowledge_cutoff` and marks titles rewritten after
the run. Opening a marked title shows a popup status that the body is
live and must be compared with this run; the earlier text is not
stored, so the popup does not invent it. The next action is specific:
only those marked titles need a cutoff comparison before treating the
live body as reconstructed evidence.
Click-through still opens the live post body. Detail compares the live
`updated_at` write clock with `knowledge_cutoff` and marks titles
rewritten after the run. Opening a marked title shows the stored
cutoff-known body (`GET /api/posts/{id}?as_of=`, ADR 0025) beside the
live rewrite. A missing revision is omitted -- never an invented
earlier sentence. The next action is specific: only those marked
titles need a cutoff comparison before treating the live body as
reconstructed evidence.

Reproducibility digests on the same detail use a labeled group whose
accessible name does not replace the visible prefixes (W3C Accessible
Expand All @@ -49,15 +48,16 @@ run.
and other in-cutoff Demo Corp titles. The later fixture account-review
post (2026-02-10) does not appear.
- Open the run: Demo public post is marked updated after cutoff
(`updated_at` 2026-01-13). Demo private post is not. Opening the
marked title shows a live-body status; the private title and the
home post list do not.
(`updated_at` 2026-01-13). Demo private post is not.
- Open a marked title: the popup shows **Body this run knew** from
`source_post_revision` and the live rewrite. Compare those two texts
before treating the live body as reconstructed evidence (ADR 0025).
- Hover a digest prefix to read the full code or configuration digest
when you need to match the API payload.
- Post-body versioning at the cutoff remains future work. The write
clock is a projection, not a stored cutoff body. The popup tells
the operator to compare the live body with this run instead of
inventing the earlier text.
- Migration 0024 (ADR 0025) stores each rewrite on
`source_post_revision` so the opened post can show the cutoff-known
body without putting that body on the analysis-run payload. The write
clock remains a projection on `source_post.updated_at`.
- Thread-group *run list* visibility now uses the same cutoff
(ADR 0018). A later public post cannot surface a previously hidden
thread-group run.
Expand Down
Loading