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..71e671038 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,7 +3,7 @@ Tool-specific pointer. Policy lives in [AGENTS.md](AGENTS.md) and the ADRs under `docs/adr/`. Do not fork those rules here. -## Analysis-run seed (v0.84.0) +## Analysis-run seed (v0.84.1) `make seed` writes a Demo Corp lineage run and a TEPP run on the same snapshot (ADR 0013). The TEPP path goes through `tepp_client`. A missing @@ -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)}` - : ""} -

- )} +