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.88.0-analysis-run-cutoff-body-warning.md b/CHANGELOG.d/0.88.0-analysis-run-cutoff-body-warning.md new file mode 100644 index 000000000..0562ff696 --- /dev/null +++ b/CHANGELOG.d/0.88.0-analysis-run-cutoff-body-warning.md @@ -0,0 +1,4 @@ +# 0.88.0 Analysis-run cutoff body warning + +Opening a title marked updated after cutoff now says the popup body is +live. The earlier text is not stored, so the popup does not invent it. diff --git a/CHANGELOG.d/0.88.0-analysis-run-live-write-clock.md b/CHANGELOG.d/0.88.0-analysis-run-live-write-clock.md new file mode 100644 index 000000000..7a967633d --- /dev/null +++ b/CHANGELOG.d/0.88.0-analysis-run-live-write-clock.md @@ -0,0 +1,5 @@ +# 0.88.0 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..044a399ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ 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.88.0] - 2026-08-16 + +### Added + +- Opening an analysis-run title marked **Updated after cutoff** now + shows a popup status that the body is live, not a cutoff snapshot + (ADR 0016). After `make seed`, open the Demo Corp lineage run and + click Demo public post: the warning appears above the live body. + Demo private post and the home post list do not. The earlier text + is not stored, so the popup does not invent it. + +- 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. + ## [0.87.0] - 2026-08-16 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 870c77f87..122e348c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,6 +3,15 @@ Tool-specific pointer. Policy lives in [AGENTS.md](AGENTS.md) and the ADRs under `docs/adr/`. Do not fork those rules here. +## Analysis-run write clock (v0.88.0) + +Open the Demo Corp lineage run after `make seed`. Demo public post is +marked **Updated after cutoff**; Demo private post is not. Opening the +marked title shows a live-body status above the text — the earlier +version is not stored, so the popup does not invent it. Compare that +body with the cutoff before treating it as reconstructed evidence +(ADR 0016). The home post list and unmarked titles stay quiet. + ## Analysis-run retention (v0.87.0) To empty a run-bearing registry, insert an unrevoked @@ -29,7 +38,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..685e7ad05 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -305,11 +305,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 +339,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 +512,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 diff --git a/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md index 089443374..dc855a964 100644 --- a/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md +++ b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md @@ -25,9 +25,12 @@ 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. Opening a marked title shows a popup status that the body is +live; 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. Reproducibility digests on the same detail use a labeled group whose accessible name does not replace the visible prefixes (W3C Accessible @@ -44,11 +47,15 @@ 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. Opening the + marked title shows a live-body status; the private title and the + home post list do 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. The popup states + that honesty instead of inventing the earlier text. - 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..8f465a482 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. | diff --git a/frontend/package.json b/frontend/package.json index 0d43d9fa2..4c66c7205 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.87.0", + "version": "0.88.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index 5251e69f8..71d1018cc 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -147,6 +147,14 @@ opacity: 0.7; } +.popup-live-body-warning { + margin: 0.75rem 0 1rem; + padding: 0.65rem 0.75rem; + border-left: 3px solid #b45309; + background: color-mix(in srgb, canvas 88%, #b45309 12%); + font-size: 0.85rem; +} + .popup-section { margin-top: 1.5rem; padding-top: 1rem; diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index fd8a15146..1d735319c 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()); @@ -1719,6 +1742,42 @@ describe("App, authenticated", () => { expect(teppHistory).not.toHaveTextContent("Succeeded"); }); + it("warns that a cutoff-rewritten title opens the live body, not a snapshot", async () => { + stubBackend(); + render(); + + await userEvent.click( + await screen.findByRole("button", { + name: "Open analysis run: Lineage reconstruction · Succeeded · Demo Corp", + }), + ); + await userEvent.click( + await screen.findByRole("button", { + name: "Open live post (updated after cutoff): Public post", + }), + ); + await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); + expect(screen.getByRole("status")).toHaveTextContent( + "This is the live body, not the version known at the 2026-01-12 analysis-run cutoff. The earlier text is not stored, so this popup does not invent it.", + ); + + await userEvent.click(screen.getByRole("button", { name: "Close" })); + await userEvent.click( + screen.getByRole("button", { + name: "Open live post: Private post", + }), + ); + await waitFor(() => + expect(screen.getByText("The evidence panel should show exactly this text.")).toBeInTheDocument(), + ); + expect(screen.queryByRole("status")).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")).not.toBeInTheDocument(); + }); + it("does not tell a failed lineage run to connect the measurement service", async () => { stubBackend({ failedLineageRun: true }); render(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 07088e9d4..291e0cbad 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1161,6 +1161,7 @@ function PostDetailPopup({ accessToken, canExtract, graph, + liveBodyWarning, onClose, onSelectPost, }: { @@ -1168,6 +1169,7 @@ function PostDetailPopup({ accessToken: string; canExtract: boolean; graph: LineageGraph | null; + liveBodyWarning?: string | null; onClose: () => void; onSelectPost?: (postId: string) => void; }) { @@ -1242,6 +1244,11 @@ function PostDetailPopup({ {post.visibility_label ?? post.visibility_code} ·{" "} {new Date(post.created_at).toLocaleString()}

