diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bf19c5f77..f02d27ef5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -482,8 +482,11 @@ unavailable, so that run is Failed rather than a fabricated score. The home list is clickable: `GET /api/analysis-runs/{id}` fills a labeled detail (cutoff, requested date, 12-character digest prefixes with full digests on hover, counts, status history) -without exposing a DSN or raw record. Opening a cutoff title warns -that the live body may have changed after the run. Status history is detail-only +without exposing a DSN or raw record. Opening a cutoff title still +shows the live body and names both clocks when the title was +rewritten after the run. A marked title also shows the body that +run knew (`GET /api/posts/{id}?as_of=`) so the operator can compare +two texts, not two clocks. Status history is detail-only and uses lookup labels plus occurrence times; a failure event keeps its machine `failure_code` rather than an invented caption. Failed TEPP list rows add a next-action line (open the run, then connect the diff --git a/CHANGELOG.d/0.87.1-analysis-run-live-write-clock.md b/CHANGELOG.d/0.87.1-analysis-run-live-write-clock.md new file mode 100644 index 000000000..bb7c0293c --- /dev/null +++ b/CHANGELOG.d/0.87.1-analysis-run-live-write-clock.md @@ -0,0 +1,6 @@ +# 0.87.1 Analysis-run live write clock + +In-cutoff titles now say whether the live row was rewritten after the +run. Open Demo public post as the edited counter-example; Demo private +post still matches the January cutoff. The opened live body names both +clocks. Bodies stay live. diff --git a/CHANGELOG.d/0.87.2-source-post-revision.md b/CHANGELOG.d/0.87.2-source-post-revision.md new file mode 100644 index 000000000..8dbf31ef8 --- /dev/null +++ b/CHANGELOG.d/0.87.2-source-post-revision.md @@ -0,0 +1,5 @@ +# 0.87.2 Source-post revision at cutoff + +Open a marked Demo public post: the January sentence is **Body this run +knew**; the live body is the later delivery window. Compare those two +texts. Analysis-run detail still has no post body. diff --git a/CHANGELOG.md b/CHANGELOG.md index 00a19fe92..5caa38079 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,30 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.87.2] - 2026-08-16 + +### Added + +- Opening a title marked **Updated after cutoff** now shows the body + that run knew beside the live rewrite. After `make seed`, open Demo + public post from the Demo Corp lineage run: **Body this run knew** is + the January follow-up; the live body names the later delivery window. + `GET /api/posts/{id}?as_of=` reads `source_post_revision`. Analysis-run + detail stays titles and clocks. A missing revision is omitted — never + a fabricated cutoff sentence or a TEPP theta (ADR 0022). + +## [0.87.1] - 2026-08-16 + +### Added + +- Analysis-run detail now compares each in-cutoff title's live + `updated_at` with that run's knowledge cutoff. After `make seed`, + open the Demo Corp lineage run: Demo public post is marked + **Updated after cutoff**; Demo private post is not. Opening a + marked title still shows the live body and names both clocks. + Cutoff body versioning stays a later slice (ADR 0016). The list + stays aggregates-only. No TEPP theta is invented. + ## [0.87.0] - 2026-08-16 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 870c77f87..f77943159 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,7 +29,10 @@ mention TEPP. A failed period-report row rebuilds the report. A pending TEPP row does not claim a calibrated measurement. A pending lineage row says reconstruction has not started yet. Digest prefixes stay audible; hover a prefix to read the full digest. -Opening a cutoff title shows the live post -- compare it with the -cutoff before treating the body as reconstructed evidence (ADR 0016). +Opening a cutoff title shows the live post. Titles marked updated +after cutoff were rewritten after the run; the opened body names +both clocks and shows **Body this run knew** beside the live +rewrite. Compare those two texts before treating the live body as +reconstructed evidence (ADR 0016 / 0021 / 0022). `POST /api/analysis-runs` records Pending on an authorized cutoff capture (ADR 0017) and does not reconstruct lineage. diff --git a/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py index d26eb6f6e..1da59ad1d 100644 --- a/backend/app/analysis_run_ingestion.py +++ b/backend/app/analysis_run_ingestion.py @@ -93,6 +93,22 @@ def _iso(value: Any) -> str: return value.isoformat() if hasattr(value, "isoformat") else str(value) +def _as_utc(value: datetime) -> datetime: + """Treat a naive clock as UTC so cutoff comparison stays timezone-aware.""" + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + +def live_write_after_cutoff(updated_at: datetime, knowledge_cutoff: datetime) -> bool: + """True when the live row was rewritten after the run's analysis clock. + + ``created_at <= knowledge_cutoff`` admits the title. ``updated_at`` is + the live write clock (ADR 0016). Equal times stay in-cutoff evidence. + """ + return _as_utc(updated_at) > _as_utc(knowledge_cutoff) + + async def _counts_by_run( conn: asyncpg.Connection, run_ids: list[str], @@ -258,15 +274,21 @@ async def fetch_visible_scope_posts( scope_key: str | None, affiliated_entity_ids: list[str], knowledge_cutoff: Any, -) -> list[dict[str, str]]: +) -> list[dict[str, Any]]: """ABAC-visible post titles known at the run cutoff -- never a hidden body. ``knowledge_cutoff`` is the analysis clock (W3C Time / ISO 8601-1:2019; ADR 0013/0016). A later live post must not appear inside an earlier run. + ``updated_at`` is compared separately so the operator can see which + in-cutoff titles were rewritten after that clock. The live body is + still not returned. """ + columns = ( + "post_id, post_title, visibility_code, corporate_entity_id, updated_at" + ) if scope_kind_code == "analysis_scope_corporate_entity" and corporate_entity_id: rows = await conn.fetch( - "select post_id, post_title, visibility_code, corporate_entity_id " + f"select {columns} " "from source_post where corporate_entity_id = $1 " "and created_at <= $2 " "order by created_at, post_title", @@ -275,7 +297,7 @@ async def fetch_visible_scope_posts( ) elif scope_kind_code == "analysis_scope_process_unit" and process_unit_id: rows = await conn.fetch( - "select post_id, post_title, visibility_code, corporate_entity_id " + f"select {columns} " "from source_post where process_unit_id = $1 " "and created_at <= $2 " "order by created_at, post_title", @@ -284,7 +306,7 @@ async def fetch_visible_scope_posts( ) elif scope_kind_code == "analysis_scope_thread_group" and scope_key: rows = await conn.fetch( - "select post_id, post_title, visibility_code, corporate_entity_id " + f"select {columns} " "from source_post where thread_group_key = $1 " "and created_at <= $2 " "order by created_at, post_title", @@ -293,7 +315,7 @@ async def fetch_visible_scope_posts( ) elif scope_kind_code == "analysis_scope_all_visible": rows = await conn.fetch( - "select post_id, post_title, visibility_code, corporate_entity_id " + f"select {columns} " "from source_post where created_at <= $1 " "order by created_at, post_title", knowledge_cutoff, @@ -301,12 +323,22 @@ async def fetch_visible_scope_posts( else: return [] affiliated = {str(entity_id) for entity_id in affiliated_entity_ids} - posts: list[dict[str, str]] = [] + posts: list[dict[str, Any]] = [] for row in rows: visible = row["visibility_code"] == "public" or str(row["corporate_entity_id"]) in affiliated if not visible: continue - posts.append({"post_id": str(row["post_id"]), "post_title": row["post_title"]}) + updated_at = row["updated_at"] + posts.append( + { + "post_id": str(row["post_id"]), + "post_title": row["post_title"], + "updated_at": _iso(updated_at), + "live_after_cutoff": live_write_after_cutoff( + updated_at, knowledge_cutoff + ), + } + ) return posts diff --git a/backend/app/main.py b/backend/app/main.py index adb7a20a8..bc6b7647e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -72,6 +72,7 @@ fetch_visible_analysis_run, fetch_visible_analysis_runs, ) +from backend.app.source_post_revision import fetch_known_at_revision, parse_as_of_clock from backend.app.activity_stream import ( create_valkey_client, get_valkey, @@ -368,11 +369,29 @@ async def list_posts( @app.get("/api/posts/{post_id}") async def read_post( post_id: str, + as_of: str | None = None, account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), ) -> dict[str, Any]: - """Return one source_post, or 404 / 403 if it is missing or out of scope.""" + """Return one source_post, or 404 / 403 if it is missing or out of scope. + + ``as_of`` adds ``known_at`` when a ``source_post_revision`` covers that + clock (ADR 0022). The live ``post_body`` stays the live row. A missing + cover is omitted -- never a fabricated cutoff sentence. Next action: + pass the analysis-run cutoff, then compare ``known_at`` with the live + body before treating the live text as reconstructed evidence. + """ _require_post_read(account) + as_of_clock = None + if as_of is not None: + try: + as_of_clock = parse_as_of_clock(as_of) + except ValueError as exc: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + "as_of must be an ISO-8601 timestamp. Use the run cutoff, " + "then compare the known body with the live body.", + ) from exc async with pool.acquire() as conn: row = await conn.fetchrow( "select post_id, post_title, post_body, voc_type_code, visibility_code, corporate_entity_id, created_at " @@ -384,7 +403,13 @@ async def read_post( if not _can_see_post(account, row): raise HTTPException(status.HTTP_403_FORBIDDEN, "not authorized to view this post") labels = await _lookup_post_labels(conn, [row]) - return {**_serialize_post(row, labels), "post_body": row["post_body"]} + known_at = None + if as_of_clock is not None: + known_at = await fetch_known_at_revision(conn, post_id, as_of_clock) + payload = {**_serialize_post(row, labels), "post_body": row["post_body"]} + if known_at is not None: + payload["known_at"] = known_at + return payload async def _load_visible_post( diff --git a/backend/app/source_post_revision.py b/backend/app/source_post_revision.py new file mode 100644 index 000000000..7ef2f7896 --- /dev/null +++ b/backend/app/source_post_revision.py @@ -0,0 +1,92 @@ +"""Source-post valid-time revisions for cutoff-known bodies (ADR 0022). + +The analysis-run registry stays aggregates-only. Callers that need the +sentence a run knew must read ``source_post_revision`` through an +authorized post fetch with ``as_of``. A missing cover is omitted -- +never a fabricated cutoff body or a TEPP theta. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + import asyncpg + + +def _as_utc(value: datetime) -> datetime: + """Treat a naive clock as UTC so interval tests stay timezone-aware.""" + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + +def parse_as_of_clock(value: str) -> datetime: + """Parse an ISO-8601 as-of clock. + + Next action: pass the analysis-run cutoff, then compare ``known_at`` + with the live body. Empty or unparseable values raise ``ValueError``. + """ + text = value.strip() + if not text: + raise ValueError("as_of is empty") + if text.endswith("Z"): + text = text[:-1] + "+00:00" + parsed = datetime.fromisoformat(text) + return _as_utc(parsed) + + +def revision_covers_clock( + written_at: datetime, + superseded_at: datetime | None, + as_of: datetime, +) -> bool: + """True when this revision was current at ``as_of``. + + The interval is half-open: ``written_at <= as_of < superseded_at``. + A null ``superseded_at`` means the revision is still current. + """ + start = _as_utc(written_at) + clock = _as_utc(as_of) + if start > clock: + return False + if superseded_at is None: + return True + return _as_utc(superseded_at) > clock + + +def _iso(value: Any) -> str: + """Serialize a timestamptz the same way post detail already does.""" + return value.isoformat() if hasattr(value, "isoformat") else str(value) + + +async def fetch_known_at_revision( + conn: "asyncpg.Connection", + post_id: str, + as_of: datetime, +) -> dict[str, str] | None: + """Return the title/body current at ``as_of``, or None when none exists. + + Does not invent a sentence. Does not return a live body under a + cutoff label when no revision covers the clock. + """ + row = await conn.fetchrow( + "select post_title, post_body, written_at " + "from source_post_revision " + "where post_id = $1 " + "and written_at <= $2 " + "and (superseded_at is null or superseded_at > $2) " + "order by written_at desc " + "limit 1", + post_id, + as_of, + ) + if row is None: + return None + return { + "post_title": row["post_title"], + "post_body": row["post_body"], + "written_at": _iso(row["written_at"]), + "as_of": _iso(as_of), + } diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 3b74c22a3..ea86d7b0c 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -33,6 +33,8 @@ _MIGRATION_PATH = Path(__file__).resolve().parents[2] / "migrations" / "0001_initial_schema.sql" _REGISTRY_MIGRATION = Path(__file__).resolve().parents[2] / "migrations" / "0018_analysis_run_registry.sql" _RETENTION_MIGRATION = Path(__file__).resolve().parents[2] / "migrations" / "0020_analysis_run_retention_purge.sql" +_WRITE_CLOCK_MIGRATION = Path(__file__).resolve().parents[2] / "migrations" / "0021_source_post_write_clock.sql" +_REVISION_MIGRATION = Path(__file__).resolve().parents[2] / "migrations" / "0022_source_post_revision.sql" def _postgres_available() -> bool: @@ -117,6 +119,8 @@ def seeded_db(demo_analyst_token): cur.execute(_MIGRATION_PATH.read_text()) cur.execute(_REGISTRY_MIGRATION.read_text()) cur.execute(_RETENTION_MIGRATION.read_text()) + cur.execute(_WRITE_CLOCK_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'), " @@ -305,11 +309,21 @@ def _insert_post( visibility_code: str, body: str = "body", created_at: str = "2026-01-10T12:00:00Z", + updated_at: str | None = None, ) -> str: + written_at = updated_at if updated_at is not None else created_at cur.execute( - "insert into source_post (author_account_id, corporate_entity_id, post_title, post_body, voc_type_code, visibility_code, created_at) " - "values (%s, %s, %s, %s, 'voc', %s, %s) returning post_id", - (account_id, corporate_entity_id, title, body, visibility_code, created_at), + "insert into source_post (author_account_id, corporate_entity_id, post_title, post_body, voc_type_code, visibility_code, created_at, updated_at) " + "values (%s, %s, %s, %s, 'voc', %s, %s, %s) returning post_id", + ( + account_id, + corporate_entity_id, + title, + body, + visibility_code, + created_at, + written_at, + ), ) return str(cur.fetchone()[0]) @@ -329,6 +343,22 @@ def _insert_post( "A follow-up written after the January 2026 run cutoff.", created_at="2026-01-20T12:00:00Z", ) + edited_own_post_id = _insert_post( + "Edited own-corp private post", + own_corp_id, + "private", + "A January post before the rewrite.", + created_at="2026-01-10T12: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( "insert into cataloged_person (person_name, person_side_code) values " @@ -415,6 +445,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, @@ -494,11 +525,52 @@ def test_analysis_runs_are_labeled_aggregates_and_hide_other_scopes( assert all("failure_code" not in event for event in history) titles = {post["post_title"] for post in body["visible_posts"]} assert "Own-corp private post" in titles + assert "Edited own-corp private post" in titles assert "Late own-corp private post" not in titles assert "Other-corp private post" not in titles + posts_by_title = {post["post_title"]: post for post in body["visible_posts"]} + assert posts_by_title["Own-corp private post"]["live_after_cutoff"] is False + assert posts_by_title["Edited own-corp private post"]["live_after_cutoff"] is True + assert posts_by_title["Edited own-corp private post"]["updated_at"].startswith("2026-01-13") + assert "post_body" not in posts_by_title["Edited own-corp private post"] + assert "known_at" not in posts_by_title["Edited own-corp private post"] assert "postgresql://" not in str(body) assert "visible_posts" not in visible + admin_conn = psycopg2.connect(seeded_db["dsn"]) + admin_conn.autocommit = True + try: + with admin_conn.cursor() as cur: + cur.execute( + "select updated_at from source_post where post_title = %s", + ("Own-corp private post",), + ) + pinned = cur.fetchone()[0] + cur.execute( + "update source_post set post_body = post_body || ' rewritten' " + "where post_title = %s", + ("Own-corp private post",), + ) + cur.execute( + "select updated_at from source_post where post_title = %s", + ("Own-corp private post",), + ) + rewritten = cur.fetchone()[0] + assert rewritten > pinned + cur.execute( + "update source_post set post_body = %s, updated_at = %s " + "where post_title = %s", + ("body", pinned, "Own-corp private post"), + ) + cur.execute( + "select updated_at from source_post where post_title = %s", + ("Own-corp private post",), + ) + honoured = cur.fetchone()[0] + assert honoured == pinned + finally: + admin_conn.close() + hidden = client.get( f"/api/analysis-runs/{seeded_db['hidden_run_id']}", headers={"Authorization": f"Bearer {demo_analyst_token}"}, @@ -587,7 +659,12 @@ def test_post_list_includes_public_and_own_corp_but_excludes_other_corp(client, response = client.get("/api/posts", headers={"Authorization": f"Bearer {demo_analyst_token}"}) assert response.status_code == 200 titles = {post["post_title"] for post in response.json()} - assert titles == {"Public post", "Own-corp private post", "Late own-corp private post"} + assert titles == { + "Public post", + "Own-corp private post", + "Late own-corp private post", + "Edited own-corp private post", + } public = next(post for post in response.json() if post["post_title"] == "Public post") assert public["voc_type_label"] == "Voice of Customer" assert public["visibility_label"] == "Public" @@ -606,6 +683,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 ce2f0e6b5..55b1dab85 100644 --- a/docker/postgres-init/Dockerfile +++ b/docker/postgres-init/Dockerfile @@ -26,6 +26,8 @@ COPY migrations/0017_prov_o_standard_relations.sql /docker-entrypoint-initdb.d/1 COPY migrations/0018_analysis_run_registry.sql /docker-entrypoint-initdb.d/19-analysis-run-registry.sql COPY migrations/0019_role_catalog_identity.sql /docker-entrypoint-initdb.d/20-role-catalog-identity.sql COPY migrations/0020_analysis_run_retention_purge.sql /docker-entrypoint-initdb.d/21-analysis-run-retention-purge.sql +COPY migrations/0021_source_post_write_clock.sql /docker-entrypoint-initdb.d/22-source-post-write-clock.sql +COPY migrations/0022_source_post_revision.sql /docker-entrypoint-initdb.d/23-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 089443374..ff8c9b545 100644 --- a/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md +++ b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md @@ -25,9 +25,11 @@ 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. The detail must say that next action -plainly: compare the opened body with this cutoff before treating it -as reconstructed evidence. +run was not allowed to know. Detail compares the live `updated_at` +write clock with `knowledge_cutoff` and marks titles rewritten after +the run. 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 @@ -44,11 +46,17 @@ run. - After `make seed`, the Demo Corp lineage run lists Demo public post and other in-cutoff Demo Corp titles. The later fixture account-review post (2026-02-10) does not appear. -- Open the run, read the live-body warning, then open a listed post - and compare it with the cutoff date. +- Open the run: Demo public post is marked updated after cutoff + (`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 0022). - 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. +- Migration 0021 (ADR 0021) keeps `updated_at` honest after a title or + body write. Migration 0022 (ADR 0022) 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. - 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/0021-source-post-write-clock.md b/docs/adr/0021-source-post-write-clock.md new file mode 100644 index 000000000..8bf2e97d1 --- /dev/null +++ b/docs/adr/0021-source-post-write-clock.md @@ -0,0 +1,52 @@ +# ADR 0021 — Source-post write clock honors explicit pins + +**Decision status:** Accepted +**Date:** 2026-08-16 + +## Context + +ADR 0016 compares live `source_post.updated_at` with +`analysis_run.knowledge_cutoff` so the operator can see which in-cutoff +titles were rewritten after the run. The column defaulted to `now()` on +insert and never moved on a later title or body write, so a real rewrite +stayed unmarked and `make seed` could not pin Demo public post to +2026-01-13 unless every statement assigned `updated_at`. + +W3C Time Ontology in OWL (Hobbs & Pan, 2017) and ISO 8601-1:2019 keep +the live write clock distinct from the analysis cutoff. A missing write +clock and a confidently-in-cutoff clock are different things. + +## Decision + +Migration `0021_source_post_write_clock.sql` adds +`touch_source_post_write_clock` on `BEFORE UPDATE` of `source_post`. +The trigger bumps `updated_at` to `clock_timestamp()` only when +`post_title` or `post_body` changes and the statement did not already +assign `updated_at`. Thread-group, visibility, or grouping-key updates +do not pretend to be a rewrite. + +`make seed` may still pin Demo public post to 2026-01-13 and Demo +private post to its create clock. A later product body edit after the +January cutoff marks the title. + +## Consequences + +- After `make seed`, open the Demo Corp lineage run: Demo public post + is marked updated after cutoff; Demo private post is not. +- Open a marked title: the live popup names both clocks. ADR 0022 + then shows the cutoff-known body beside the live rewrite. +- Roll back `0022` before `0021` / `0020` / `0018` when emptying a + database that also uses the retention purge. + +## 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). + +Hobbs, J. R., & Pan, F. (2017). *Time ontology in OWL* (W3C +Recommendation). World Wide Web Consortium. +https://www.w3.org/TR/2017/REC-owl-time-20171019/ + +World Wide Web Consortium. (2022). *Time ontology in OWL* (W3C +Recommendation). https://www.w3.org/TR/owl-time/ diff --git a/docs/adr/0022-source-post-revision.md b/docs/adr/0022-source-post-revision.md new file mode 100644 index 000000000..746a2480b --- /dev/null +++ b/docs/adr/0022-source-post-revision.md @@ -0,0 +1,57 @@ +# ADR 0022 — Source-post revisions keep the cutoff-known body + +**Decision status:** Accepted +**Date:** 2026-08-16 + +## Context + +ADR 0016 / 0021 mark 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)`. + +## Decision + +Migration `0022_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 `0022` before `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 c776053b1..d8c18e30f 100644 --- a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md +++ b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md @@ -8,7 +8,7 @@ | 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). Opening a listed title warns that the live body may have changed after that cutoff. | +| 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. Migration 0021 bumps `updated_at` on a title or body rewrite unless the statement already assigned the clock (ADR 0021). The cutoff-known body is read from `source_post_revision` on the opened post, not from the run payload (ADR 0022). | | 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 2f0647dca..13c4a67b6 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`, `CutoffWriteClockBadge`, 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 000000000..153793a5b --- /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 0022, ADR 0022, `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 282e3515e..5c06a1e20 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -6,6 +6,8 @@ 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/CutoffWriteClockBadge` | Open the marked title and compare the live body with the run cutoff. | `--color-accent-border`, `--radius-chip`, `--font-size-badge`, `CutoffWriteClockBadge` | +| `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 0d43d9fa2..d1c9c0b84 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.87.0", + "version": "0.87.2", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index 5251e69f8..2d8924ce3 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -49,11 +49,35 @@ } .post-badge { - font-size: 0.75rem; + font-size: var(--font-size-badge); opacity: 0.7; text-transform: uppercase; } +.cutoff-write-clock-badge { + display: inline-block; + margin-left: var(--space-chip-gap); + padding: var(--space-chip-block) var(--space-chip-inline); + border: 1px solid var(--color-accent-border); + border-radius: var(--radius-chip); + background: var(--color-accent-background); + color: var(--color-text-heading); + font-size: var(--font-size-badge); + text-transform: uppercase; +} + +.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-backdrop { position: fixed; inset: 0; diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index fd8a15146..ea8405404 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -302,7 +302,20 @@ describe("App, authenticated", () => { count_value: 3, }, ], - visible_posts: [{ post_id: "post-1", post_title: "Public post" }], + visible_posts: [ + { + post_id: "post-1", + post_title: "Public post", + updated_at: "2026-01-13T09:00:00Z", + live_after_cutoff: true, + }, + { + post_id: "post-2", + post_title: "Private post", + updated_at: "2026-01-10T12:00:00Z", + live_after_cutoff: false, + }, + ], code_revision_sha: "abcdef0123456789deadbeefcafebabe", configuration_sha256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", @@ -667,7 +680,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", @@ -678,6 +693,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, + }, + } + : {}), }), ); } @@ -1688,22 +1713,40 @@ describe("App, authenticated", () => { expect(screen.getByRole("list", { name: "Posts known at this run cutoff" })).toBeInTheDocument(); expect( screen.getByText( - "Opening a title shows the live post. Compare it with cutoff 2026-01-12 before you treat the body as reconstructed evidence — it may have changed after this run.", + "Opening a title shows the live post. Titles marked updated after cutoff were rewritten after 2026-01-12. Compare those bodies with this run before you treat them as reconstructed evidence.", ), ).toBeInTheDocument(); expect( screen.getByRole("button", { - name: "Open live post (may have changed after cutoff): Public post", + name: "Open live post (updated after cutoff): Public post", }), ).toBeInTheDocument(); + expect( + screen.getByRole("button", { + name: "Open live post: Private post", + }), + ).toBeInTheDocument(); + const cutoffPosts = screen.getByRole("list", { name: "Posts known at this run cutoff" }); + expect(cutoffPosts).toHaveTextContent("Updated after cutoff"); + expect(screen.getByRole("button", { name: "Open live post: Private post" }).closest("li")).not.toHaveTextContent( + "Updated after cutoff", + ); expect(screen.queryByText(/postgresql:\/\//)).not.toBeInTheDocument(); await userEvent.click( screen.getByRole("button", { - name: "Open live post (may have changed after cutoff): Public post", + name: "Open live post (updated after cutoff): Public post", }), ); await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); + expect( + screen.getByText( + "This live body was rewritten on 2026-01-13, after cutoff 2026-01-12. Compare it with this run before you treat it as reconstructed evidence.", + ), + ).toBeInTheDocument(); + 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(); await userEvent.click( screen.getByRole("button", { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 07088e9d4..359c22a30 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -59,6 +59,8 @@ import { type VocEvidence, } from "./api"; import { CitationChip } from "./components/CitationChip"; +import { CutoffKnownBody } from "./components/CutoffKnownBody"; +import { CutoffWriteClockBadge } from "./components/CutoffWriteClockBadge"; import { PopupCloseButton } from "./components/PopupCloseButton"; import { LineageDag } from "./LineageDag"; import { PostBody } from "./PostBody"; @@ -1163,13 +1165,15 @@ function PostDetailPopup({ graph, onClose, onSelectPost, + liveWriteClock, }: { postId: string; accessToken: string; canExtract: boolean; graph: LineageGraph | null; onClose: () => void; - onSelectPost?: (postId: string) => void; + onSelectPost?: SelectPostFn; + liveWriteClock?: AnalysisRunLiveWriteClock | null; }) { const [post, setPost] = useState(null); const [error, setError] = useState(null); @@ -1212,7 +1216,8 @@ function PostDetailPopup({ setFocusPerson(null); setFocusEntity(null); setFocusTeam(null); - fetchPost(accessToken, postId).then(setPost).catch((err) => setError(String(err))); + const asOf = liveWriteClock?.liveAfterCutoff ? liveWriteClock.knowledgeCutoff : undefined; + fetchPost(accessToken, postId, asOf).then(setPost).catch((err) => setError(String(err))); fetchPostEvaluation(accessToken, postId) .then((r) => setEvaluation(r.responses)) .catch(() => setEvaluation([])); @@ -1226,7 +1231,7 @@ function PostDetailPopup({ .then((r) => setAffiliateTrees(r.trees)) .catch(() => setAffiliateTrees([])); fetchPostVocEvidence(accessToken, postId).then(setVocEvidence).catch(() => setVocEvidence(null)); - }, [postId, accessToken]); + }, [postId, accessToken, liveWriteClock]); return (
@@ -1242,6 +1247,17 @@ function PostDetailPopup({ {post.visibility_label ?? post.visibility_code} ·{" "} {new Date(post.created_at).toLocaleString()}

+ {liveWriteClock?.liveAfterCutoff && ( +

{analysisRunOpenedPostWarning(liveWriteClock)}

+ )} + {post.known_at && ( + + )}
@@ -1564,20 +1580,51 @@ function analysisRunDigestPrefix(digest: string): string { /** * Next action when a cutoff title opens the live post (ADR 0016). * - * Post-body versioning is a later slice. Until then the operator must - * compare the opened body with this run's cutoff instead of treating - * today's text as reconstructed evidence. + * Post-body versioning is a later slice. Titles marked + * `live_after_cutoff` were rewritten after this run; others still + * match the write clock the run knew. */ function analysisRunLivePostWarning(cutoffIso: string): string { const cutoffDate = cutoffIso.slice(0, 10); return ( - `Opening a title shows the live post. Compare it with cutoff ${cutoffDate} ` + - "before you treat the body as reconstructed evidence — it may have changed after this run." + `Opening a title shows the live post. Titles marked updated after cutoff ` + + `were rewritten after ${cutoffDate}. Compare those bodies with this run ` + + "before you treat them as reconstructed evidence." ); } -function analysisRunLivePostButtonLabel(postTitle: string): string { - return `Open live post (may have changed after cutoff): ${postTitle}`; +function analysisRunLivePostButtonLabel(post: { + post_title: string; + live_after_cutoff?: boolean; +}): string { + if (post.live_after_cutoff) { + return `Open live post (updated after cutoff): ${post.post_title}`; + } + return `Open live post: ${post.post_title}`; +} + +type AnalysisRunLiveWriteClock = { + knowledgeCutoff: string; + updatedAt?: string; + liveAfterCutoff?: boolean; +}; + +type SelectPostFn = (postId: string, liveWriteClock?: AnalysisRunLiveWriteClock) => void; + +/** + * Next action after a marked cutoff title opens the live body. + */ +function analysisRunOpenedPostWarning(clock: AnalysisRunLiveWriteClock): string { + const cutoffDate = clock.knowledgeCutoff.slice(0, 10); + const writtenDate = clock.updatedAt?.slice(0, 10); + if (!clock.liveAfterCutoff) { + return `This live body still matches the write clock this run knew (cutoff ${cutoffDate}).`; + } + const written = writtenDate ? ` on ${writtenDate}` : ""; + return ( + `This live body was rewritten${written}, after cutoff ${cutoffDate}. ` + + "Compare it with this run before you treat it as reconstructed evidence." + ); } function AnalysisRunReproducibilityDigests({ @@ -1615,7 +1662,7 @@ function AnalysisRunsPanel({ onSelectPost, }: { accessToken: string; - onSelectPost: (postId: string) => void; + onSelectPost: SelectPostFn; }) { const [runs, setRuns] = useState(null); const [selected, setSelected] = useState(null); @@ -1752,11 +1799,18 @@ function AnalysisRunsPanel({
  • + {post.live_after_cutoff && }
  • ))} @@ -2026,6 +2080,12 @@ function PostList({ accessToken }: { accessToken: string }) { const [graph, setGraph] = useState(null); const [error, setError] = useState(null); const [selectedPostId, setSelectedPostId] = useState(null); + const [liveWriteClock, setLiveWriteClock] = useState(null); + + function selectPost(postId: string, comparison?: AnalysisRunLiveWriteClock) { + setSelectedPostId(postId); + setLiveWriteClock(comparison ?? null); + } const [canRebuild, setCanRebuild] = useState(false); const [rebuilding, setRebuilding] = useState(false); const [rebuildError, setRebuildError] = useState(null); @@ -2057,9 +2117,9 @@ function PostList({ accessToken }: { accessToken: string }) { return ( <> - - - + + +

    Event Lineage

    @@ -2071,7 +2131,7 @@ function PostList({ accessToken }: { accessToken: string }) {
    {rebuildError &&

    {rebuildError}

    } {!graph &&

    Loading lineage graph...

    } - {graph && } + {graph && }
      {posts.map((post) => ( @@ -2079,7 +2139,7 @@ function PostList({ accessToken }: { accessToken: string }) {