diff --git a/AGENTS.md b/AGENTS.md index 5fe716b87..c97691319 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,6 +122,13 @@ R&R chips read the catalog id stored on `post_summary_role` backfill leaves a role unbound when two same-named mentions already exist on the post. +A listed analysis-run that then 404s must stay generic: do not name the thread or the cutoff, +and do not say the run is not visible (ADR 0014 / ADR 0018). After +that 404, re-read the authorized list so the stale row does not stay +clickable. Announce the next action with `role="alert"` without +moving focus. Remaining visible runs stay clickable. Request remains +the named reconstruction control. + ## CI gates `.github/workflows/tests.yml` runs the full suite on every PR to `main`. diff --git a/CHANGELOG.d/2.10.3-stale-hidden-run-list.md b/CHANGELOG.d/2.10.3-stale-hidden-run-list.md new file mode 100644 index 000000000..b1d8cda09 --- /dev/null +++ b/CHANGELOG.d/2.10.3-stale-hidden-run-list.md @@ -0,0 +1,7 @@ +# 2.10.3 Drop a stale analysis-run row after its detail 404s + +Opening a listed analysis-run that then 404s drops that stale row +from the home list after an authorized re-read, announces the next +action with a status alert, and leaves Request as the named +reconstruction control. The message still does not name the thread +or the cutoff (ADR 0014 / ADR 0018). diff --git a/CHANGELOG.md b/CHANGELOG.md index 853632a3e..f2dff8f6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ 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). +## [2.10.3] - 2026-08-17 + +### Fixed + +- Opening a listed analysis-run that then 404s drops that stale row + from the home list after an authorized re-read. The next action is + announced as a status alert: open a remaining visible run, or + request a lineage reconstruction. The message still does not name + the thread or the cutoff (ADR 0014 / ADR 0018). + ## [2.10.2] - 2026-08-17 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 0e15bc773..c9636ef64 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,3 +72,7 @@ cited source. After that next action, the popup lands the first cited evidence. Changing the week first still focuses the report period field. Mean θ stays on the period-report panel. +A listed analysis-run that then 404s stays generic: do not name the +thread or the cutoff. After that 404, re-read the authorized list so +the stale row does not stay clickable. Announce the next action with +`role="alert"` without moving focus. diff --git a/docs/adr/0014-authorized-analysis-run-read.md b/docs/adr/0014-authorized-analysis-run-read.md index b6d61dcaa..4d59ceb95 100644 --- a/docs/adr/0014-authorized-analysis-run-read.md +++ b/docs/adr/0014-authorized-analysis-run-read.md @@ -60,7 +60,14 @@ lifecycle the registry already stored. `POST /api/analysis-runs` now records a Pending lineage run on an authorized cutoff capture (ADR 0017). TEPP and period-report kinds are 422. Reconstruction, a live TEPP transport, and a fuller Analysis Run Console remain later -slices. +slices. A 404 on a hidden run (including a thread-group row that +still lacks an in-cutoff visible post, ADR 0018) must stay generic: +do not name the thread or the cutoff, and do not say the run is not +visible. Tell the operator to open a visible run from the home list, +or request a lineage reconstruction for a corporation they already +walk. After that 404, re-read `GET /api/analysis-runs` so the stale +list row does not stay clickable, and announce the status with +`role="alert"` (WCAG 2.2 SC 4.1.3) without moving focus. ## References diff --git a/docs/adr/0018-related-nodes-team-org-walk.md b/docs/adr/0018-related-nodes-team-org-walk.md index ae0a1c331..c7f020eb0 100644 --- a/docs/adr/0018-related-nodes-team-org-walk.md +++ b/docs/adr/0018-related-nodes-team-org-walk.md @@ -49,6 +49,9 @@ Thread-group run list visibility requires at least one ABAC-visible organization chip. - A later public post in a thread group no longer lists a January run that could not have known that post. +- A 404 on that hidden row stays generic: do not name the thread or + the cutoff. After that 404, re-read the authorized home list so the + stale row does not stay clickable (ADR 0014). ## References diff --git a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md index 66f382504..2b963cf6d 100644 --- a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md +++ b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md @@ -11,6 +11,7 @@ | 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). Detail compares live `updated_at` with that cutoff and marks titles rewritten after the run. Seed records the built period report as a later Succeeded run on that same snapshot (ADR 0024) without copying a theta onto the registry row. The cutoff-known body is read from `source_post_revision` on the opened post, not from the run payload (ADR 0025). | | 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. List-button names include the kind-specific next-action sentence (ADR 0014; WCAG 2.2 SC 4.1.2). | +| WCAG 2.2 SC 4.1.3 Status Messages | Announce a hidden-run 404 without confirming why the row is hidden, then drop the stale list row. | `StatusAlert` (`role="alert"`) plus a post-404 `GET /api/analysis-runs` re-read (ADR 0014 / ADR 0018). | | 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. | | NIST SP 800-92 | Treat audit records as bounded, protected operational evidence rather than unstructured application logging. | Append-only status events, machine failure codes, actor identity, occurrence/record clocks, fail-closed rollback, `invoking_session_role` on each retention event, and exclusion of raw source/provider payloads. | diff --git a/docs/doctoring/DESIGN_TOKEN_REFERENCES.md b/docs/doctoring/DESIGN_TOKEN_REFERENCES.md index ac73b0921..8b23b7b83 100644 --- a/docs/doctoring/DESIGN_TOKEN_REFERENCES.md +++ b/docs/doctoring/DESIGN_TOKEN_REFERENCES.md @@ -8,8 +8,9 @@ the Storybook inventory. | Source | Product implication | Implemented evidence | |---|---|---| -| W3C Design Tokens Format Module 1.0 | Name color, space, type, and radius once; consume those names from repeated objects. | `frontend/src/styles/tokens.css` defines `--color-*`, `--space-*`, `--size-control-min`, `--radius-chip`, `--radius-control`, `--radius-panel`, and `--font-*`. `CitationChip`, `PopupCloseButton`, `CutoffKnownBody`, and `LineageEntityPicker` read those names through `App.css`. | +| W3C Design Tokens Format Module 1.0 | Name color, space, type, and radius once; consume those names from repeated objects. | `frontend/src/styles/tokens.css` defines `--color-*`, `--space-*`, `--size-control-min`, `--radius-chip`, `--radius-control`, `--radius-panel`, and `--font-*`. `CitationChip`, `PopupCloseButton`, `CutoffKnownBody`, `LineageEntityPicker`, and `StatusAlert` read those names through `App.css`. | | Storybook for React & Vite | Catalog repeated controls so a buyer can try the next click without reading `App.tsx`. | `frontend/src/components/*.stories.tsx` and `docs/storybook-inventory.md`. | +| WCAG 2.2 SC 4.1.3 Status Messages | Announce a fail-closed status without moving focus, so the operator hears the next action. | `StatusAlert` uses `role="alert"` and `--color-status-alert`. | ## APA 7th references @@ -18,3 +19,7 @@ Design Tokens Community Group. (2025). *Design Tokens Format Module 1.0* Storybook. (2026). *Storybook for React & Vite*. https://storybook.js.org/docs/get-started/frameworks/react-vite + +World Wide Web Consortium. (2023). *Web content accessibility +guidelines (WCAG) 2.2* (W3C Recommendation). +https://www.w3.org/TR/WCAG22/ diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index a535877bc..808da12a3 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -9,6 +9,7 @@ buyer-facing control you can click before changing product CSS. | `AnalysisRun/CutoffKnownBody` | Read the cutoff-known sentence, then compare it with the live body below. | `--color-accent-border`, `--space-panel-block`, `--radius-panel`, `CutoffKnownBody` | | `Analysis/LineageEntityPicker` | Choose which corp to reconstruct, then click Request a lineage reconstruction. | `--space-control-gap`, `--size-control-min`, `--radius-control`, `LineageEntityPicker` | | `Chrome/PopupCloseButton` | Close the evidence panel or post popup. | `--space-close-inset`, `--font-size-close`, `PopupCloseButton` | +| `Chrome/StatusAlert` | Hear the next action after a hidden-run 404, then open a visible run or request a reconstruction. | `--color-status-alert`, `StatusAlert` | 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; @@ -21,3 +22,7 @@ Design Tokens Community Group. (2025). *Design Tokens Format Module 1.0* Storybook. (2026). *Storybook for React & Vite*. https://storybook.js.org/docs/get-started/frameworks/react-vite + +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 9175f4cd6..709b32e5c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.10.2", + "version": "2.10.3", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index bdda84290..0ca197583 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -27,6 +27,10 @@ color: #b91c1c; } +.status-alert { + color: var(--color-status-alert); +} + .post-list { list-style: none; padding: 0; diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index e5180b90d..b759fccb6 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -74,6 +74,7 @@ describe("App, authenticated", () => { succeededReportRun?: boolean; succeededTeppRun?: boolean; pendingTeppRun?: boolean; + hiddenAnalysisRun?: boolean; pluralAffiliations?: boolean; deferMe?: boolean; meFailed?: boolean; @@ -101,6 +102,7 @@ describe("App, authenticated", () => { let nextEventId = 1; let createdPendingLineage: Record | null = null; let createdPendingTepp: Record | null = null; + let analysisRunListCalls = 0; let releaseMe = () => {}; const meReady = options?.deferMe @@ -371,6 +373,14 @@ describe("App, authenticated", () => { ); } if (url.endsWith("/api/analysis-runs/run-demo-lineage")) { + if (options?.hiddenAnalysisRun) { + return Promise.resolve( + new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }), + ); + } return Promise.resolve( jsonResponse({ analysis_run_id: "run-demo-lineage", @@ -623,12 +633,18 @@ describe("App, authenticated", () => { return Promise.resolve(new Response(JSON.stringify(created), { status: 201 })); } if (url.endsWith("/api/analysis-runs")) { + analysisRunListCalls += 1; + const includeStaleLineageRow = !( + options?.hiddenAnalysisRun && analysisRunListCalls > 1 + ); return Promise.resolve( jsonResponse({ analysis_runs: [ ...(createdPendingLineage ? [createdPendingLineage] : []), ...(createdPendingTepp ? [createdPendingTepp] : []), - { + ...(includeStaleLineageRow + ? [ + { analysis_run_id: "run-demo-lineage", run_kind_code: "analysis_run_lineage", run_kind_label: "Lineage reconstruction", @@ -657,7 +673,9 @@ describe("App, authenticated", () => { code_revision_sha: "abcdef0123456789deadbeefcafebabe", configuration_sha256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", - }, + }, + ] + : []), { analysis_run_id: "run-demo-tepp", run_kind_code: "analysis_run_tepp", @@ -2279,6 +2297,45 @@ describe("App, authenticated", () => { expect(screen.queryByRole("heading", { name: "Body this run knew" })).not.toBeInTheDocument(); }); + it("drops a stale listed run after its detail 404s and names the next action", async () => { + stubBackend({ hiddenAnalysisRun: true }); + render(); + + await screen.findByRole("list", { name: "Analysis runs" }); + await userEvent.click( + screen.getByRole("button", { + name: "Open analysis run: Lineage reconstruction · Succeeded · Demo Corp", + }), + ); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "This run is not on your list. Open a visible run from the home list, or request a lineage reconstruction for a corporation you already walk.", + ); + await waitFor(() => { + expect( + screen.queryByRole("button", { + name: "Open analysis run: Lineage reconstruction · Succeeded · Demo Corp", + }), + ).not.toBeInTheDocument(); + }); + expect( + screen.getByRole("button", { + name: "Open analysis run: TEPP measurement · Failed · Demo Corp. Open this run to see why it failed, then connect the measurement service and re-run.", + }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { + name: "Open analysis run: Period report · Succeeded · Demo Corp", + }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Request a lineage reconstruction" }), + ).toBeInTheDocument(); + expect(screen.queryByText(/not visible/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/thread-group/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/knowledge_cutoff/i)).not.toBeInTheDocument(); + }); + it("finds a failed TEPP list button by the next-action accessible name", async () => { stubBackend(); render(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 684902521..7ddd739f5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -66,6 +66,7 @@ import { CitationChip } from "./components/CitationChip"; import { CutoffKnownBody } from "./components/CutoffKnownBody"; import { LineageEntityPicker } from "./components/LineageEntityPicker"; import { PopupCloseButton } from "./components/PopupCloseButton"; +import { StatusAlert } from "./components/StatusAlert"; import { LineageDag } from "./LineageDag"; import { PostBody } from "./PostBody"; import { subgraphForPost } from "./lineageLayout"; @@ -1819,6 +1820,17 @@ function analysisRunAccessibleName(run: AnalysisRun): string { return `Open analysis run: ${caption}. ${nextAction}`; } +/** + * Next action when detail 404s. Stay generic: do not name the thread or the cutoff. + * Naming either would confirm a hidden row (ADR 0018). + */ +function analysisRunHiddenNextAction(): string { + return ( + "This run is not on your list. Open a visible run from the home list, " + + "or request a lineage reconstruction for a corporation you already walk." + ); +} + /** * Empty-corpus copy that tells the operator what to do next. */ @@ -2172,18 +2184,24 @@ function AnalysisRunsPanel({ } catch (err) { setSelected(null); if (err instanceof BackendError && err.status === 404) { - setError("This analysis run is not visible."); + setError(analysisRunHiddenNextAction()); + try { + setRuns((await fetchAnalysisRuns(accessToken)).analysis_runs); + } catch { + // Keep the last authorized list if the re-read fails. + } return; } setError(String(err)); } } - if (error && runs === null) return

