From 165b4aad0b68071a8a32edf6e460082a7d4c88fa Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Sun, 16 Aug 2026 15:44:03 +0000
Subject: [PATCH 1/4] fix(ui): keep analysis-run digests audible and warn on
live posts
aria-label on the digest paragraph hid the prefixes from assistive
technology. Move the label to a group, keep prefixes as visible text,
and put the full digest on hover. Tell the operator that a cutoff
title opens the live body so they compare it with the run clock.
Co-authored-by: Seongho Bae
---
ARCHITECTURE.md | 6 +-
.../0.84.1-analysis-run-digest-a11y.md | 5 ++
CHANGELOG.md | 13 ++++
CLAUDE.md | 3 +
...016-analysis-run-knowledge-cutoff-posts.md | 20 ++++-
.../ANALYSIS_RUN_REGISTRY_REFERENCES.md | 6 +-
frontend/package.json | 2 +-
frontend/src/App.css | 21 ++++-
frontend/src/App.test.tsx | 31 +++++++-
frontend/src/App.tsx | 76 +++++++++++++++----
lineageweave/__init__.py | 2 +-
pyproject.toml | 2 +-
uv.lock | 2 +-
13 files changed, 161 insertions(+), 28 deletions(-)
create mode 100644 CHANGELOG.d/0.84.1-analysis-run-digest-a11y.md
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 2492ee50d..063b7a196 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -471,8 +471,10 @@ revision and configuration digest prefixes.
`tepp_client` on that same snapshot; the default transport is
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, counts, status history)
-without exposing a DSN or raw record. Status history is detail-only
+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
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.84.1-analysis-run-digest-a11y.md b/CHANGELOG.d/0.84.1-analysis-run-digest-a11y.md
new file mode 100644
index 000000000..213eb5451
--- /dev/null
+++ b/CHANGELOG.d/0.84.1-analysis-run-digest-a11y.md
@@ -0,0 +1,5 @@
+# 0.84.1 Analysis-run digest a11y and live-body warning
+
+Detail prefixes stay audible and hoverable. Open a cutoff title only
+after reading that the live body may have changed since the run.
+The list stays aggregates-only.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c36b2666d..22b372c35 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,19 @@ 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.84.1] - 2026-08-16
+
+### Fixed
+
+- Analysis-run detail keeps 12-character digest prefixes as visible
+ text (so assistive technology hears `Code` / `Config` values) and
+ puts the full digest on hover. Open the Demo Corp lineage run, hover
+ a prefix, and match it to the API payload. The home list still hides
+ digests even when the list JSON includes them.
+- Opening a cutoff title now says the live body may have changed after
+ that run. Compare the opened post with the cutoff date before you
+ treat it as reconstructed evidence (ADR 0016).
+
## [0.84.0] - 2026-08-16
### Added
diff --git a/CLAUDE.md b/CLAUDE.md
index 0a2950e91..7c588daa6 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -14,3 +14,6 @@ theta or a local psychometric substitute. The home list caption stays
(ADR 0014). Open a Failed TEPP row, then connect a live TEPP
transport. A failed lineage row retries reconstruction -- it does not
mention TEPP.
+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).
diff --git a/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md
index d6ac70db8..f9c82a86d 100644
--- a/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md
+++ b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md
@@ -25,7 +25,15 @@ 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.
+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.
+
+Reproducibility digests on the same detail use a labeled group whose
+accessible name does not replace the visible prefixes (W3C Accessible
+Name and Description Computation 1.1). Full digests stay on `title`
+for hover verification and on the API payload; the home list stays
+aggregates-only.
Seed and API fixtures backdate in-cutoff posts. A late own-corp private
post remains on the live post list and stays out of the January 2026
@@ -36,8 +44,10 @@ 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, then open a listed post, to inspect what that cutoff
- actually reconstructed.
+- Open the run, read the live-body warning, then open a listed post
+ and compare it with the cutoff date.
+- 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.
## References
@@ -48,3 +58,7 @@ rules* (confirmed 2024; Amendment 1:2022).
World Wide Web Consortium. (2022). *Time ontology in OWL* (W3C
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/
diff --git a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md
index a1dc73957..b41b31c17 100644
--- a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md
+++ b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md
@@ -8,7 +8,8 @@
| 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). |
+| 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 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. |
| 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, and exclusion of raw source/provider payloads. |
@@ -98,5 +99,8 @@ PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation:
World Wide Web Consortium. (2013). *PROV-O: The PROV ontology* (W3C
Recommendation). https://www.w3.org/TR/prov-o/
+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. (2022). *Time ontology in OWL* (W3C Recommendation).
https://www.w3.org/TR/owl-time/
diff --git a/frontend/package.json b/frontend/package.json
index c21ed209f..8ce5f334b 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
- "version": "0.84.0",
+ "version": "0.84.1",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/App.css b/frontend/src/App.css
index dfd0f2e81..8f38b4dd0 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -85,9 +85,26 @@
cursor: pointer;
}
+:root {
+ --lw-opacity-meta: 0.7;
+ --lw-font-size-meta: 0.85rem;
+}
+
.post-meta {
- opacity: 0.7;
- font-size: 0.85rem;
+ opacity: var(--lw-opacity-meta);
+ font-size: var(--lw-font-size-meta);
+}
+
+.visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+ border: 0;
}
.post-body {
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index d8d82c8d7..c77d965b5 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -293,6 +293,9 @@ describe("App, authenticated", () => {
count_value: 3,
},
],
+ code_revision_sha: "abcdef0123456789deadbeefcafebabe",
+ configuration_sha256:
+ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
},
{
analysis_run_id: "run-demo-tepp",
@@ -1460,6 +1463,12 @@ describe("App, authenticated", () => {
expect(list).toHaveTextContent("3 documents");
expect(list).not.toHaveTextContent("postgresql://");
expect(list).not.toHaveTextContent("select ");
+ expect(list).not.toHaveTextContent("Code abcdef012345");
+ expect(list).not.toHaveTextContent("Config 0123456789ab");
+ expect(list).not.toHaveTextContent("abcdef0123456789deadbeefcafebabe");
+ expect(list).not.toHaveTextContent(
+ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
+ );
await userEvent.click(
screen.getByRole("button", {
@@ -1470,21 +1479,39 @@ describe("App, authenticated", () => {
expect(screen.getByText(/Cutoff 2026-01-12/)).toBeInTheDocument();
expect(screen.getByText(/Requested 2026-01-12/)).toBeInTheDocument();
const digests = screen.getByLabelText("Analysis run reproducibility digests");
+ expect(digests).toHaveTextContent("Hover a prefix to read the full digest for verification.");
expect(digests).toHaveTextContent("Code abcdef012345");
expect(digests).toHaveTextContent("Config 0123456789ab");
expect(digests).not.toHaveTextContent("abcdef0123456789deadbeefcafebabe");
expect(digests).not.toHaveTextContent(
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
);
+ expect(screen.getByTitle("abcdef0123456789deadbeefcafebabe")).toHaveTextContent("Code abcdef012345");
+ expect(
+ screen.getByTitle("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"),
+ ).toHaveTextContent("Config 0123456789ab");
const history = screen.getByRole("list", { name: "Analysis run status history" });
expect(history).toHaveTextContent("Pending 2026-01-12 12:31");
expect(history).toHaveTextContent("Running 2026-01-12 12:32");
expect(history).toHaveTextContent("Succeeded 2026-01-12 12:33");
expect(screen.getByRole("list", { name: "Posts known at this run cutoff" })).toBeInTheDocument();
- expect(screen.getByRole("button", { name: "Open run post: Public post" })).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.",
+ ),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", {
+ name: "Open live post (may have changed after cutoff): Public post",
+ }),
+ ).toBeInTheDocument();
expect(screen.queryByText(/postgresql:\/\//)).not.toBeInTheDocument();
- await userEvent.click(screen.getByRole("button", { name: "Open run post: Public post" }));
+ await userEvent.click(
+ screen.getByRole("button", {
+ name: "Open live post (may have changed after cutoff): Public post",
+ }),
+ );
await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument());
await userEvent.click(
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 65af9596c..948035430 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1406,6 +1406,62 @@ function analysisRunCorpusHint(run: AnalysisRun): string | null {
return "These posts are the cutoff corpus this TEPP run measured.";
}
+/** Git-style prefix. The full digest stays on `title` for verification. */
+const ANALYSIS_RUN_DIGEST_PREFIX_LENGTH = 12;
+
+function analysisRunDigestPrefix(digest: string): string {
+ return digest.slice(0, ANALYSIS_RUN_DIGEST_PREFIX_LENGTH);
+}
+
+/**
+ * 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.
+ */
+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."
+ );
+}
+
+function analysisRunLivePostButtonLabel(postTitle: string): string {
+ return `Open live post (may have changed after cutoff): ${postTitle}`;
+}
+
+function AnalysisRunReproducibilityDigests({
+ codeRevisionSha,
+ configurationSha256,
+}: {
+ codeRevisionSha?: string;
+ configurationSha256?: string;
+}) {
+ if (!codeRevisionSha && !configurationSha256) {
+ return null;
+ }
+ return (
+
+
+
+ Hover a prefix to read the full digest for verification.{" "}
+
+ {codeRevisionSha ? (
+ {`Code ${analysisRunDigestPrefix(codeRevisionSha)}`}
+ ) : null}
+ {codeRevisionSha && configurationSha256 ? " · " : null}
+ {configurationSha256 ? (
+
+ {`Config ${analysisRunDigestPrefix(configurationSha256)}`}
+
+ ) : null}
+
+
+ );
+}
+
function AnalysisRunsPanel({
accessToken,
onSelectPost,
@@ -1488,19 +1544,10 @@ function AnalysisRunsPanel({
{" · "}
Requested {selected.requested_at.slice(0, 10)}
- {(selected.code_revision_sha || selected.configuration_sha256) && (
-
- {selected.code_revision_sha
- ? `Code ${selected.code_revision_sha.slice(0, 12)}`
- : ""}
- {selected.code_revision_sha && selected.configuration_sha256
- ? " · "
- : ""}
- {selected.configuration_sha256
- ? `Config ${selected.configuration_sha256.slice(0, 12)}`
- : ""}
-
- )}
+