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 ? ( +
{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
+ {title} · written {clockDate(writtenAt)}, known at cutoff{" "}
+ {clockDate(cutoff)}. Compare this text with the live body below.
+ Body this run knew
+