{error}

; + if (error && runs === null) return {error}; if (runs === null) return

Loading analysis runs...

; const corpusHint = selected ? analysisRunCorpusHint(selected) : null; const selectedNextAction = selected ? analysisRunNextAction(selected) : null; + const statusMessage = error ?? entitiesLoadError; return (
@@ -2208,7 +2226,7 @@ function AnalysisRunsPanel({ {requestLabel} - {(error || entitiesLoadError) &&

{error ?? entitiesLoadError}

} + {statusMessage ? {statusMessage} : null} {runs.length === 0 ? (

No analysis runs visible to this account yet. Request a lineage diff --git a/frontend/src/components/StatusAlert.stories.tsx b/frontend/src/components/StatusAlert.stories.tsx new file mode 100644 index 000000000..947aeedff --- /dev/null +++ b/frontend/src/components/StatusAlert.stories.tsx @@ -0,0 +1,23 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { StatusAlert } from "./StatusAlert"; + +const meta = { + title: "Chrome/StatusAlert", + component: StatusAlert, + args: { + children: + "This run is not on your list. Open a visible run from the home list, or request a lineage reconstruction for a corporation you already walk.", + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const HiddenAnalysisRun: Story = {}; + +export const ListLoadFailure: Story = { + args: { + children: "BackendError: 503 Service Unavailable", + }, +}; diff --git a/frontend/src/components/StatusAlert.test.tsx b/frontend/src/components/StatusAlert.test.tsx new file mode 100644 index 000000000..ebca0552c --- /dev/null +++ b/frontend/src/components/StatusAlert.test.tsx @@ -0,0 +1,23 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { StatusAlert } from "./StatusAlert"; + +describe("StatusAlert", () => { + it("announces the next action as an alert without moving focus", () => { + render( + + This run is not on your list. Open a visible run from the home list, or request a lineage reconstruction for a corporation you already walk. + , + ); + + const alert = screen.getByRole("alert"); + expect(alert).toHaveTextContent( + "This run is not on your list. Open a visible run from the home list, or request a lineage reconstruction for a corporation you already walk.", + ); + expect(alert.tagName).toBe("P"); + expect(document.activeElement).not.toBe(alert); + expect(screen.queryByText(/not visible/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/thread-group/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/knowledge_cutoff/i)).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/StatusAlert.tsx b/frontend/src/components/StatusAlert.tsx new file mode 100644 index 000000000..f8710b881 --- /dev/null +++ b/frontend/src/components/StatusAlert.tsx @@ -0,0 +1,18 @@ +export type StatusAlertProps = { + children: string; +}; + +/** + * Announces a fail-closed status so the operator hears the next action. + * + * Uses `role="alert"` (WCAG 2.2 SC 4.1.3) without moving focus. + * Next action: read the sentence, then use the control it names + * (open a visible run, or request a lineage reconstruction). + */ +export function StatusAlert({ children }: StatusAlertProps) { + return ( +

+ {children} +

+ ); +} diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css index fc92405f7..7722cb16a 100644 --- a/frontend/src/styles/tokens.css +++ b/frontend/src/styles/tokens.css @@ -8,6 +8,7 @@ --color-accent-background: rgba(170, 59, 255, 0.1); --color-accent-border: rgba(170, 59, 255, 0.5); --color-chip-border: #3335; + --color-status-alert: #b91c1c; --space-chip-inline: 0.6rem; --space-chip-block: 0.1rem; --space-chip-gap: 0.3rem; @@ -34,5 +35,6 @@ --color-accent-background: rgba(192, 132, 252, 0.15); --color-accent-border: rgba(192, 132, 252, 0.5); --color-chip-border: #9ca3af; + --color-status-alert: #f87171; } } diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 8fda891a2..aa53eb9eb 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "2.10.2" +__version__ = "2.10.3" diff --git a/pyproject.toml b/pyproject.toml index b04c3ca26..5cb375082 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.10.2" +version = "2.10.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/tests/test_ingestion_transaction_contracts.py b/tests/test_ingestion_transaction_contracts.py index 5a4e284b3..55a94ab4d 100644 --- a/tests/test_ingestion_transaction_contracts.py +++ b/tests/test_ingestion_transaction_contracts.py @@ -642,6 +642,36 @@ async def persist_edges(conn, post_id) -> list[Any]: assert mention_inserts == [] +def test_hidden_run_copy_stays_generic_and_drops_the_stale_row() -> None: + """ADR 0014/0018: a 404 must not confirm why a row is hidden.""" + + app = ( + Path(__file__).resolve().parents[1] / "frontend" / "src" / "App.tsx" + ).read_text(encoding="utf-8") + alert = ( + Path(__file__).resolve().parents[1] + / "frontend" + / "src" + / "components" + / "StatusAlert.tsx" + ).read_text(encoding="utf-8") + agents = ( + Path(__file__).resolve().parents[1] / "AGENTS.md" + ).read_text(encoding="utf-8") + assert "This analysis run is not visible." not in app + assert "This run is not on your list. Open a visible run from the home list," in app + assert ( + "or request a lineage reconstruction for a corporation you already walk." + in app + ) + assert "do not name the thread or the cutoff" in app + assert "setRuns((await fetchAnalysisRuns(accessToken)).analysis_runs)" in app + assert 'role="alert"' in alert + assert "{error}" in app + assert "re-read the authorized list" in agents + assert "do not name the thread or the cutoff" in agents + + def test_role_catalog_identity_is_stored_on_the_role_row() -> None: """ADR 0019: fetch must not reconstruct organization identity by name.""" root = Path(__file__).resolve().parents[1] diff --git a/uv.lock b/uv.lock index 127875bd5..025598df7 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "2.10.2" +version = "2.10.3" source = { virtual = "." } dependencies = [ { name = "certifi" },