diff --git a/CHANGELOG.d/2.21.2-cancelled-analysis-next-actions.md b/CHANGELOG.d/2.21.2-cancelled-analysis-next-actions.md
new file mode 100644
index 000000000..5fb2e6c89
--- /dev/null
+++ b/CHANGELOG.d/2.21.2-cancelled-analysis-next-actions.md
@@ -0,0 +1,5 @@
+# 2.21.2 Name the next action for every cancelled analysis kind
+
+Cancelled lineage, TEPP, topic-lineage, and period-report runs now tell the
+operator the kind-specific valid next action. A terminal cancellation no
+longer leaves the analysis list and detail without guidance.
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index a6a525e31..d3cfdf986 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -84,8 +84,8 @@ context only.
| PR | Exact observed head | Merge/check state at this snapshot |
| ---: | --- | --- |
-| #667 | `dbc438e2` | baseline refresh branch exact head at this snapshot; BLOCKED with review required |
-| #668 | `facbae58` | evidence-bound project history projection with normalized action keys; BLOCKED with review required |
+| #667 | `a9731d52` | baseline refresh branch exact head at this snapshot; BLOCKED with review required |
+| #668 | `f272f4b0` | evidence-bound project history projection with request guards and normalized keys; BLOCKED with review required |
| #663 | `7ac1483e` | project ontology traversal, cutoff-snapshot project focus, bounded MCP admission, and migration-fixture/worker startup repair; BLOCKED with review required |
| #658 | `f497a6e8` | evidence-honest Global Ask cutoff with robust revision timestamps; BLOCKED with review required |
| #657 | `a59a2023` | TEPP lifecycle persistence; BLOCKED with review required |
@@ -372,6 +372,7 @@ this file per §3.5 of the prior snapshot).
| --- | --- | --- |
| Protected release | Protected `main@494b54e2` includes the #660/#664 semantic-unit/backend stack and #659 ontology readability/token repair. Twelve PRs remain open; every candidate still requires exact-head checks, unresolved-thread review, and independent approval before merge | Terminal exact-head checks, no unresolved threads, two independent exact-head approvals including last-push approval, protected squash-merge SHA |
| Evidence-grounded operations workspace | Protected-main #614 delivers governed semantic Ask, live Similar VOC, disjoint pending/failed analysis metrics, full Storybook state inventory, and current desktop/mobile screenshot evidence. Authorized-corpus backfill acceptance remains unavailable | Perform authenticated authorized-corpus acceptance with aggregate evidence and retain fail-closed no-match behavior |
+| Cancelled analysis guidance | PRD-FR-5 requires every lifecycle state to identify a valid next action, while ADR 0013 makes Cancelled terminal. Protected `main@494b54e2` rendered Cancelled without a next action. This stacked candidate adds kind-specific guidance for lineage, TEPP, topic lineage, and period reports; 390×844 and 1440×1000 authenticated synthetic-runtime audits are retained in `docs/screenshots/cancelled-analysis-runs-{mobile,desktop}.png`. The audit also found and repaired attached count/action text and the three-column mobile squeeze | Land through the protected gate, then repeat authenticated keyboard and screen-reader acceptance on the exact release head; no cancelled run may imply that it can resume or that a measurement exists |
| Shared frontend gate | The ADR 0109 login repair is on protected `main`; eight older branches carried the defect and received the same verified repair this loop (#521–#560) | Keep every future branch cut from post-repair bases; re-verify with frontend lint/test/build before push |
| Identifying baseline regression | `main` gap file listed real post identifiers; separately, closed #506 and pre-existing public history contain a private runtime source-table identifier, while current `main` and #507 trees are clean | Land this non-identifying rewrite, then coordinate ADR 0001 history remediation with security/privacy owners; do not reproduce the value, force-push, or delete evidence ad hoc |
| Authorized-corpus runtime | Repository tests use synthetic fixtures; private records remain outside git | Authenticated runtime validation returning only aggregate, non-identifying evidence |
@@ -408,10 +409,10 @@ before a commercial release claim. Figma File ID `1Su3lDRmiZdcUs47t1QwIX`.
| Touch & Interaction | Click-first popup and lists | 44px targets, swipe/escape to dismiss popup, no hover-only actions |
| Performance | Board caps and hint render limits exist | Interaction-to-next-paint on board search, DAG, Ask; no N+1 query regressions |
| Style Selection | Korean UI standards merged (#347) | Tokenized light/dark; Anti-Slop-UI density; no decorative noise |
-| Layout & Responsive | Desktop popup shell | 402px-class phone layout; stacked GNB; readable DAG |
+| Layout & Responsive | Desktop popup shell; this candidate stacks analysis-run caption, document count, and next action at 390px and retains explicit grid columns at 1440px | Complete 402px-class phone acceptance for remaining panels; stacked GNB; readable DAG |
| Typography & Color | Badge tokens extracted | Contrast on badges, links, error/status; no raw hex in components |
| Animation | Minimal | Reduced-motion; no blocking animation on evidence open |
-| Forms & Feedback | Login, Ask, tickets, admin brand | Inline validation, next-action copy, unavailable vs failed distinction |
+| Forms & Feedback | Login, Ask, tickets, admin brand; this candidate gives all four Cancelled analysis kinds an evidence-safe next action | Inline validation and unavailable-vs-failed acceptance across remaining workflows |
| Navigation Patterns | Board / customers / calendar / Ask / admin | Deep-link post + OIDC return URL (#426); bookmarkable Ask |
| Charts & Data | Period reports, leftover pairs, Rankings, DAG | Honest empty/unavailable; no invented theta; Storybook chart states |
diff --git a/docs/screenshots/cancelled-analysis-runs-desktop.png b/docs/screenshots/cancelled-analysis-runs-desktop.png
new file mode 100644
index 000000000..f1c0f5d56
Binary files /dev/null and b/docs/screenshots/cancelled-analysis-runs-desktop.png differ
diff --git a/docs/screenshots/cancelled-analysis-runs-mobile.png b/docs/screenshots/cancelled-analysis-runs-mobile.png
new file mode 100644
index 000000000..64ed8bd6d
Binary files /dev/null and b/docs/screenshots/cancelled-analysis-runs-mobile.png differ
diff --git a/frontend/src/App.css b/frontend/src/App.css
index b4b65dfc8..b7482e61f 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -936,6 +936,27 @@
gap: 0.5rem;
}
+.analysis-run-item {
+ display: grid;
+ gap: 0.75rem;
+}
+
+.analysis-run-item.has-document-count.has-next-action {
+ grid-template-columns: minmax(12rem, 1fr) auto minmax(18rem, 2fr);
+}
+
+.analysis-run-item.has-document-count:not(.has-next-action) {
+ grid-template-columns: minmax(12rem, 1fr) auto;
+}
+
+.analysis-run-item.has-next-action:not(.has-document-count) {
+ grid-template-columns: minmax(12rem, 1fr) minmax(18rem, 2fr);
+}
+
+.analysis-run-item:not(.has-document-count):not(.has-next-action) {
+ grid-template-columns: 1fr;
+}
+
.ranking-hit {
flex-direction: column;
align-items: stretch;
@@ -1115,6 +1136,14 @@
align-items: flex-start;
gap: 0.5rem;
}
+
+ .analysis-run-item,
+ .analysis-run-item.has-document-count.has-next-action,
+ .analysis-run-item.has-document-count:not(.has-next-action),
+ .analysis-run-item.has-next-action:not(.has-document-count),
+ .analysis-run-item:not(.has-document-count):not(.has-next-action) {
+ grid-template-columns: 1fr;
+ }
}
.ontology-explorer {
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index 2dee4513d..1110d8fb0 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -2,6 +2,7 @@ import { fireEvent, render, screen, waitFor, within } from "@testing-library/rea
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import App from "./App";
+import type { AnalysisRunKindCode } from "./api";
import { setLocale } from "./i18n";
import { OIDC_RETURN_URL_STORAGE_KEY } from "./oidcReturnUrl";
@@ -106,6 +107,7 @@ describe("App, authenticated", () => {
succeededReportRun?: boolean;
succeededTeppRun?: boolean;
pendingTeppRun?: boolean;
+ cancelledRunKind?: AnalysisRunKindCode;
pluralAffiliations?: boolean;
deferMe?: boolean;
deferPostOne?: boolean;
@@ -161,6 +163,27 @@ describe("App, authenticated", () => {
releasePostOne = resolve;
})
: Promise.resolve();
+ const cancelledRun = options?.cancelledRunKind
+ ? {
+ analysis_run_id: "run-demo-cancelled",
+ run_kind_code: options.cancelledRunKind,
+ run_kind_label: {
+ analysis_run_lineage: "Lineage reconstruction",
+ analysis_run_tepp: "TEPP measurement",
+ analysis_run_topic_lineage: "Topic lineage",
+ analysis_run_report: "Period report",
+ }[options.cancelledRunKind],
+ scope_kind_code: "analysis_scope_corporate_entity",
+ scope_kind_label: "Corporate entity",
+ scope_entity_name: "Demo Corp",
+ status_code: "analysis_status_cancelled" as const,
+ status_label: "Cancelled",
+ knowledge_cutoff: "2026-01-12T12:00:00Z",
+ requested_at: "2026-01-12T12:42:00Z",
+ source_counts: [],
+ visible_posts: [],
+ }
+ : null;
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
@@ -269,6 +292,9 @@ describe("App, authenticated", () => {
jsonResponse({ post_id: "post-1", has_commitment: true, ticket }),
);
}
+ if (url.endsWith("/api/analysis-runs/run-demo-cancelled") && cancelledRun) {
+ return Promise.resolve(jsonResponse(cancelledRun));
+ }
if (url.endsWith("/api/analysis-runs/run-demo-report")) {
const reportSucceeded = !options?.failedReportRun;
return Promise.resolve(
@@ -686,6 +712,7 @@ describe("App, authenticated", () => {
return Promise.resolve(
jsonResponse({
analysis_runs: [
+ ...(cancelledRun ? [cancelledRun] : []),
...(createdPendingLineage ? [createdPendingLineage] : []),
...(createdPendingTepp ? [createdPendingTepp] : []),
{
@@ -3399,6 +3426,33 @@ describe("App, authenticated", () => {
expect(screen.queryByRole("heading", { name: "Body this run knew" })).not.toBeInTheDocument();
});
+ it.each([
+ ["analysis_run_lineage", "Request a new lineage reconstruction from a current snapshot."],
+ [
+ "analysis_run_tepp",
+ "Connect the measurement service, then ask an administrator to submit a new TEPP run from a current snapshot.",
+ ],
+ [
+ "analysis_run_topic_lineage",
+ "Connect the TEPP transport, then ask an administrator to submit new topic-lineage analysis from a current snapshot.",
+ ],
+ ["analysis_run_report", "Rebuild the period report from a current snapshot."],
+ ] satisfies [AnalysisRunKindCode, string][])(
+ "gives a cancelled %s run its kind-exact next action",
+ async (runKindCode, nextAction) => {
+ stubBackend({ cancelledRunKind: runKindCode });
+ render(