From 296fa3054d2d5db3b615b525fe5f0e2278558c6d Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Mon, 17 Aug 2026 01:30:33 +0900
Subject: [PATCH] feat: warn that cutoff-rewritten titles open the live body
(v0.88.0)
Opening a marked analysis-run title still shows the live post. The
popup now says so instead of inventing a cutoff snapshot.
---
...0.88.0-analysis-run-cutoff-body-warning.md | 4 +
CHANGELOG.md | 12 +++
...016-analysis-run-knowledge-cutoff-posts.md | 14 ++--
frontend/package.json | 2 +-
frontend/src/App.css | 8 ++
frontend/src/App.test.tsx | 36 +++++++++
frontend/src/App.tsx | 74 ++++++++++++++++---
lineageweave/__init__.py | 2 +-
pyproject.toml | 2 +-
uv.lock | 2 +-
10 files changed, 135 insertions(+), 21 deletions(-)
create mode 100644 CHANGELOG.d/0.88.0-analysis-run-cutoff-body-warning.md
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.md b/CHANGELOG.md
index 333616792..037b11287 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 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. 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..dc855a964 100644
--- a/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md
+++ b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md
@@ -27,9 +27,10 @@ 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; 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
@@ -47,11 +48,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; 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 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/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..0f24508d1 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 92c3fa16f..42b99c766 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -1754,6 +1754,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 ac5ca7cf7..5ce509519 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,11 @@ function PostDetailPopup({
{post.visibility_label ?? post.visibility_code} ·{" "}
{new Date(post.created_at).toLocaleString()}
+ {liveBodyWarning ? (
+
+ {liveBodyWarning}
+
+ ) : null}
@@ -1545,12 +1552,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 +1574,21 @@ function analysisRunLivePostWarning(cutoffIso: string): string {
);
}
+/**
+ * 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(post: {
post_title: string;
live_after_cutoff?: boolean;
@@ -1606,7 +1634,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 +1770,12 @@ function AnalysisRunsPanel({
@@ -2018,10 +2051,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 +2096,9 @@ function PostList({ accessToken }: { accessToken: string }) {
return (
<>
-
-
-
+
+
+