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..7794ac5f3 --- /dev/null +++ b/CHANGELOG.d/0.88.0-analysis-run-cutoff-body-warning.md @@ -0,0 +1,5 @@ +# 0.88.0 Analysis-run cutoff body warning + +Opening a title marked updated after cutoff now says the popup body is +live. Compare it with this run before treating it as reconstructed +evidence. diff --git a/CHANGELOG.md b/CHANGELOG.md index 333616792..5ef65924b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.88.0] - 2026-08-17 + +### 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 says to compare that live body + with this run before treating it as reconstructed evidence. Demo + private post, a related post opened from that popup, and the home + post list do not. No TEPP theta is invented. + ## [0.87.0] - 2026-08-17 ### Added diff --git a/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md index 274d34dbd..23a1d88e1 100644 --- a/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md +++ b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md @@ -27,9 +27,12 @@ Click-through still opens the live post body -- post versioning is a later slice -- but the run list itself must not advertise a post the run was not allowed to know. Detail compares the live `updated_at` write clock with `knowledge_cutoff` and marks titles rewritten after -the run. The next action is specific: only those marked titles need a -cutoff comparison before treating the live body as reconstructed -evidence. +the run. Opening a marked title shows a popup status that the body is +live, not a cutoff snapshot. The next action is specific: compare that +live body with this run before treating it as reconstructed evidence. +That status uses a stable accessible name so a later live region cannot +replace it (WCAG 2.2 Success Criterion 4.1.3; W3C Accessible Name and +Description Computation 1.1). Reproducibility digests on the same detail use a labeled group whose accessible name does not replace the visible prefixes (W3C Accessible @@ -47,11 +50,14 @@ run. and other in-cutoff Demo Corp titles. The later fixture account-review post (2026-02-10) does not appear. - Open the run: Demo public post is marked updated after cutoff - (`updated_at` 2026-01-13). Demo private post is not. + (`updated_at` 2026-01-13). Demo private post is not. Opening the + marked title shows a live-body status that names the compare-with- + this-run action; 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. The write - clock is a projection, not a stored cutoff body. + clock is a projection, not a stored cutoff body. The popup names + the compare action 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. @@ -68,3 +74,6 @@ Recommendation). https://www.w3.org/TR/owl-time/ World Wide Web Consortium. (2018). *Accessible name and description computation 1.1* (W3C Recommendation). https://www.w3.org/TR/accname-1.1/ + +World Wide Web Consortium. (2023). *Web content accessibility guidelines +(WCAG) 2.2* (W3C Recommendation). https://www.w3.org/TR/WCAG22/ diff --git a/frontend/package.json b/frontend/package.json index c93670d28..a9cf709cf 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 b3fab25d1..f48c36a3a 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -88,6 +88,7 @@ :root { --lw-opacity-meta: 0.7; --lw-font-size-meta: 0.85rem; + --lw-color-warning: #b45309; } .post-meta { @@ -147,6 +148,14 @@ opacity: 0.7; } +.popup-live-body-warning { + margin: 0.75rem 0 1rem; + padding: 0.65rem 0.75rem; + border-left: 3px solid var(--lw-color-warning); + background: color-mix(in srgb, canvas 88%, var(--lw-color-warning) 12%); + font-size: var(--lw-font-size-meta); +} + .popup-section { margin-top: 1.5rem; padding-top: 1rem; diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 92c3fa16f..ff91e73ef 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1754,6 +1754,48 @@ 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", { name: "Live body warning" })).toHaveTextContent( + "This is the live body, not a cutoff snapshot. Compare it with this 2026-01-12 run before you treat it as reconstructed evidence.", + ); + + await userEvent.click(screen.getAllByRole("button", { name: "Open post: Linked post" })[0]); + await waitFor(() => + expect(screen.getByText("The evidence panel should show exactly this text.")).toBeInTheDocument(), + ); + expect(screen.queryByRole("status", { name: "Live body warning" })).not.toBeInTheDocument(); + + 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 ac5ca7cf7..8be0c0cea 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1163,6 +1163,7 @@ function PostDetailPopup({ accessToken, canExtract, graph, + liveBodyWarning, onClose, onSelectPost, }: { @@ -1170,6 +1171,7 @@ function PostDetailPopup({ accessToken: string; canExtract: boolean; graph: LineageGraph | null; + liveBodyWarning?: string | null; onClose: () => void; onSelectPost?: (postId: string) => void; }) { @@ -1246,6 +1248,15 @@ function PostDetailPopup({ {post.visibility_label ?? post.visibility_code} ·{" "} {new Date(post.created_at).toLocaleString()}

+ {liveBodyWarning ? ( +

+ {liveBodyWarning} +

+ ) : null}
@@ -1545,12 +1556,18 @@ 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. Titles marked - * `live_after_cutoff` were rewritten after this run; others still - * match the write clock the run knew. + * 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); @@ -1561,6 +1578,22 @@ function analysisRunLivePostWarning(cutoffIso: string): string { ); } +/** + * Next action when a marked cutoff title opens the live body. + * + * ADR 0016 does not store a historical snapshot. The operator must + * compare this live text with the run before treating it as + * reconstructed evidence. + */ +function analysisRunOpenedBodyWarning(cutoffIso?: string | null): string { + const cutoffDate = cutoffIso?.slice(0, 10); + const when = cutoffDate ? `this ${cutoffDate} run` : "this run"; + return ( + `This is the live body, not a cutoff snapshot. Compare it with ${when} ` + + "before you treat it as reconstructed evidence." + ); +} + function analysisRunLivePostButtonLabel(post: { post_title: string; live_after_cutoff?: boolean; @@ -1606,7 +1639,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); @@ -1742,7 +1775,12 @@ function AnalysisRunsPanel({ @@ -2018,10 +2056,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: [] })); @@ -2049,9 +2101,9 @@ function PostList({ accessToken }: { accessToken: string }) { return ( <> - - - + + +

Event Lineage

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

{rebuildError}

} {!graph &&

Loading lineage graph...

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