+ {liveBodyWarning ? ( +

+ {liveBodyWarning} +

+ ) : null}
@@ -1561,23 +1568,51 @@ function analysisRunDigestPrefix(digest: string): string { return digest.slice(0, ANALYSIS_RUN_DIGEST_PREFIX_LENGTH); } +type SelectPostOptions = { + liveAfterCutoff?: boolean; + knowledgeCutoff?: 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. + * 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. */ 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." + ); +} + +/** + * Popup honesty 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. + */ +function analysisRunOpenedBodyWarning(cutoffIso?: string | null): string { + const cutoffDate = cutoffIso?.slice(0, 10); + const when = cutoffDate ? `the ${cutoffDate} ` : ""; + return ( + `This is the live body, not the version known at ${when}analysis-run cutoff. ` + + "The earlier text is not stored, so this popup does not invent it." ); } -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({ @@ -1615,7 +1650,7 @@ function AnalysisRunsPanel({ onSelectPost, }: { accessToken: string; - onSelectPost: (postId: string) => void; + onSelectPost: (postId: string, options?: SelectPostOptions) => void; }) { const [runs, setRuns] = useState(null); const [selected, setSelected] = useState(null); @@ -1752,11 +1787,19 @@ function AnalysisRunsPanel({
  • + {post.live_after_cutoff && ( + Updated after cutoff + )}
  • ))} @@ -2026,10 +2069,24 @@ function PostList({ accessToken }: { accessToken: string }) { const [graph, setGraph] = useState(null); const [error, setError] = useState(null); const [selectedPostId, setSelectedPostId] = useState(null); + const [openedAfterCutoff, setOpenedAfterCutoff] = useState(false); + const [openedCutoffIso, setOpenedCutoffIso] = useState(null); const [canRebuild, setCanRebuild] = useState(false); const [rebuilding, setRebuilding] = useState(false); const [rebuildError, setRebuildError] = useState(null); + function selectPost(postId: string, options?: SelectPostOptions) { + setSelectedPostId(postId); + setOpenedAfterCutoff(Boolean(options?.liveAfterCutoff)); + setOpenedCutoffIso(options?.knowledgeCutoff ?? null); + } + + function closeSelectedPost() { + setSelectedPostId(null); + setOpenedAfterCutoff(false); + setOpenedCutoffIso(null); + } + useEffect(() => { fetchPosts(accessToken).then(setPosts).catch((err) => setError(String(err))); fetchLineageGraph(accessToken).then(setGraph).catch(() => setGraph({ nodes: [], edges: [] })); @@ -2057,9 +2114,9 @@ function PostList({ accessToken }: { accessToken: string }) { return ( <> - - - + + +

    Event Lineage

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

    {rebuildError}

    } {!graph &&

    Loading lineage graph...

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