diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index aa557bd7a..26c817cde 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -489,7 +489,9 @@ its machine `failure_code` rather than an invented caption. Failed TEPP list rows add a next-action line (open the run, then connect the measurement service) so `tepp_not_available` is not mistaken for a calibrated negative result. A failed lineage row tells the operator -to retry reconstruction, not to connect TEPP. The +to retry reconstruction, not to connect TEPP. A failed period-report +row tells the operator to rebuild the report. A pending TEPP row +does not claim a calibrated measurement. The payload is lookup labels plus non-negative aggregate counts -- never source SQL, a DSN, a raw record, or a provider body. After `make seed`, Demo Analyst and Demo Admin see "Lineage reconstruction · Succeeded · diff --git a/CHANGELOG.d/0.84.0-tepp-analysis-run.md b/CHANGELOG.d/0.84.0-tepp-analysis-run.md index c96531899..df127a9d4 100644 --- a/CHANGELOG.d/0.84.0-tepp-analysis-run.md +++ b/CHANGELOG.d/0.84.0-tepp-analysis-run.md @@ -4,3 +4,5 @@ Seed writes `analysis_run_tepp` via `tepp_client` on the shared Demo Corp snapshot. The home list shows Failed and a kind-specific next action; detail history keeps `tepp_not_available`. Missing transport is not a fake measurement. A failed lineage row does not mention TEPP. +A failed period-report row rebuilds the report. A pending TEPP row +does not claim a calibrated measurement. diff --git a/CHANGELOG.d/0.86.0-related-nodes-team-org-walk.md b/CHANGELOG.d/0.86.0-related-nodes-team-org-walk.md index 4efa8100f..4989a054f 100644 --- a/CHANGELOG.d/0.86.0-related-nodes-team-org-walk.md +++ b/CHANGELOG.d/0.86.0-related-nodes-team-org-walk.md @@ -1,2 +1,4 @@ Related-node walks include team and organization mention edges. Click an R&R team to open sibling posts. Thread-group run lists honor knowledge_cutoff. +Failed period-report rows rebuild the report; a pending TEPP corpus +does not claim a calibrated measurement. diff --git a/CHANGELOG.md b/CHANGELOG.md index 434c4d63b..6bf4dc62a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,9 @@ All notable changes to this project are documented here. Format follows - Thread-group analysis-run *lists* now require an in-cutoff visible post. A later public post in that thread group no longer surfaces a January run the account was not allowed to know. +- Failed period-report rows tell the operator to rebuild the report. + Next-action copy is pinned to the registered run kinds. A pending + TEPP corpus does not claim a calibrated measurement. ## [0.85.0] - 2026-08-16 @@ -62,7 +65,10 @@ All notable changes to this project are documented here. Format follows snapshot-count inserts once counts exist so a re-run does not hit the freeze trigger. A failed lineage row tells the operator to retry reconstruction; only a failed TEPP row mentions the measurement - service. Stacked PRs now run the same GitHub Checks as PRs to main. + service. A failed period-report row tells the operator to rebuild + the report from a current snapshot. A pending TEPP row does not + claim a calibrated measurement. Stacked PRs now run the same + GitHub Checks as PRs to main. ## [0.83.0] - 2026-08-16 diff --git a/CLAUDE.md b/CLAUDE.md index 2f89bada7..a11127584 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,8 @@ theta or a local psychometric substitute. The home list caption stays `kind · status · entity`; the machine failure code is detail-only (ADR 0014). Open a Failed TEPP row, then connect a live TEPP transport. A failed lineage row retries reconstruction -- it does not -mention TEPP. +mention TEPP. A failed period-report row rebuilds the report. A +pending TEPP row does not claim a calibrated measurement. 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/0014-authorized-analysis-run-read.md b/docs/adr/0014-authorized-analysis-run-read.md index 9188187fb..29c074c8f 100644 --- a/docs/adr/0014-authorized-analysis-run-read.md +++ b/docs/adr/0014-authorized-analysis-run-read.md @@ -45,7 +45,10 @@ kinds without a second application. The TEPP run is Failed / keeps that machine code off the caption (this decision) and instead tells the operator to open the TEPP run, then connect the measurement service. A failed lineage row tells the operator to retry -reconstruction, not to connect TEPP. The detail now shows the legal +reconstruction, not to connect TEPP. A failed period-report row +tells the operator to rebuild the report from a current snapshot. +A pending or running TEPP row must not claim a calibrated +measurement. The detail now shows the legal lifecycle the registry already stored. `POST /api/analysis-runs` now records a Pending run on an authorized cutoff capture (ADR 0017). Reconstruction, a live TEPP transport, and a fuller Analysis Run diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 934f150e4..8618fcfbf 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -60,7 +60,9 @@ describe("App, authenticated", () => { searchUnavailable?: boolean; verificationEvidenceUrl?: string | null; failedLineageRun?: boolean; + failedReportRun?: boolean; succeededTeppRun?: boolean; + pendingTeppRun?: boolean; }) { const statusLabel: Record = { open: "Open", @@ -171,21 +173,19 @@ describe("App, authenticated", () => { jsonResponse({ post_id: "post-1", has_commitment: true, ticket }), ); } - if (url.endsWith("/api/analysis-runs/run-demo-tepp")) { + if (url.endsWith("/api/analysis-runs/run-demo-report")) { return Promise.resolve( jsonResponse({ - analysis_run_id: "run-demo-tepp", - run_kind_code: "analysis_run_tepp", - run_kind_label: "TEPP measurement", + analysis_run_id: "run-demo-report", + run_kind_code: "analysis_run_report", + run_kind_label: "Period report", scope_kind_code: "analysis_scope_corporate_entity", scope_kind_label: "Corporate entity", scope_entity_name: "Demo Corp", - status_code: options?.succeededTeppRun - ? "analysis_status_succeeded" - : "analysis_status_failed", - status_label: options?.succeededTeppRun ? "Succeeded" : "Failed", + status_code: "analysis_status_failed", + status_label: "Failed", knowledge_cutoff: "2026-01-12T12:00:00Z", - requested_at: "2026-01-12T12:34:00Z", + requested_at: "2026-01-12T12:38:00Z", source_counts: [ { count_type_code: "analysis_count_document", @@ -193,32 +193,90 @@ describe("App, authenticated", () => { count_value: 3, }, ], - visible_posts: [{ post_id: "post-1", post_title: "Public post" }], + visible_posts: [], status_history: [ { status_ordinal: 1, status_code: "analysis_status_pending", status_label: "Pending", - occurred_at: "2026-01-12T12:35:00Z", + occurred_at: "2026-01-12T12:39:00Z", }, { status_ordinal: 2, - status_code: "analysis_status_running", - status_label: "Running", - occurred_at: "2026-01-12T12:36:00Z", + status_code: "analysis_status_failed", + status_label: "Failed", + occurred_at: "2026-01-12T12:40:00Z", + failure_code: "period_report_rebuild_failed", }, + ], + }), + ); + } + if (url.endsWith("/api/analysis-runs/run-demo-tepp")) { + const teppStatus = options?.succeededTeppRun + ? "analysis_status_succeeded" + : options?.pendingTeppRun + ? "analysis_status_pending" + : "analysis_status_failed"; + const teppLabel = options?.succeededTeppRun + ? "Succeeded" + : options?.pendingTeppRun + ? "Pending" + : "Failed"; + return Promise.resolve( + jsonResponse({ + analysis_run_id: "run-demo-tepp", + run_kind_code: "analysis_run_tepp", + run_kind_label: "TEPP measurement", + scope_kind_code: "analysis_scope_corporate_entity", + scope_kind_label: "Corporate entity", + scope_entity_name: "Demo Corp", + status_code: teppStatus, + status_label: teppLabel, + knowledge_cutoff: "2026-01-12T12:00:00Z", + requested_at: "2026-01-12T12:34:00Z", + source_counts: [ { - status_ordinal: 3, - status_code: options?.succeededTeppRun - ? "analysis_status_succeeded" - : "analysis_status_failed", - status_label: options?.succeededTeppRun ? "Succeeded" : "Failed", - occurred_at: "2026-01-12T12:37:00Z", - ...(options?.succeededTeppRun - ? {} - : { failure_code: "tepp_not_available" }), + count_type_code: "analysis_count_document", + count_type_label: "Documents", + count_value: 3, }, ], + visible_posts: [{ post_id: "post-1", post_title: "Public post" }], + status_history: options?.pendingTeppRun + ? [ + { + status_ordinal: 1, + status_code: "analysis_status_pending", + status_label: "Pending", + occurred_at: "2026-01-12T12:35:00Z", + }, + ] + : [ + { + status_ordinal: 1, + status_code: "analysis_status_pending", + status_label: "Pending", + occurred_at: "2026-01-12T12:35:00Z", + }, + { + status_ordinal: 2, + status_code: "analysis_status_running", + status_label: "Running", + occurred_at: "2026-01-12T12:36:00Z", + }, + { + status_ordinal: 3, + status_code: options?.succeededTeppRun + ? "analysis_status_succeeded" + : "analysis_status_failed", + status_label: options?.succeededTeppRun ? "Succeeded" : "Failed", + occurred_at: "2026-01-12T12:37:00Z", + ...(options?.succeededTeppRun + ? {} + : { failure_code: "tepp_not_available" }), + }, + ], }), ); } @@ -331,8 +389,14 @@ describe("App, authenticated", () => { scope_entity_name: "Demo Corp", status_code: options?.succeededTeppRun ? "analysis_status_succeeded" - : "analysis_status_failed", - status_label: options?.succeededTeppRun ? "Succeeded" : "Failed", + : options?.pendingTeppRun + ? "analysis_status_pending" + : "analysis_status_failed", + status_label: options?.succeededTeppRun + ? "Succeeded" + : options?.pendingTeppRun + ? "Pending" + : "Failed", knowledge_cutoff: "2026-01-12T12:00:00Z", requested_at: "2026-01-12T12:34:00Z", source_counts: [ @@ -343,6 +407,29 @@ describe("App, authenticated", () => { }, ], }, + ...(options?.failedReportRun + ? [ + { + analysis_run_id: "run-demo-report", + run_kind_code: "analysis_run_report" as const, + run_kind_label: "Period report", + scope_kind_code: "analysis_scope_corporate_entity", + scope_kind_label: "Corporate entity", + scope_entity_name: "Demo Corp", + status_code: "analysis_status_failed" as const, + status_label: "Failed", + knowledge_cutoff: "2026-01-12T12:00:00Z", + requested_at: "2026-01-12T12:38:00Z", + source_counts: [ + { + count_type_code: "analysis_count_document", + count_type_label: "Documents", + count_value: 3, + }, + ], + }, + ] + : []), ], }), ); @@ -1615,18 +1702,58 @@ describe("App, authenticated", () => { stubBackend({ failedLineageRun: true }); render(); - const list = await screen.findByRole("list", { name: "Analysis runs" }); - expect(list).toHaveTextContent("Lineage reconstruction · Failed · Demo Corp"); - expect(list).toHaveTextContent( + await screen.findByRole("list", { name: "Analysis runs" }); + const lineageButton = screen.getByRole("button", { + name: "Open analysis run: Lineage reconstruction · Failed · Demo Corp", + }); + const teppButton = screen.getByRole("button", { + name: "Open analysis run: TEPP measurement · Failed · Demo Corp", + }); + expect(lineageButton).toHaveTextContent( "Open this run to see why it failed, then retry reconstruction from a current snapshot.", ); - expect(list).toHaveTextContent( + expect(lineageButton).not.toHaveTextContent("measurement service"); + expect(teppButton).toHaveTextContent( "Open this run to see why it failed, then connect the measurement service and re-run.", ); - const lineageButton = screen.getByRole("button", { - name: "Open analysis run: Lineage reconstruction · Failed · Demo Corp", + expect(teppButton).not.toHaveTextContent("reconstruction"); + }); + + it("does not tell a failed period report to connect the measurement service", async () => { + stubBackend({ failedReportRun: true }); + render(); + + const reportButton = await screen.findByRole("button", { + name: "Open analysis run: Period report · Failed · Demo Corp", }); - expect(lineageButton).not.toHaveTextContent("measurement service"); + expect(reportButton).toHaveTextContent( + "Open this run to see why it failed, then rebuild the period report from a current snapshot.", + ); + expect(reportButton).not.toHaveTextContent("measurement service"); + expect(reportButton).not.toHaveTextContent("reconstruction"); + + await userEvent.click(reportButton); + expect( + await screen.findByText( + "No posts were available at this cutoff for the period report. Open a later run, or ask an administrator to capture a newer snapshot.", + ), + ).toBeInTheDocument(); + }); + + it("does not tell a pending TEPP run that it already measured", async () => { + stubBackend({ pendingTeppRun: true }); + render(); + + await userEvent.click( + await screen.findByRole("button", { + name: "Open analysis run: TEPP measurement · Pending · Demo Corp", + }), + ); + expect( + await screen.findByText("These posts are the cutoff corpus TEPP will measure once this run finishes."), + ).toBeInTheDocument(); + expect(screen.queryByText(/replace Failed/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/this TEPP run measured/i)).not.toBeInTheDocument(); }); it("does not tell a succeeded TEPP run to replace Failed", async () => { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 83fb88860..35a080aa2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1468,8 +1468,12 @@ function analysisRunNextAction(run: AnalysisRun): string | null { return "Open this run to see why it failed, then connect the measurement service and re-run."; case "analysis_run_lineage": return "Open this run to see why it failed, then retry reconstruction from a current snapshot."; - default: - return "Open this run to see why it failed, then retry after the blocking service is connected."; + case "analysis_run_report": + return "Open this run to see why it failed, then rebuild the period report from a current snapshot."; + default: { + const unexpected: never = run.run_kind_code; + return unexpected; + } } } @@ -1477,32 +1481,60 @@ function analysisRunNextAction(run: AnalysisRun): string | null { * Empty-corpus copy that tells the operator what to do next. */ function analysisRunEmptyPostsHint(run: AnalysisRun): string { - if (run.run_kind_code === "analysis_run_tepp") { - return ( - "No posts were available at this cutoff for TEPP to measure. " + - "Open a later run, or ask an administrator to capture a newer snapshot." - ); + switch (run.run_kind_code) { + case "analysis_run_tepp": + return ( + "No posts were available at this cutoff for TEPP to measure. " + + "Open a later run, or ask an administrator to capture a newer snapshot." + ); + case "analysis_run_lineage": + return ( + "No posts were available at this cutoff for reconstruction. " + + "Open a later run, or ask an administrator to capture a newer snapshot." + ); + case "analysis_run_report": + return ( + "No posts were available at this cutoff for the period report. " + + "Open a later run, or ask an administrator to capture a newer snapshot." + ); + default: { + const unexpected: never = run.run_kind_code; + return unexpected; + } } - return ( - "No posts were available at this cutoff. Open a later run, or ask an " + - "administrator to capture a newer snapshot." - ); } /** * Corpus copy for a TEPP run that already has cutoff posts. * * Those titles are the measurement bag, not a reconstruction result. + * Pending or running must not claim a calibrated measurement. */ function analysisRunCorpusHint(run: AnalysisRun): string | null { if (run.run_kind_code !== "analysis_run_tepp") return null; - if (run.status_code === "analysis_status_failed") { - return ( - "These posts are the cutoff corpus TEPP would measure. Connect a TEPP " + - "transport, then re-run, to replace Failed with a calibrated result." - ); + switch (run.status_code) { + case "analysis_status_failed": + return ( + "These posts are the cutoff corpus TEPP would measure. Connect a TEPP " + + "transport, then re-run, to replace Failed with a calibrated result." + ); + case "analysis_status_succeeded": + return "These posts are the cutoff corpus this TEPP run measured."; + case "analysis_status_pending": + case "analysis_status_running": + return "These posts are the cutoff corpus TEPP will measure once this run finishes."; + case "analysis_status_cancelled": + return ( + "These posts are the cutoff corpus this TEPP run would have measured. " + + "The run was cancelled before a calibrated result." + ); + case null: + return "These posts are the cutoff corpus attached to this TEPP run."; + default: { + const unexpected: never = run.status_code; + return unexpected; + } } - return "These posts are the cutoff corpus this TEPP run measured."; } /** Git-style prefix. The full digest stays on `title` for verification. */ diff --git a/frontend/src/api.ts b/frontend/src/api.ts index f9bd4068e..3385d5179 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -514,9 +514,23 @@ export interface AnalysisRunCount { count_value: number; } +/** Registry kinds from `analysis_run.run_kind_code` (migration 0018). */ +export type AnalysisRunKindCode = + | "analysis_run_lineage" + | "analysis_run_report" + | "analysis_run_tepp"; + +/** Registry statuses from `analysis_run_status_event.status_code`. */ +export type AnalysisRunStatusCode = + | "analysis_status_pending" + | "analysis_status_running" + | "analysis_status_succeeded" + | "analysis_status_failed" + | "analysis_status_cancelled"; + export interface AnalysisRunStatusEvent { status_ordinal: number; - status_code: string; + status_code: AnalysisRunStatusCode; status_label: string; occurred_at: string; failure_code?: string; @@ -524,12 +538,12 @@ export interface AnalysisRunStatusEvent { export interface AnalysisRun { analysis_run_id: string; - run_kind_code: string; + run_kind_code: AnalysisRunKindCode; run_kind_label: string; scope_kind_code: string; scope_kind_label: string; scope_entity_name?: string; - status_code: string | null; + status_code: AnalysisRunStatusCode | null; status_label: string | null; knowledge_cutoff: string; requested_at: string;