Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.d/0.88.0-analysis-run-cutoff-body-warning.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 14 additions & 5 deletions docs/adr/0016-analysis-run-knowledge-cutoff-posts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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/
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "0.87.0",
"version": "0.88.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
:root {
--lw-opacity-meta: 0.7;
--lw-font-size-meta: 0.85rem;
--lw-color-warning: #b45309;
}

.post-meta {
Expand Down Expand Up @@ -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;
Expand Down
42 changes: 42 additions & 0 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<App />);

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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Name Live body warning here too. An unnamed status query fails if the run detail later grows its own live region, even when this popup is honest.


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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same AccName as the related-post assertion above. The home list must stay warning-free; the name keeps that check tied to this status.

});

it("does not tell a failed lineage run to connect the measurement service", async () => {
stubBackend({ failedLineageRun: true });
render(<App />);
Expand Down
79 changes: 67 additions & 12 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1163,13 +1163,15 @@ function PostDetailPopup({
accessToken,
canExtract,
graph,
liveBodyWarning,
onClose,
onSelectPost,
}: {
postId: string;
accessToken: string;
canExtract: boolean;
graph: LineageGraph | null;
liveBodyWarning?: string | null;
onClose: () => void;
onSelectPost?: (postId: string) => void;
}) {
Expand Down Expand Up @@ -1246,6 +1248,15 @@ function PostDetailPopup({
{post.visibility_label ?? post.visibility_code} &middot;{" "}
{new Date(post.created_at).toLocaleString()}
</p>
{liveBodyWarning ? (
<p
className="popup-live-body-warning"
role="status"
aria-label="Live body warning"
>
{liveBodyWarning}
</p>
) : null}
<PostBody body={post.post_body} />

<section className="popup-section">
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -1606,7 +1639,7 @@ function AnalysisRunsPanel({
onSelectPost,
}: {
accessToken: string;
onSelectPost: (postId: string) => void;
onSelectPost: (postId: string, options?: SelectPostOptions) => void;
}) {
const [runs, setRuns] = useState<AnalysisRun[] | null>(null);
const [selected, setSelected] = useState<AnalysisRun | null>(null);
Expand Down Expand Up @@ -1742,7 +1775,12 @@ function AnalysisRunsPanel({
<button
className="keyman-select"
aria-label={analysisRunLivePostButtonLabel(post)}
onClick={() => onSelectPost(post.post_id)}
onClick={() =>
onSelectPost(post.post_id, {
liveAfterCutoff: Boolean(post.live_after_cutoff),
knowledgeCutoff: selected.knowledge_cutoff,
})
}
>
{post.post_title}
</button>
Expand Down Expand Up @@ -2018,10 +2056,24 @@ function PostList({ accessToken }: { accessToken: string }) {
const [graph, setGraph] = useState<LineageGraph | null>(null);
const [error, setError] = useState<string | null>(null);
const [selectedPostId, setSelectedPostId] = useState<string | null>(null);
const [openedAfterCutoff, setOpenedAfterCutoff] = useState(false);
const [openedCutoffIso, setOpenedCutoffIso] = useState<string | null>(null);
const [canRebuild, setCanRebuild] = useState(false);
const [rebuilding, setRebuilding] = useState(false);
const [rebuildError, setRebuildError] = useState<string | null>(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: [] }));
Expand Down Expand Up @@ -2049,9 +2101,9 @@ function PostList({ accessToken }: { accessToken: string }) {

return (
<>
<CalendarPanel accessToken={accessToken} onSelectPost={setSelectedPostId} />
<AnalysisRunsPanel accessToken={accessToken} onSelectPost={setSelectedPostId} />
<ReportsPanel accessToken={accessToken} canRebuild={canRebuild} onSelectPost={setSelectedPostId} />
<CalendarPanel accessToken={accessToken} onSelectPost={selectPost} />
<AnalysisRunsPanel accessToken={accessToken} onSelectPost={selectPost} />
<ReportsPanel accessToken={accessToken} canRebuild={canRebuild} onSelectPost={selectPost} />
<section className="popup-section lineage-home">
<div className="lineage-home-header">
<h2>Event Lineage</h2>
Expand All @@ -2063,15 +2115,15 @@ function PostList({ accessToken }: { accessToken: string }) {
</div>
{rebuildError && <p className="error">{rebuildError}</p>}
{!graph && <p>Loading lineage graph...</p>}
{graph && <LineageDag graph={graph} onSelectPost={setSelectedPostId} />}
{graph && <LineageDag graph={graph} onSelectPost={selectPost} />}
</section>
<ul className="post-list">
{posts.map((post) => (
<li key={post.post_id}>
<button
className="post-list-item"
aria-label={`View post: ${post.post_title}`}
onClick={() => setSelectedPostId(post.post_id)}
onClick={() => selectPost(post.post_id)}
>
<span className="post-title">{post.post_title}</span>
<span className="post-badge">{post.voc_type_label ?? post.voc_type_code}</span>
Expand All @@ -2086,8 +2138,11 @@ function PostList({ accessToken }: { accessToken: string }) {
accessToken={accessToken}
canExtract={canRebuild}
graph={graph}
onClose={() => setSelectedPostId(null)}
onSelectPost={setSelectedPostId}
liveBodyWarning={
openedAfterCutoff ? analysisRunOpenedBodyWarning(openedCutoffIso) : null
}
onClose={closeSelectedPost}
onSelectPost={selectPost}
/>
)}
</>
Expand Down
2 changes: 1 addition & 1 deletion lineageweave/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,4 @@
"sentence_excerpts",
]

__version__ = "0.87.0"
__version__ = "0.88.0"
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "lineageweave"
version = "0.87.0"
version = "0.88.0"
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" }
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading