diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bf19c5f77..e2fe01e72 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -482,8 +482,9 @@ 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; titles rewritten after the run are marked +updated after cutoff. 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.3-analysis-run-live-write-clock.md b/CHANGELOG.d/0.87.3-analysis-run-live-write-clock.md new file mode 100644 index 000000000..5129ed528 --- /dev/null +++ b/CHANGELOG.d/0.87.3-analysis-run-live-write-clock.md @@ -0,0 +1,5 @@ +# 0.87.3 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. Bodies stay live. diff --git a/CHANGELOG.md b/CHANGELOG.md index 00a19fe92..ca8b8bddd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ 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.3] - 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 -- cutoff body versioning + stays a later slice (ADR 0016). The list stays aggregates-only. + No TEPP theta is invented. The mark uses the `PostBadge` token + module (`cd frontend && pnpm run storybook`). A title or body + rewrite stamps `updated_at` unless the statement sets that clock + (migration 0022). + ## [0.87.0] - 2026-08-16 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 870c77f87..4c92e9807 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,7 +29,8 @@ 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; compare those bodies +before treating them as reconstructed evidence (ADR 0016). `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/tests/test_api.py b/backend/tests/test_api.py index 3b74c22a3..0d5c6a177 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -33,6 +33,7 @@ _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" / "0022_source_post_write_clock.sql" def _postgres_available() -> bool: @@ -117,6 +118,7 @@ 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( "insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values " "('corporate_entity_level', 'group', 'Group'), " @@ -305,11 +307,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 +341,14 @@ def _insert_post( "A follow-up written after the January 2026 run cutoff.", created_at="2026-01-20T12:00:00Z", ) + _insert_post( + "Edited own-corp private post", + own_corp_id, + "private", + "A January post rewritten after the run cutoff.", + created_at="2026-01-10T12:00:00Z", + updated_at="2026-01-13T09:00:00Z", + ) cur.execute( "insert into cataloged_person (person_name, person_side_code) values " @@ -494,8 +514,14 @@ 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 "postgresql://" not in str(body) assert "visible_posts" not in visible @@ -587,12 +613,57 @@ 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" +def test_body_rewrite_moves_the_write_clock_unless_the_statement_sets_it( + client, demo_analyst_token, seeded_db +) -> None: + """A later body write is live-after-cutoff; an explicit clock stays put.""" + conn = psycopg2.connect(seeded_db["dsn"]) + try: + with conn.cursor() as cur: + cur.execute( + "update source_post set post_body = %s where post_id = %s", + ("Rewritten after the January run.", seeded_db["own_private_post_id"]), + ) + cur.execute( + "select post_id from source_post where post_title = %s", + ("Edited own-corp private post",), + ) + edited_id = str(cur.fetchone()[0]) + cur.execute( + "update source_post set post_body = %s, updated_at = %s " + "where post_id = %s", + ("Still the January rewrite.", "2026-01-13T09:00:00Z", edited_id), + ) + cur.execute( + "update source_post set thread_group_key = %s where post_title = %s", + ("thread-group-rewrite", "Edited own-corp private post"), + ) + conn.commit() + finally: + conn.close() + + detail = client.get( + f"/api/analysis-runs/{seeded_db['visible_run_id']}", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert detail.status_code == 200 + posts = {post["post_title"]: post for post in detail.json()["visible_posts"]} + assert posts["Own-corp private post"]["live_after_cutoff"] is True + assert posts["Edited own-corp private post"]["live_after_cutoff"] is True + assert posts["Edited own-corp private post"]["updated_at"].startswith("2026-01-13") + + def test_post_detail_uses_lookup_labels_not_raw_codes(client, demo_analyst_token, seeded_db) -> None: response = client.get( f"/api/posts/{seeded_db['public_post_id']}", diff --git a/docker/postgres-init/Dockerfile b/docker/postgres-init/Dockerfile index ce2f0e6b5..03635ab9b 100644 --- a/docker/postgres-init/Dockerfile +++ b/docker/postgres-init/Dockerfile @@ -26,6 +26,7 @@ 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/0022_source_post_write_clock.sql /docker-entrypoint-initdb.d/22-source-post-write-clock.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..bb2d762a8 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,14 @@ 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. - 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. +- Post-body versioning at the cutoff remains future work. The write + clock is a projection, not a stored cutoff body. Migration 0022 + stamps `updated_at` on title or body rewrites and honors an explicit + `updated_at` so `make seed` can keep Demo public post at 2026-01-13. - 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/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md index c776053b1..e006b9125 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. | | 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. | @@ -71,6 +71,7 @@ provenance, retention, and immutable evidence rather than blanket masking. |---|---| | One snapshot supports multiple analyses | Insert two runs over one snapshot with different valid cutoffs. | | Future evidence is excluded | Reject a run whose cutoff precedes the snapshot's maximum availability time. A late own-corp post stays out of `visible_posts`. | +| Live write clock is distinct from admission | An in-cutoff title with `updated_at` after the cutoff is marked `live_after_cutoff`; equal clocks stay unmarked. After `make seed`, Demo public post is the 2026-01-13 counter-example. A later title or body rewrite stamps `updated_at` unless the statement sets that clock (migration 0022). | | Evidence cannot change after derivation | Reject snapshot/count updates and count insert/delete after the first run. | | Count/run race is serialized | Both paths acquire the snapshot row first; a later concurrency test must prove one legal winner and no lost freeze. | | Request identity is stable | Reject analysis-run updates; scope and lifecycle live in their own relations. | diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index 282e3515e..4a8ff23c9 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -7,6 +7,7 @@ buyer-facing control you can click before changing product CSS. |---|---|---| | `Evidence/CitationChip` | Click a cited title to open that source post. | `--color-chip-border`, `--radius-chip`, `CitationChip` | | `Chrome/PopupCloseButton` | Close the evidence panel or post popup. | `--space-close-inset`, `--font-size-close`, `PopupCloseButton` | +| `AnalysisRun/PostBadge` | Open the marked title and compare the live body with this run. | `--font-size-badge`, `--opacity-badge`, `PostBadge` | Repeated web objects must use `frontend/src/styles/tokens.css` and a module under `frontend/src/components/`. Do not add a second Node package manager; diff --git a/frontend/package.json b/frontend/package.json index 0d43d9fa2..ad8163077 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.87.0", + "version": "0.87.3", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index 5251e69f8..96445923d 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -49,8 +49,8 @@ } .post-badge { - font-size: 0.75rem; - opacity: 0.7; + font-size: var(--font-size-badge); + opacity: var(--opacity-badge); text-transform: uppercase; } diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index fd8a15146..f7468ba5b 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", @@ -1688,19 +1701,29 @@ 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()); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 07088e9d4..91b1a7425 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -59,6 +59,7 @@ import { type VocEvidence, } from "./api"; import { CitationChip } from "./components/CitationChip"; +import { PostBadge } from "./components/PostBadge"; import { PopupCloseButton } from "./components/PopupCloseButton"; import { LineageDag } from "./LineageDag"; import { PostBody } from "./PostBody"; @@ -1564,20 +1565,27 @@ 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}`; } function AnalysisRunReproducibilityDigests({ @@ -1752,11 +1760,14 @@ function AnalysisRunsPanel({
  • + {post.live_after_cutoff && ( + Updated after cutoff + )}
  • ))} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 3385d5179..9eaaba8b8 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -536,6 +536,13 @@ export interface AnalysisRunStatusEvent { failure_code?: string; } +export interface AnalysisRunVisiblePost { + post_id: string; + post_title: string; + updated_at?: string; + live_after_cutoff?: boolean; +} + export interface AnalysisRun { analysis_run_id: string; run_kind_code: AnalysisRunKindCode; @@ -549,7 +556,7 @@ export interface AnalysisRun { requested_at: string; source_counts: AnalysisRunCount[]; status_history?: AnalysisRunStatusEvent[]; - visible_posts?: { post_id: string; post_title: string }[]; + visible_posts?: AnalysisRunVisiblePost[]; code_revision_sha?: string; configuration_sha256?: string; } diff --git a/frontend/src/components/PostBadge.stories.tsx b/frontend/src/components/PostBadge.stories.tsx new file mode 100644 index 000000000..068e99f78 --- /dev/null +++ b/frontend/src/components/PostBadge.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PostBadge } from "./PostBadge"; + +const meta = { + title: "AnalysisRun/PostBadge", + component: PostBadge, + args: { + children: "Updated after cutoff", + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const UpdatedAfterCutoff: Story = {}; + +export const InCutoff: Story = { + args: { + children: "3 documents", + }, +}; diff --git a/frontend/src/components/PostBadge.test.tsx b/frontend/src/components/PostBadge.test.tsx new file mode 100644 index 000000000..ad833019d --- /dev/null +++ b/frontend/src/components/PostBadge.test.tsx @@ -0,0 +1,10 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { PostBadge } from "./PostBadge"; + +describe("PostBadge", () => { + it("tells the operator the live title was rewritten after the run", () => { + render(Updated after cutoff); + expect(screen.getByText("Updated after cutoff")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/PostBadge.tsx b/frontend/src/components/PostBadge.tsx new file mode 100644 index 000000000..5da77dc69 --- /dev/null +++ b/frontend/src/components/PostBadge.tsx @@ -0,0 +1,12 @@ +export type PostBadgeProps = { + children: string; +}; + +/** + * Marks a list row with a compact status the operator can act on. + * + * Next action: read the mark, then open the row it labels. + */ +export function PostBadge({ children }: PostBadgeProps) { + return {children}; +} diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css index e3510b83c..17238399a 100644 --- a/frontend/src/styles/tokens.css +++ b/frontend/src/styles/tokens.css @@ -14,6 +14,8 @@ --space-close-inset: 0.75rem; --radius-chip: 999px; --font-size-close: 1.5rem; + --font-size-badge: 0.75rem; + --opacity-badge: 0.7; --font-family-chip: ui-monospace, Consolas, monospace; } diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 1950c39f8..9d1b2667d 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.87.0" +__version__ = "0.87.3" diff --git a/migrations/0022_source_post_write_clock.sql b/migrations/0022_source_post_write_clock.sql new file mode 100644 index 000000000..d7d00e89c --- /dev/null +++ b/migrations/0022_source_post_write_clock.sql @@ -0,0 +1,26 @@ +-- Keep source_post.updated_at as the live write clock (ADR 0016). +-- An explicit updated_at in the UPDATE is honored so historical seed +-- rows can keep their authoring instant. Otherwise the trigger stamps +-- now(). Two-word relation and column names stay snake_case. + +create or replace function set_source_post_updated_at() +returns trigger +language plpgsql +as $$ +begin + if new.post_title is not distinct from old.post_title + and new.post_body is not distinct from old.post_body then + return new; + end if; + if new.updated_at is not distinct from old.updated_at then + new.updated_at = now(); + end if; + return new; +end; +$$; + +drop trigger if exists source_post_set_updated_at on source_post; +create trigger source_post_set_updated_at + before update on source_post + for each row + execute function set_source_post_updated_at(); diff --git a/migrations/rollback/0022_source_post_write_clock.sql b/migrations/rollback/0022_source_post_write_clock.sql new file mode 100644 index 000000000..bb105e80e --- /dev/null +++ b/migrations/rollback/0022_source_post_write_clock.sql @@ -0,0 +1,5 @@ +-- Remove the live write-clock trigger. The updated_at column remains +-- on source_post from the initial schema. + +drop trigger if exists source_post_set_updated_at on source_post; +drop function if exists set_source_post_updated_at(); diff --git a/pyproject.toml b/pyproject.toml index ecfe24877..7913e402c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.87.0" +version = "0.87.3" 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 2f3c66c45..0df5b8978 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -122,6 +122,7 @@ def seed( cur.execute((migrations / "0018_analysis_run_registry.sql").read_text()) cur.execute((migrations / "0019_role_catalog_identity.sql").read_text()) cur.execute((migrations / "0020_analysis_run_retention_purge.sql").read_text()) + cur.execute((migrations / "0022_source_post_write_clock.sql").read_text()) cur.execute( """ insert into common_lookup_value (lookup_category, lookup_code, lookup_label, display_order) values @@ -233,32 +234,38 @@ def seed( cur.execute("select post_id from source_post where post_title = 'Demo public post'") if cur.fetchone() 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) " + "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')", + "'voc', 'public', '2026-01-10T12:00:00Z', '2026-01-13T09:00:00Z')", (account_ids["demo.analyst"], corporate_entity_id, process_units["DEMO-PU-A"]), ) 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) " - "values (%s, %s, %s, 'Demo private post', 'A synthetic private post scoped to Demo Corp accounts.', 'vom', 'private', '2026-01-10T12:00:00Z')", + "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"]), ) - cur.execute( - "update source_post set created_at = '2026-01-10T12:00:00Z' " - "where post_title in ('Demo public post', 'Demo private post') " - "and created_at > '2026-01-12T12:00:00Z'" - ) 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", + "update source_post set post_body = %s, " + "updated_at = '2026-01-13T09:00:00Z' 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( + "update source_post set created_at = '2026-01-10T12:00:00Z', " + "updated_at = '2026-01-13T09:00:00Z' " + "where post_title = 'Demo public post'" + ) + 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( "insert into post_counterparty_entity (post_id, counterparty_entity_name, relationship_type_code) " "values (%s, 'Northridge Grid', 'rel_voc'), (%s, 'Demo Corp', 'rel_voc') " @@ -327,6 +334,16 @@ def seed( process_units["DEMO-PU-LINEAGE"], ) _seed_fixture_keymen_and_voc(cur, corporate_entity_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'" + ) + 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'" + ) _seed_fixture_summaries(cur) _seed_fixture_chats(cur) _seed_fixture_evaluations(cur) @@ -377,8 +394,8 @@ def insert_fixture_source_posts(cur, author_account_id, corporate_entity_id, pro "insert into source_post " "(author_account_id, corporate_entity_id, process_unit_id, " " post_title, post_body, voc_type_code, visibility_code, " - " thread_group_key, secondary_grouping_key, created_at) " - "values (%s, %s, %s, %s, %s, %s, 'public', %s, %s, %s) returning post_id", + " thread_group_key, secondary_grouping_key, created_at, updated_at) " + "values (%s, %s, %s, %s, %s, %s, 'public', %s, %s, %s, %s) returning post_id", ( author_account_id, corporate_entity_id, @@ -389,6 +406,7 @@ def insert_fixture_source_posts(cur, author_account_id, corporate_entity_id, pro rec.group_key, rec.secondary_key, occurred, + occurred, ), ) post_id = str(cur.fetchone()[0]) @@ -708,7 +726,8 @@ def _seed_fixture_keymen_and_voc(cur, corporate_entity_id) -> None: post_id = str(row[0]) if cast.body is not None: cur.execute( - "update source_post set post_body = %s where post_id = %s", + "update source_post set post_body = %s, updated_at = created_at " + "where post_id = %s", (cast.body, post_id), ) mentioned: list[str] = [] diff --git a/tests/test_analysis_run_create.py b/tests/test_analysis_run_create.py index 4e24a4228..4b7ffcf53 100644 --- a/tests/test_analysis_run_create.py +++ b/tests/test_analysis_run_create.py @@ -5,6 +5,7 @@ from backend.app.analysis_run_ingestion import ( AnalysisRunCreateError, _resolve_corporate_entity_id, + live_write_after_cutoff, plan_analysis_run_capture, ) import pytest @@ -124,6 +125,17 @@ def test_empty_corpus_uses_the_cutoff_as_latest_available_time() -> None: assert capture.maximum_available_time == _CUTOFF +def test_live_write_clock_is_distinct_from_the_cutoff_admission_clock() -> None: + """An in-cutoff title can still have been rewritten after the run.""" + cutoff = _CUTOFF + assert live_write_after_cutoff(_EARLIER, cutoff) is False + assert live_write_after_cutoff(cutoff, cutoff) is False + assert live_write_after_cutoff( + datetime(2026, 1, 13, 9, 0, tzinfo=timezone.utc), cutoff + ) is True + assert live_write_after_cutoff(datetime(2026, 1, 13, 9, 0), cutoff) is True + + def test_create_rejects_an_unaffiliated_or_ambiguous_corporate_entity() -> None: with pytest.raises(AnalysisRunCreateError) as hidden: _resolve_corporate_entity_id("corp-other", ["corp-1"]) diff --git a/tests/test_analysis_run_registry_schema.py b/tests/test_analysis_run_registry_schema.py index 3d185dbed..e5f052824 100644 --- a/tests/test_analysis_run_registry_schema.py +++ b/tests/test_analysis_run_registry_schema.py @@ -276,10 +276,14 @@ def test_registry_contract_is_normalized_and_has_one_temporal_authority() -> Non assert "0018_analysis_run_registry.sql" in dockerfile assert "0019_role_catalog_identity.sql" in dockerfile assert "0020_analysis_run_retention_purge.sql" in dockerfile + assert "0022_source_post_write_clock.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" ) + assert seed.index("0020_analysis_run_retention_purge.sql") < seed.index( + "0022_source_post_write_clock.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_seed_write_clocks.py b/tests/test_seed_write_clocks.py new file mode 100644 index 000000000..dfd19d77b --- /dev/null +++ b/tests/test_seed_write_clocks.py @@ -0,0 +1,29 @@ +"""Seed Demo Corp write clocks stay distinct from the January cutoff.""" + +from pathlib import Path + +_SEED = Path(__file__).resolve().parents[1] / "scripts" / "seed_demo_data.py" + + +def test_seed_demo_public_post_is_rewritten_after_the_january_cutoff() -> None: + """After make seed, only Demo public post is the edited counter-example.""" + source = _SEED.read_text(encoding="utf-8") + assert "0022_source_post_write_clock.sql" in source + assert "updated_at = '2026-01-13T09:00:00Z'" in source + assert "where post_title = 'Demo public post'" in source + assert "updated_at = '2026-01-10T12:00:00Z'" in source + assert "where post_title = 'Demo private post'" in source + assert "updated_at = created_at" in source + + +def test_write_clock_trigger_honors_an_explicit_updated_at() -> None: + """A body rewrite stamps now() unless the statement sets updated_at.""" + root = Path(__file__).resolve().parents[1] + trigger = (root / "migrations" / "0022_source_post_write_clock.sql").read_text( + encoding="utf-8" + ) + assert "source_post_set_updated_at" in trigger + assert "new.post_title is not distinct from old.post_title" in trigger + assert "new.post_body is not distinct from old.post_body" in trigger + assert "new.updated_at is not distinct from old.updated_at" in trigger + assert "new.updated_at = now()" in trigger diff --git a/uv.lock b/uv.lock index 6915a3531..602164739 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.87.0" +version = "0.87.3" source = { virtual = "." } dependencies = [ { name = "certifi" },