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" },