From 516f49f70de190e8dd42c47e8fc0918bee82f8a9 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 17 Aug 2026 08:35:17 +0000
Subject: [PATCH] feat: show cutoff-known post body beside the live rewrite
(v2.1.0)
Open a marked Demo public post from the Demo Corp lineage run and the
January follow-up sits under Body this run knew; the live body names
the later delivery window. GET /api/posts/{id}?as_of= reads
source_post_revision. A missing cover is omitted. Do not invent a
cutoff sentence or a TEPP theta.
---
AGENTS.md | 3 +
ARCHITECTURE.md | 6 +-
CHANGELOG.d/2.1.0-source-post-revision.md | 5 +
CHANGELOG.md | 12 +++
CLAUDE.md | 6 +-
backend/app/main.py | 29 +++++-
backend/app/source_post_revision.py | 92 +++++++++++++++++++
backend/tests/test_api.py | 57 +++++++++++-
docker/postgres-init/Dockerfile | 1 +
...016-analysis-run-knowledge-cutoff-posts.md | 32 +++----
docs/adr/0025-source-post-revision.md | 61 ++++++++++++
.../ANALYSIS_RUN_REGISTRY_REFERENCES.md | 6 +-
docs/doctoring/DESIGN_TOKEN_REFERENCES.md | 2 +-
.../SOURCE_POST_REVISION_REFERENCES.md | 58 ++++++++++++
docs/storybook-inventory.md | 1 +
frontend/package.json | 2 +-
frontend/src/App.css | 12 +++
frontend/src/App.test.tsx | 19 +++-
frontend/src/App.tsx | 27 ++++--
frontend/src/api.ts | 17 +++-
.../components/CutoffKnownBody.stories.tsx | 19 ++++
.../src/components/CutoffKnownBody.test.tsx | 22 +++++
frontend/src/components/CutoffKnownBody.tsx | 40 ++++++++
frontend/src/styles/tokens.css | 3 +
lineageweave/__init__.py | 2 +-
migrations/0024_source_post_revision.sql | 83 +++++++++++++++++
.../rollback/0024_source_post_revision.sql | 10 ++
pyproject.toml | 2 +-
scripts/seed_demo_data.py | 75 +++++++++++----
...test_analysis_run_reconstruction_schema.py | 1 +
tests/test_analysis_run_registry_schema.py | 4 +
tests/test_source_post_revision.py | 58 ++++++++++++
uv.lock | 2 +-
33 files changed, 708 insertions(+), 61 deletions(-)
create mode 100644 CHANGELOG.d/2.1.0-source-post-revision.md
create mode 100644 backend/app/source_post_revision.py
create mode 100644 docs/adr/0025-source-post-revision.md
create mode 100644 docs/doctoring/SOURCE_POST_REVISION_REFERENCES.md
create mode 100644 frontend/src/components/CutoffKnownBody.stories.tsx
create mode 100644 frontend/src/components/CutoffKnownBody.test.tsx
create mode 100644 frontend/src/components/CutoffKnownBody.tsx
create mode 100644 migrations/0024_source_post_revision.sql
create mode 100644 migrations/rollback/0024_source_post_revision.sql
create mode 100644 tests/test_source_post_revision.py
diff --git a/AGENTS.md b/AGENTS.md
index cebc790a..f1891950 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index a7f6dfeb..0f2756c1 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -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
diff --git a/CHANGELOG.d/2.1.0-source-post-revision.md b/CHANGELOG.d/2.1.0-source-post-revision.md
new file mode 100644
index 00000000..79b50c76
--- /dev/null
+++ b/CHANGELOG.d/2.1.0-source-post-revision.md
@@ -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.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 735156c4..db129f0e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/CLAUDE.md b/CLAUDE.md
index 096852a4..6ead8380 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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
diff --git a/backend/app/main.py b/backend/app/main.py
index 55576ca1..45c51708 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -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,
@@ -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 "
@@ -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(
diff --git a/backend/app/source_post_revision.py b/backend/app/source_post_revision.py
new file mode 100644
index 00000000..489f6f48
--- /dev/null
+++ b/backend/app/source_post_revision.py
@@ -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),
+ }
diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py
index 3d08b865..23726950 100644
--- a/backend/tests/test_api.py
+++ b/backend/tests/test_api.py
@@ -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:
@@ -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'), "
@@ -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(
@@ -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,
@@ -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.
diff --git a/docker/postgres-init/Dockerfile b/docker/postgres-init/Dockerfile
index 2e016a60..82e679d8 100644
--- a/docker/postgres-init/Dockerfile
+++ b/docker/postgres-init/Dockerfile
@@ -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
diff --git a/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md
index 0a550b86..373c783a 100644
--- a/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md
+++ b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md
@@ -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
@@ -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.
diff --git a/docs/adr/0025-source-post-revision.md b/docs/adr/0025-source-post-revision.md
new file mode 100644
index 00000000..b70aebfa
--- /dev/null
+++ b/docs/adr/0025-source-post-revision.md
@@ -0,0 +1,61 @@
+# ADR 0025 — Source-post revisions keep the cutoff-known body
+
+**Decision status:** Accepted
+**Date:** 2026-08-17
+
+## Context
+
+ADR 0016 marks in-cutoff titles whose live `updated_at` is after
+`analysis_run.knowledge_cutoff`. After `make seed`, Demo public post was
+marked rewritten while the live sentence stayed the January text, so the
+operator was told to compare bodies and was given two clocks, not two
+texts.
+
+The analysis-run registry must not store raw posts (ADR 0013). A missing
+cutoff body and a confidently-reconstructed body are different things:
+do not invent the earlier sentence on the run detail.
+
+W3C PROV-O `wasRevisionOf` (Moreau & Missier, 2013), W3C Time Ontology
+in OWL (World Wide Web Consortium, 2022), and temporal valid-time
+intervals (Jensen & Snodgrass, 1999) keep the write history on the
+source row, half-open `[written_at, superseded_at)`.
+
+Migration 0021 is reconstruction, 0022 is snapshot membership, 0023 is
+the start outbox, and ADR 0024 seeds the period-report run. This is the
+next free slot.
+
+## Decision
+
+Migration `0024_source_post_revision.sql` adds `source_post_revision`
+(3NF: one post, one title/body pair, one valid-time interval). A trigger
+records a revision on insert and on title or body rewrite. Clock-only
+updates do not pretend to be a rewrite.
+
+`GET /api/posts/{id}?as_of=` returns `known_at` when a revision covers
+that clock. The live `post_body` stays the live row. A missing cover is
+omitted. Analysis-run detail stays titles and clocks.
+
+`make seed` writes the January Demo public sentence, then rewrites it on
+2026-01-13 so the opened marked title shows both texts.
+
+## Consequences
+
+- After `make seed`, open **Lineage reconstruction · Succeeded · Demo
+ Corp**, then Demo public post: **Body this run knew** is the January
+ follow-up; the live body names the later delivery window.
+- Demo private post stays unmarked and has no second text.
+- Roll back `0024` before `0023` / `0022` / `0021` / `0020` / `0018`.
+- TEPP stays behind `tepp_client`. This write does not invent a theta.
+
+## References
+
+Jensen, C. S., & Snodgrass, R. T. (1999). Temporal data management.
+*IEEE Transactions on Knowledge and Data Engineering, 11*(1), 36–44.
+https://doi.org/10.1109/69.755613
+
+Moreau, L., & Missier, P. (Eds.). (2013). *PROV-O: The PROV ontology*
+(W3C Recommendation). World Wide Web Consortium.
+https://www.w3.org/TR/prov-o/
+
+World Wide Web Consortium. (2022). *Time ontology in OWL* (W3C
+Recommendation). https://www.w3.org/TR/owl-time/
diff --git a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md
index 11e4871f..e1951c9d 100644
--- a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md
+++ b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md
@@ -1,15 +1,15 @@
# Analysis-run registry standards and research traceability
**Status:** Active PR evidence; not protected-main truth until merge.
-**Scope:** Migrations 0018–0023, ADR 0013 / 0017 / 0020 / 0021 / 0022 /
-0023 / 0024, rollback, and real-PostgreSQL contract tests.
+**Scope:** Migrations 0018–0024, ADR 0013 / 0017 / 0020 / 0021 / 0022 /
+0023 / 0024 / 0025, rollback, and real-PostgreSQL contract tests.
## Standards mapped to implementation
| Source | Product implication | Implemented evidence |
|---|---|---|
| W3C PROV-DM and PROV-O | Preserve identifiable entities, activities, agents, generation/use, and derivation without flattening provenance into display-only edges. | `analysis_source_snapshot`, `analysis_run`, authenticated requester, append-only status events, immutable digests; later product bindings continue to use the separate `provenance_*` layer from ADR 0011. |
-| W3C Time Ontology in OWL | Keep temporal concepts explicit and avoid collapsing distinct clocks. | Evidence availability and snapshot capture remain on `analysis_source_snapshot`; analysis knowledge cutoff and request time remain on `analysis_run`; status occurrence and database record time remain distinct. `GET /api/analysis-runs/{id}` visible posts apply `created_at <= knowledge_cutoff` (ADR 0016). Detail compares live `updated_at` with that cutoff and marks titles rewritten after the run. Seed records the built period report as a later Succeeded run on that same snapshot (ADR 0024) without copying a theta onto the registry row. |
+| W3C Time Ontology in OWL | Keep temporal concepts explicit and avoid collapsing distinct clocks. | Evidence availability and snapshot capture remain on `analysis_source_snapshot`; analysis knowledge cutoff and request time remain on `analysis_run`; status occurrence and database record time remain distinct. `GET /api/analysis-runs/{id}` visible posts apply `created_at <= knowledge_cutoff` (ADR 0016). Detail compares live `updated_at` with that cutoff and marks titles rewritten after the run. Seed records the built period report as a later Succeeded run on that same snapshot (ADR 0024) without copying a theta onto the registry row. The cutoff-known body is read from `source_post_revision` on the opened post, not from the run payload (ADR 0025). |
| W3C Accessible Name and Description Computation 1.1 | Do not let `aria-label` replace visible text the operator must hear. | Analysis-run digest prefixes live in a labeled group; the prefixes remain the accessible contents and the full digest is on `title` for hover verification. |
| ISO 8601-1:2019 | Use unambiguous timestamp representation and timezone-aware persistence. | PostgreSQL `timestamptz` for availability, capture, cutoff, request, occurrence, and record clocks; tests use explicit `Z` offsets. |
| PostgreSQL 18 constraints and trigger contracts | Put integrity close to durable truth and use constraints for row shape while triggers enforce cross-row state and serialization. | Digest/check constraints, category allowlists, account-scoped uniqueness, shape constraints, immutable-row triggers, shared snapshot-row locking, and serialized status transitions. |
diff --git a/docs/doctoring/DESIGN_TOKEN_REFERENCES.md b/docs/doctoring/DESIGN_TOKEN_REFERENCES.md
index 2f0647dc..9201df8e 100644
--- a/docs/doctoring/DESIGN_TOKEN_REFERENCES.md
+++ b/docs/doctoring/DESIGN_TOKEN_REFERENCES.md
@@ -8,7 +8,7 @@ the Storybook inventory.
| Source | Product implication | Implemented evidence |
|---|---|---|
-| W3C Design Tokens Format Module 1.0 | Name color, space, type, and radius once; consume those names from repeated objects. | `frontend/src/styles/tokens.css` defines `--color-*`, `--space-*`, `--radius-chip`, and `--font-*`. `CitationChip` and `PopupCloseButton` read those names through `App.css`. |
+| W3C Design Tokens Format Module 1.0 | Name color, space, type, and radius once; consume those names from repeated objects. | `frontend/src/styles/tokens.css` defines `--color-*`, `--space-*`, `--radius-chip`, `--radius-panel`, and `--font-*`. `CitationChip`, `PopupCloseButton`, and `CutoffKnownBody` read those names through `App.css`. |
| Storybook for React & Vite | Catalog repeated controls so a buyer can try the next click without reading `App.tsx`. | `frontend/src/components/*.stories.tsx` and `docs/storybook-inventory.md`. |
## APA 7th references
diff --git a/docs/doctoring/SOURCE_POST_REVISION_REFERENCES.md b/docs/doctoring/SOURCE_POST_REVISION_REFERENCES.md
new file mode 100644
index 00000000..ffc6bc8b
--- /dev/null
+++ b/docs/doctoring/SOURCE_POST_REVISION_REFERENCES.md
@@ -0,0 +1,58 @@
+# Source-post revision standards and research traceability
+
+**Status:** Active PR evidence; not protected-main truth until merge.
+**Scope:** Migration 0024, ADR 0025, `GET /api/posts/{id}?as_of=`, and the
+opened-post cutoff comparison.
+
+## Standards mapped to implementation
+
+| Source | Product implication | Implemented evidence |
+|---|---|---|
+| W3C PROV-O `wasRevisionOf` | Keep each rewrite as an identifiable revision of the same entity instead of overwriting the only stored sentence. | `source_post_revision` rows keyed by `post_id` + `written_at`; live `source_post` remains the current entity. |
+| W3C Time Ontology in OWL | Do not collapse the analysis cutoff with the source write clock. | `written_at` / `superseded_at` live on the revision; `knowledge_cutoff` stays on `analysis_run`. `as_of` selects the covering interval. |
+| Jensen & Snodgrass (1999) valid time | Use a half-open interval so exactly one revision is current at a clock. | Coverage is `written_at <= as_of` and (`superseded_at` is null or `superseded_at > as_of`). |
+| ISO 8601-1:2019 | Parse `as_of` as a timezone-aware timestamp. | `parse_as_of_clock` treats `Z` and naive values as UTC; invalid clocks are 422. |
+| ADR 0013 registry boundary | Do not store raw posts on the analysis-run payload. | `GET /api/analysis-runs/{id}` still returns titles and clocks only. The known body is on the opened post. |
+
+## Temporal reasoning
+
+A revision answers "what title and body were current at this source
+clock." A run cutoff answers "what that analysis was allowed to know."
+Selecting `as_of = knowledge_cutoff` is a join in the product, not a
+column on `source_post_revision`.
+
+## Privacy boundary
+
+Revisions store the same purpose-bound source title and body already on
+`source_post`. They do not belong in the analysis-run registry, audit
+event, or home list. Necessary PII stays in the authorized post read.
+A missing revision is omitted rather than masked or invented.
+
+## Verification matrix
+
+| Claim | Falsifiable test |
+|---|---|
+| Insert records a revision | After insert, one current `source_post_revision` matches title/body/`updated_at`. |
+| Rewrite supersedes | A title or body update sets `superseded_at` and inserts a new current row. |
+| Clock-only update is silent | Changing only `updated_at` does not add a revision. |
+| Cutoff cover is exact | `as_of` between write and rewrite returns the earlier body; later `as_of` returns the live rewrite as `known_at` or omits when only the live row is asked. |
+| Missing cover is omitted | `as_of` before the first `written_at` has no `known_at`. |
+| Run detail stays aggregates | Analysis-run JSON has no `post_body`. |
+| Seed is comparable | Demo public January sentence ≠ live later-window sentence. |
+
+## APA 7th references
+
+International Organization for Standardization. (2019). *ISO 8601-1:2019:
+Date and time—Representations for information interchange—Part 1: Basic
+rules* (confirmed 2024; Amendment 1:2022).
+
+Jensen, C. S., & Snodgrass, R. T. (1999). Temporal data management.
+*IEEE Transactions on Knowledge and Data Engineering, 11*(1), 36–44.
+https://doi.org/10.1109/69.755613
+
+Moreau, L., & Missier, P. (Eds.). (2013). *PROV-O: The PROV ontology*
+(W3C Recommendation). World Wide Web Consortium.
+https://www.w3.org/TR/prov-o/
+
+World Wide Web Consortium. (2022). *Time ontology in OWL* (W3C
+Recommendation). https://www.w3.org/TR/owl-time/
diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md
index 282e3515..538b7960 100644
--- a/docs/storybook-inventory.md
+++ b/docs/storybook-inventory.md
@@ -6,6 +6,7 @@ buyer-facing control you can click before changing product CSS.
| Story | Buyer next action | Token / module |
|---|---|---|
| `Evidence/CitationChip` | Click a cited title to open that source post. | `--color-chip-border`, `--radius-chip`, `CitationChip` |
+| `AnalysisRun/CutoffKnownBody` | Read the cutoff-known sentence, then compare it with the live body below. | `--color-accent-border`, `--space-panel-block`, `--radius-panel`, `CutoffKnownBody` |
| `Chrome/PopupCloseButton` | Close the evidence panel or post popup. | `--space-close-inset`, `--font-size-close`, `PopupCloseButton` |
Repeated web objects must use `frontend/src/styles/tokens.css` and a module
diff --git a/frontend/package.json b/frontend/package.json
index 24240f91..a01050f5 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
- "version": "2.0.0",
+ "version": "2.1.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/App.css b/frontend/src/App.css
index 78fced0e..dd1ed153 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -162,6 +162,18 @@
font-size: var(--lw-font-size-meta);
}
+.cutoff-known-body {
+ margin: var(--space-panel-block) 0;
+ padding: var(--space-panel-block);
+ border: 1px solid var(--color-accent-border);
+ border-radius: var(--radius-panel);
+ background: var(--color-accent-background);
+}
+
+.cutoff-known-body h3 {
+ margin: 0 0 var(--space-chip-gap);
+}
+
.popup-section {
margin-top: 1.5rem;
padding-top: 1rem;
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index b4fab0d2..328cb4f2 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -976,7 +976,9 @@ describe("App, authenticated", () => {
]),
);
}
- if (url.endsWith("/api/posts/post-1")) {
+ const postOneUrl = new URL(url, "https://backend.test");
+ if (postOneUrl.pathname === "/api/posts/post-1") {
+ const asOf = postOneUrl.searchParams.get("as_of");
return Promise.resolve(
jsonResponse({
post_id: "post-1",
@@ -987,6 +989,16 @@ describe("App, authenticated", () => {
visibility_code: "public",
visibility_label: "Public",
created_at: "2026-01-01T00:00:00Z",
+ ...(asOf
+ ? {
+ known_at: {
+ post_title: "Public post",
+ post_body: "The cutoff body this run knew.",
+ written_at: "2026-01-10T12:00:00Z",
+ as_of: asOf,
+ },
+ }
+ : {}),
}),
);
}
@@ -2099,6 +2111,9 @@ describe("App, authenticated", () => {
expect(screen.getByRole("status", { name: "Live body warning" })).toHaveTextContent(
"This is the live body, not a cutoff snapshot. Compare it with this 2026-01-12 run before you treat it as reconstructed evidence.",
);
+ expect(screen.getByRole("heading", { name: "Body this run knew" })).toBeInTheDocument();
+ expect(screen.getByText("The cutoff body this run knew.")).toBeInTheDocument();
+ expect(screen.getByText(/written 2026-01-10, known at cutoff 2026-01-12/)).toBeInTheDocument();
const linkedPosts = screen.getAllByLabelText("Open post: Linked post");
await userEvent.click(linkedPosts[linkedPosts.length - 1]);
@@ -2117,11 +2132,13 @@ describe("App, authenticated", () => {
expect(screen.getByText("The evidence panel should show exactly this text.")).toBeInTheDocument(),
);
expect(screen.queryByRole("status", { name: "Live body warning" })).not.toBeInTheDocument();
+ expect(screen.queryByRole("heading", { name: "Body this run knew" })).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Close" }));
await userEvent.click(screen.getByRole("button", { name: "View post: Public post" }));
await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument());
expect(screen.queryByRole("status", { name: "Live body warning" })).not.toBeInTheDocument();
+ expect(screen.queryByRole("heading", { name: "Body this run knew" })).not.toBeInTheDocument();
});
it("tells a running lineage run to refresh the durable outbox", async () => {
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index e1db31c1..73af4fb9 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -60,6 +60,7 @@ import {
type VocEvidence,
} from "./api";
import { CitationChip } from "./components/CitationChip";
+import { CutoffKnownBody } from "./components/CutoffKnownBody";
import { PopupCloseButton } from "./components/PopupCloseButton";
import { LineageDag } from "./LineageDag";
import { PostBody } from "./PostBody";
@@ -1270,6 +1271,7 @@ function PostDetailPopup({
canExtract,
graph,
liveBodyWarning,
+ knowledgeCutoff,
focusEventLineage,
onClose,
onSelectPost,
@@ -1279,6 +1281,7 @@ function PostDetailPopup({
canExtract: boolean;
graph: LineageGraph | null;
liveBodyWarning?: string | null;
+ knowledgeCutoff?: string | null;
focusEventLineage?: boolean;
onClose: () => void;
onSelectPost?: (postId: string) => void;
@@ -1324,7 +1327,8 @@ function PostDetailPopup({
setFocusPerson(null);
setFocusEntity(null);
setFocusTeam(null);
- fetchPost(accessToken, postId).then(setPost).catch((err) => setError(String(err)));
+ const asOf = liveBodyWarning && knowledgeCutoff ? knowledgeCutoff : undefined;
+ fetchPost(accessToken, postId, asOf).then(setPost).catch((err) => setError(String(err)));
fetchPostEvaluation(accessToken, postId)
.then((r) => setEvaluation(r.responses))
.catch(() => setEvaluation([]));
@@ -1338,7 +1342,7 @@ function PostDetailPopup({
.then((r) => setAffiliateTrees(r.trees))
.catch(() => setAffiliateTrees([]));
fetchPostVocEvidence(accessToken, postId).then(setVocEvidence).catch(() => setVocEvidence(null));
- }, [postId, accessToken]);
+ }, [postId, accessToken, liveBodyWarning, knowledgeCutoff]);
useEffect(() => {
if (!focusEventLineage || !post) {
@@ -1363,6 +1367,14 @@ function PostDetailPopup({
{post.visibility_label ?? post.visibility_code} ·{" "}
{new Date(post.created_at).toLocaleString()}
+ {post.known_at ? (
+
+ ) : null}
{liveBodyWarning ? (
{liveBodyWarning}
@@ -1736,12 +1748,12 @@ type SelectPostOptions = {
};
/**
- * Next action when a cutoff title opens the live post (ADR 0016).
+ * Next action when a cutoff title opens the live post (ADR 0016 / 0025).
*
* Titles marked `live_after_cutoff` were rewritten after this run;
* others still match the write clock the run knew. The popup then
- * states that the body is live. Cutoff body versioning stays later
- * work -- we never invent the earlier text.
+ * shows the stored cutoff-known body beside the live rewrite. A
+ * missing revision is omitted -- never an invented earlier sentence.
*/
function analysisRunLivePostWarning(cutoffIso: string): string {
const cutoffDate = cutoffIso.slice(0, 10);
@@ -1755,8 +1767,8 @@ function analysisRunLivePostWarning(cutoffIso: string): string {
/**
* Popup next action when a marked cutoff title opens the live body.
*
- * ADR 0016 does not store a historical snapshot. This copy must not
- * invent the earlier text.
+ * ADR 0025 stores the earlier sentence on source_post_revision. This
+ * copy still names the live body so the operator compares two texts.
*/
function analysisRunOpenedBodyWarning(cutoffIso?: string | null): string {
const cutoffDate = cutoffIso?.slice(0, 10);
@@ -2735,6 +2747,7 @@ function PostList({ accessToken }: { accessToken: string }) {
liveBodyWarning={
openedAfterCutoff ? analysisRunOpenedBodyWarning(openedCutoffIso) : null
}
+ knowledgeCutoff={openedAfterCutoff ? openedCutoffIso : null}
focusEventLineage={openedFromReportMember}
onClose={closeSelectedPost}
onSelectPost={selectPost}
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index 576cf1eb..c5fe328e 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -10,8 +10,16 @@ export interface PostSummary {
created_at: string;
}
+export interface PostKnownAt {
+ post_title: string;
+ post_body: string;
+ written_at: string;
+ as_of: string;
+}
+
export interface PostDetail extends PostSummary {
post_body: string;
+ known_at?: PostKnownAt;
}
export interface Affiliation {
@@ -252,8 +260,13 @@ export function fetchPosts(accessToken: string): Promise {
return backendFetch("/api/posts", accessToken);
}
-export function fetchPost(accessToken: string, postId: string): Promise {
- return backendFetch(`/api/posts/${postId}`, accessToken);
+export function fetchPost(
+ accessToken: string,
+ postId: string,
+ asOf?: string,
+): Promise {
+ const query = asOf ? `?as_of=${encodeURIComponent(asOf)}` : "";
+ return backendFetch(`/api/posts/${postId}${query}`, accessToken);
}
export function fetchPostKeymen(accessToken: string, postId: string): Promise<{ keymen: Keyman[] }> {
diff --git a/frontend/src/components/CutoffKnownBody.stories.tsx b/frontend/src/components/CutoffKnownBody.stories.tsx
new file mode 100644
index 00000000..a9ddd852
--- /dev/null
+++ b/frontend/src/components/CutoffKnownBody.stories.tsx
@@ -0,0 +1,19 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { CutoffKnownBody } from "./CutoffKnownBody";
+
+const meta = {
+ title: "AnalysisRun/CutoffKnownBody",
+ component: CutoffKnownBody,
+ args: {
+ title: "Demo public post",
+ body: "Ada West at Demo Corp followed up with Priya Nair at Northridge Grid about the delayed shipment.",
+ writtenAt: "2026-01-10T12:00:00Z",
+ cutoff: "2026-01-12T12:00:00Z",
+ },
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
diff --git a/frontend/src/components/CutoffKnownBody.test.tsx b/frontend/src/components/CutoffKnownBody.test.tsx
new file mode 100644
index 00000000..f0f20243
--- /dev/null
+++ b/frontend/src/components/CutoffKnownBody.test.tsx
@@ -0,0 +1,22 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { CutoffKnownBody } from "./CutoffKnownBody";
+
+describe("CutoffKnownBody", () => {
+ it("tells the operator to compare the cutoff-known text with the live body", () => {
+ render(
+ ,
+ );
+ expect(screen.getByRole("heading", { name: "Body this run knew" })).toBeInTheDocument();
+ expect(screen.getByText("January follow-up about the delayed shipment.")).toBeInTheDocument();
+ expect(
+ screen.getByText(/written 2026-01-10, known at cutoff 2026-01-12/),
+ ).toBeInTheDocument();
+ expect(screen.getByText(/Compare this text with the live body below/)).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/CutoffKnownBody.tsx b/frontend/src/components/CutoffKnownBody.tsx
new file mode 100644
index 00000000..eac12cda
--- /dev/null
+++ b/frontend/src/components/CutoffKnownBody.tsx
@@ -0,0 +1,40 @@
+import { PostBody } from "../PostBody";
+
+export type CutoffKnownBodyProps = {
+ /** Title current at the run cutoff. */
+ title: string;
+ /** Body current at the run cutoff. */
+ body: string;
+ /** When that revision was written. */
+ writtenAt: string;
+ /** Analysis-run knowledge cutoff used for as_of. */
+ cutoff: string;
+};
+
+function clockDate(iso: string): string {
+ return iso.slice(0, 10);
+}
+
+/**
+ * Shows the title/body the analysis run knew.
+ *
+ * Next action: read this text, then compare it with the live body
+ * below before treating the live rewrite as reconstructed evidence.
+ */
+export function CutoffKnownBody({
+ title,
+ body,
+ writtenAt,
+ cutoff,
+}: CutoffKnownBodyProps) {
+ return (
+
+ Body this run knew
+
+ {title} · written {clockDate(writtenAt)}, known at cutoff{" "}
+ {clockDate(cutoff)}. Compare this text with the live body below.
+
+
+
+ );
+}
diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css
index e3510b83..5f2b1210 100644
--- a/frontend/src/styles/tokens.css
+++ b/frontend/src/styles/tokens.css
@@ -15,6 +15,9 @@
--radius-chip: 999px;
--font-size-close: 1.5rem;
--font-family-chip: ui-monospace, Consolas, monospace;
+ --font-size-badge: 0.75rem;
+ --space-panel-block: 0.75rem;
+ --radius-panel: 0.5rem;
}
@media (prefers-color-scheme: dark) {
diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py
index b825d29d..ffb131a7 100644
--- a/lineageweave/__init__.py
+++ b/lineageweave/__init__.py
@@ -55,4 +55,4 @@
"sentence_excerpts",
]
-__version__ = "2.0.0"
+__version__ = "2.1.0"
diff --git a/migrations/0024_source_post_revision.sql b/migrations/0024_source_post_revision.sql
new file mode 100644
index 00000000..0bdd495c
--- /dev/null
+++ b/migrations/0024_source_post_revision.sql
@@ -0,0 +1,83 @@
+-- Store each source_post title/body rewrite as a dated revision (ADR 0025).
+--
+-- The analysis-run registry stays aggregates-only. Cutoff comparison
+-- reads this source-layer history through GET /api/posts/{id}?as_of=.
+-- A missing revision is omitted -- never a fabricated cutoff body.
+
+begin;
+
+create table if not exists source_post_revision (
+ source_post_revision_id uuid primary key default uuid_generate_v4(),
+ post_id uuid not null references source_post (post_id) on delete cascade,
+ post_title text not null,
+ post_body text not null,
+ written_at timestamptz not null,
+ superseded_at timestamptz,
+ constraint source_post_revision_interval_check
+ check (superseded_at is null or superseded_at >= written_at)
+);
+
+comment on table source_post_revision is
+ 'Valid-time title/body history for one source_post. Knowledge cutoffs '
+ 'stay on analysis_run; this table does not store a run id.';
+
+comment on column source_post_revision.written_at is
+ 'When this title/body became current (ISO 8601 / W3C Time).';
+
+comment on column source_post_revision.superseded_at is
+ 'When the next rewrite replaced this row. Null means current.';
+
+create index if not exists source_post_revision_post_clock_idx
+ on source_post_revision (post_id, written_at);
+
+create unique index if not exists source_post_revision_current_idx
+ on source_post_revision (post_id)
+ where superseded_at is null;
+
+create or replace function record_source_post_revision()
+returns trigger
+language plpgsql
+as $$
+begin
+ if tg_op = 'UPDATE'
+ and (new.post_title, new.post_body)
+ is not distinct from (old.post_title, old.post_body) then
+ return new;
+ end if;
+ if tg_op = 'UPDATE' then
+ update source_post_revision
+ set superseded_at = new.updated_at
+ where post_id = new.post_id
+ and superseded_at is null;
+ end if;
+ insert into source_post_revision (
+ post_id, post_title, post_body, written_at
+ ) values (
+ new.post_id, new.post_title, new.post_body, new.updated_at
+ );
+ return new;
+end;
+$$;
+
+comment on function record_source_post_revision() is
+ 'Writes a source_post_revision row on insert or title/body rewrite.';
+
+drop trigger if exists source_post_revision_write on source_post;
+create trigger source_post_revision_write
+ after insert or update of post_title, post_body on source_post
+ for each row
+ execute function record_source_post_revision();
+
+comment on trigger source_post_revision_write on source_post is
+ 'Keeps source_post_revision current when title or body changes (ADR 0025).';
+
+insert into source_post_revision (post_id, post_title, post_body, written_at)
+select post_id, post_title, post_body, updated_at
+ from source_post sp
+ where not exists (
+ select 1
+ from source_post_revision revision
+ where revision.post_id = sp.post_id
+ );
+
+commit;
diff --git a/migrations/rollback/0024_source_post_revision.sql b/migrations/rollback/0024_source_post_revision.sql
new file mode 100644
index 00000000..9d2f4adb
--- /dev/null
+++ b/migrations/rollback/0024_source_post_revision.sql
@@ -0,0 +1,10 @@
+-- Reverse migration 0024. Live source_post rows stay; only revision
+-- history and its write trigger are removed.
+
+begin;
+
+drop trigger if exists source_post_revision_write on source_post;
+drop function if exists record_source_post_revision();
+drop table if exists source_post_revision;
+
+commit;
diff --git a/pyproject.toml b/pyproject.toml
index 5f75ae57..33ff7e0d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "lineageweave"
-version = "2.0.0"
+version = "2.1.0"
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/scripts/seed_demo_data.py b/scripts/seed_demo_data.py
index cacfe557..6c928569 100644
--- a/scripts/seed_demo_data.py
+++ b/scripts/seed_demo_data.py
@@ -126,6 +126,7 @@ def seed(
cur.execute((migrations / "0021_analysis_run_reconstruction.sql").read_text())
cur.execute((migrations / "0022_analysis_source_snapshot_member.sql").read_text())
cur.execute((migrations / "0023_analysis_run_outbox.sql").read_text())
+ cur.execute((migrations / "0024_source_post_revision.sql").read_text())
cur.execute(
"""
insert into common_lookup_value (lookup_category, lookup_code, lookup_label, display_order) values
@@ -234,40 +235,78 @@ def seed(
(account_id, roles[role_code]),
)
- cur.execute("select post_id from source_post where post_title = 'Demo public post'")
- if cur.fetchone() is None:
+ demo_public_cutoff_body = (
+ "Ada West at Demo Corp followed up with Priya Nair at "
+ "Northridge Grid about the delayed shipment."
+ )
+ demo_public_live_body = (
+ "Ada West at Demo Corp revised the delayed-shipment note after "
+ "the January cutoff: Priya Nair at Northridge Grid now expects "
+ "a later delivery window."
+ )
+ cur.execute("select post_id, post_body from source_post where post_title = 'Demo public post'")
+ demo_public_row = cur.fetchone()
+ if demo_public_row is None:
cur.execute(
"insert into source_post (author_account_id, corporate_entity_id, process_unit_id, post_title, post_body, voc_type_code, visibility_code, created_at, updated_at) "
"values (%s, %s, %s, 'Demo public post', "
- "'Ada West at Demo Corp followed up with Priya Nair at Northridge Grid about the delayed shipment.', "
- "'voc', 'public', '2026-01-10T12:00:00Z', '2026-01-13T09:00:00Z')",
- (account_ids["demo.analyst"], corporate_entity_id, process_units["DEMO-PU-A"]),
+ "%s, "
+ "'voc', 'public', '2026-01-10T12:00:00Z', '2026-01-10T12:00:00Z') "
+ "returning post_id",
+ (
+ account_ids["demo.analyst"],
+ corporate_entity_id,
+ process_units["DEMO-PU-A"],
+ demo_public_cutoff_body,
+ ),
+ )
+ demo_public_post_id = cur.fetchone()[0]
+ cur.execute(
+ "update source_post set post_body = %s, "
+ "updated_at = '2026-01-13T09:00:00Z' "
+ "where post_id = %s",
+ (demo_public_live_body, demo_public_post_id),
)
cur.execute(
"insert into source_post (author_account_id, corporate_entity_id, process_unit_id, post_title, post_body, voc_type_code, visibility_code, created_at, updated_at) "
"values (%s, %s, %s, 'Demo private post', 'A synthetic private post scoped to Demo Corp accounts.', 'vom', 'private', '2026-01-10T12:00:00Z', '2026-01-10T12:00:00Z')",
(account_ids["demo.admin"], corporate_entity_id, process_units["DEMO-PU-HQ"]),
)
-
+ else:
+ demo_public_post_id = demo_public_row[0]
+ if demo_public_row[1] != demo_public_live_body:
+ cur.execute(
+ "update source_post set post_body = %s, "
+ "updated_at = '2026-01-13T09:00:00Z' "
+ "where post_id = %s",
+ (demo_public_live_body, demo_public_post_id),
+ )
+ cur.execute(
+ "update source_post set created_at = '2026-01-10T12:00:00Z' "
+ "where post_id = %s",
+ (demo_public_post_id,),
+ )
cur.execute(
- "update source_post set created_at = '2026-01-10T12:00:00Z', "
- "updated_at = '2026-01-13T09:00:00Z' "
- "where post_title = 'Demo public post'"
+ """
+ insert into source_post_revision (
+ post_id, post_title, post_body, written_at, superseded_at
+ )
+ select %s, 'Demo public post', %s,
+ '2026-01-10T12:00:00Z', '2026-01-13T09:00:00Z'
+ where not exists (
+ select 1 from source_post_revision
+ where post_id = %s
+ and written_at <= '2026-01-12T12:00:00Z'
+ and (superseded_at is null or superseded_at > '2026-01-12T12:00:00Z')
+ )
+ """,
+ (demo_public_post_id, demo_public_cutoff_body, demo_public_post_id),
)
cur.execute(
"update source_post set created_at = '2026-01-10T12:00:00Z', "
"updated_at = '2026-01-10T12:00:00Z' "
"where post_title = 'Demo private post'"
)
- cur.execute("select post_id from source_post where post_title = 'Demo public post'")
- demo_public_post_id = cur.fetchone()[0]
- cur.execute(
- "update source_post set post_body = %s where post_id = %s",
- (
- "Ada West at Demo Corp followed up with Priya Nair at Northridge Grid about the delayed shipment.",
- demo_public_post_id,
- ),
- )
cur.execute(
"insert into post_counterparty_entity (post_id, counterparty_entity_name, relationship_type_code) "
"values (%s, 'Northridge Grid', 'rel_voc'), (%s, 'Demo Corp', 'rel_voc') "
diff --git a/tests/test_analysis_run_reconstruction_schema.py b/tests/test_analysis_run_reconstruction_schema.py
index 9362edc8..a7a06689 100644
--- a/tests/test_analysis_run_reconstruction_schema.py
+++ b/tests/test_analysis_run_reconstruction_schema.py
@@ -48,6 +48,7 @@ def test_reconstruction_migration_is_normalized_and_wired() -> None:
assert "0021_analysis_run_reconstruction.sql" in dockerfile
assert "0022_analysis_source_snapshot_member.sql" in dockerfile
assert "0023_analysis_run_outbox.sql" in dockerfile
+ assert "0024_source_post_revision.sql" in dockerfile
assert "analysis_run_reconstruction_not_empty" in rollback
assert "reject_analysis_run_reconstruction_update" in migration
assert "reject_analysis_run_lineage_edge_update" in migration
diff --git a/tests/test_analysis_run_registry_schema.py b/tests/test_analysis_run_registry_schema.py
index 18a1a91c..62eb2713 100644
--- a/tests/test_analysis_run_registry_schema.py
+++ b/tests/test_analysis_run_registry_schema.py
@@ -279,6 +279,7 @@ def test_registry_contract_is_normalized_and_has_one_temporal_authority() -> Non
assert "0021_analysis_run_reconstruction.sql" in dockerfile
assert "0022_analysis_source_snapshot_member.sql" in dockerfile
assert "0023_analysis_run_outbox.sql" in dockerfile
+ assert "0024_source_post_revision.sql" in dockerfile
seed = (_ROOT / "scripts" / "seed_demo_data.py").read_text(encoding="utf-8")
assert seed.index("0019_role_catalog_identity.sql") < seed.index(
"0020_analysis_run_retention_purge.sql"
@@ -292,6 +293,9 @@ def test_registry_contract_is_normalized_and_has_one_temporal_authority() -> Non
assert seed.index("0022_analysis_source_snapshot_member.sql") < seed.index(
"0023_analysis_run_outbox.sql"
)
+ assert seed.index("0023_analysis_run_outbox.sql") < seed.index(
+ "0024_source_post_revision.sql"
+ )
assert "analysis_run_registry_not_empty" in rollback
retention = _RETENTION_MIGRATION.read_text(encoding="utf-8")
retention_rollback = _RETENTION_ROLLBACK.read_text(encoding="utf-8")
diff --git a/tests/test_source_post_revision.py b/tests/test_source_post_revision.py
new file mode 100644
index 00000000..4f6d17b7
--- /dev/null
+++ b/tests/test_source_post_revision.py
@@ -0,0 +1,58 @@
+"""Cutoff-known bodies come from source_post_revision, never an invented sentence."""
+
+from datetime import datetime, timezone
+from pathlib import Path
+
+from backend.app.source_post_revision import parse_as_of_clock, revision_covers_clock
+
+_ROOT = Path(__file__).resolve().parents[1]
+_MIGRATION = _ROOT / "migrations" / "0024_source_post_revision.sql"
+_ROLLBACK = _ROOT / "migrations" / "rollback" / "0024_source_post_revision.sql"
+_CUTOFF = datetime(2026, 1, 12, 12, 0, tzinfo=timezone.utc)
+
+
+def test_parse_as_of_clock_treats_z_and_naive_as_utc() -> None:
+ parsed = parse_as_of_clock("2026-01-12T12:00:00Z")
+ assert parsed == _CUTOFF
+ naive = parse_as_of_clock("2026-01-12T12:00:00")
+ assert naive == _CUTOFF
+
+
+def test_parse_as_of_clock_rejects_empty_or_unparseable() -> None:
+ try:
+ parse_as_of_clock(" ")
+ except ValueError as exc:
+ assert "empty" in str(exc)
+ else:
+ raise AssertionError("empty as_of must fail closed")
+ try:
+ parse_as_of_clock("not-a-clock")
+ except ValueError:
+ return
+ raise AssertionError("unparseable as_of must fail closed")
+
+
+def test_revision_interval_is_half_open() -> None:
+ written = datetime(2026, 1, 10, 12, 0, tzinfo=timezone.utc)
+ superseded = datetime(2026, 1, 13, 9, 0, tzinfo=timezone.utc)
+ assert revision_covers_clock(written, superseded, written) is True
+ assert revision_covers_clock(written, superseded, _CUTOFF) is True
+ assert revision_covers_clock(written, superseded, superseded) is False
+ assert revision_covers_clock(written, None, superseded) is True
+ assert revision_covers_clock(superseded, None, _CUTOFF) is False
+
+
+def test_revision_migration_records_title_or_body_rewrites_only() -> None:
+ sql = _MIGRATION.read_text(encoding="utf-8")
+ rollback = _ROLLBACK.read_text(encoding="utf-8")
+ assert "source_post_revision" in sql
+ assert "record_source_post_revision" in sql
+ assert "update of post_title, post_body" in sql
+ assert "superseded_at" in sql
+ assert "drop table if exists source_post_revision" in rollback
+ seed = (_ROOT / "scripts" / "seed_demo_data.py").read_text(encoding="utf-8")
+ assert "later delivery window" in seed
+ assert "delayed shipment." in seed
+ assert seed.index("0023_analysis_run_outbox.sql") < seed.index(
+ "0024_source_post_revision.sql"
+ )
diff --git a/uv.lock b/uv.lock
index 75eb24c5..dd528a38 100644
--- a/uv.lock
+++ b/uv.lock
@@ -454,7 +454,7 @@ wheels = [
[[package]]
name = "lineageweave"
-version = "2.0.0"
+version = "2.1.0"
source = { virtual = "." }
dependencies = [
{ name = "certifi" },