FN-8695: fix dashboard assertion CRUD validation

Restore dashboard assertion status edits and deletes for store-generated IDs.

- Validate assertion route IDs against the MissionStore-generated format while preserving legacy IDs.
- Dispatch assertion deletes through the shared confirmation handler and surface request failures.
- Cover current ID CRUD routes and desktop/mobile deletion behavior.

Files changed:
 .changeset/fn-8695-assertion-id-validation.md      |   7 ++
 docs/missions.md                                   |   2 +-
 .../dashboard/app/components/MissionManager.tsx    |  51 ++++++++---
 .../MissionManager.delete-confirm.test.tsx         |  99 ++++++++++++++++++++
 .../mission-assertion-id-validation.test.ts        | 100 +++++++++++++++++++++
 packages/dashboard/src/mission-routes.ts           |   8 +-
 6 files changed, 249 insertions(+), 18 deletions(-)

Fusion-Task-Id: FN-8695

Fusion-Task-Lineage: b7e2d70f-d3c7-4fdd-92f6-989cf414570b

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-01 13:10:26 -07:00
parent 1e67e87321
commit 529ab26ba4
6 changed files with 249 additions and 18 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix mission assertion status edits and deletes failing with invalid assertion IDs.
category: fix
dev: Align dashboard assertion ID validation with MissionStore-generated IDs and surface delete failures.

View File

@@ -451,7 +451,7 @@ Contract assertions (`MissionContractAssertion`) formalize what must be true for
```typescript
interface MissionContractAssertion {
id: string; // e.g., "CA-A3B7CD-E9F2"
id: string; // e.g., "CA-MS39KJP3-000A-8ABO" (legacy: "CA-A3B7CD-E9F2")
milestoneId: string; // Parent milestone
sourceFeatureId?: string;// Store-managed feature assertion owner
scope: "feature" | "milestone";

View File

@@ -88,6 +88,7 @@ import {
fetchAssertions,
createAssertion,
updateAssertion,
deleteAssertion,
linkFeatureToAssertion,
unlinkFeatureFromAssertion,
fetchFeaturesForAssertion,
@@ -940,7 +941,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
}, [isActive, milestoneSliceResumeSessionId, onMilestoneSliceResumeFetchError]);
// Delete confirmation
const [deleteConfirmId, setDeleteConfirmId] = useState<{ type: string; id: string } | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<{ type: string; id: string; milestoneId?: string } | null>(null);
// Assertion panel state
const [assertionsByMilestone, setAssertionsByMilestone] = useState<Map<string, MissionContractAssertion[]>>(new Map());
@@ -2212,6 +2213,14 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
}
}, [assertionForm, addToast, loadAssertionsForMilestone, loadValidationRollup, projectId]);
const handleDeleteAssertion = useCallback(async (assertionId: string, milestoneId: string) => {
await deleteAssertion(assertionId, projectId);
addToast(t("missions.assertionDeleted", "Assertion deleted"), "success");
await loadAssertionsForMilestone(milestoneId);
await loadValidationRollup(milestoneId);
setDeleteConfirmId(null);
}, [addToast, loadAssertionsForMilestone, loadValidationRollup, projectId]);
const handleEditAssertion = useCallback((assertion: MissionContractAssertion) => {
setEditingAssertionId(assertion.id);
setAssertionForm({
@@ -4065,8 +4074,9 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
</button>
<button
className="mission-icon-btn mission-icon-btn--danger"
onClick={() => setDeleteConfirmId({ type: "assertion", id: assertion.id })}
onClick={() => setDeleteConfirmId({ type: "assertion", id: assertion.id, milestoneId: milestone.id })}
title={t("missions.deleteAssertion", "Delete assertion")}
aria-label={t("missions.deleteAssertion", "Delete assertion")}
>
<Trash2 size={14} />
</button>
@@ -4975,6 +4985,30 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
);
};
/*
FNXC:MissionAssertions 2026-08-01-19:44:
Every deleteConfirmId type must dispatch a deletion, and the shared confirmation panel must surface rejected requests. Assertion deletion is an operator recovery path for validation failures, so a silent no-op would leave stale rollups unrepairable.
*/
const handleConfirmDelete = useCallback(async () => {
if (!deleteConfirmId) return;
try {
if (deleteConfirmId.type === "milestone") {
await handleDeleteMilestone(deleteConfirmId.id);
} else if (deleteConfirmId.type === "slice") {
await handleDeleteSlice(deleteConfirmId.id);
} else if (deleteConfirmId.type === "feature") {
await handleDeleteFeature(deleteConfirmId.id);
} else if (deleteConfirmId.type === "assertion" && deleteConfirmId.milestoneId) {
await handleDeleteAssertion(deleteConfirmId.id, deleteConfirmId.milestoneId);
} else if (deleteConfirmId.type === "interview_draft") {
await handleDiscardInterviewSession(deleteConfirmId.id);
}
} catch (err) {
addToast(getErrorMessage(err) || t("missions.deleteFailed", "Failed to delete item"), "error");
}
}, [addToast, deleteConfirmId, handleDeleteAssertion, handleDeleteFeature, handleDeleteMilestone, handleDeleteSlice, handleDiscardInterviewSession, t]);
const renderDeleteConfirmPanel = () => {
if (deleteConfirmId?.type === "mission") {
return null;
@@ -4992,18 +5026,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
<div className="mission-confirm-panel__actions">
<button
className="mission-btn mission-btn--danger"
onClick={async () => {
if (!deleteConfirmId) return;
if (deleteConfirmId.type === "milestone") {
await handleDeleteMilestone(deleteConfirmId.id);
} else if (deleteConfirmId.type === "slice") {
await handleDeleteSlice(deleteConfirmId.id);
} else if (deleteConfirmId.type === "feature") {
await handleDeleteFeature(deleteConfirmId.id);
} else if (deleteConfirmId.type === "interview_draft") {
await handleDiscardInterviewSession(deleteConfirmId.id);
}
}}
onClick={() => { void handleConfirmDelete(); }}
>
{isInterviewDraftDelete ? t("missions.discardButton", "Discard") : t("missions.deleteButton", "Delete")}
</button>

View File

@@ -18,6 +18,7 @@ const mockFetchAiSession = vi.fn();
const mockFetchMissionInterviewDrafts = vi.fn();
const mockDiscardMissionInterviewDraft = vi.fn();
const mockDeleteMission = vi.fn();
const mockDeleteAssertion = vi.fn();
const mockSubscribeSse = vi.fn(() => vi.fn());
vi.mock("../../hooks/useViewportMode", () => ({
@@ -68,6 +69,7 @@ vi.mock("../../api", async (importOriginal) => {
fetchMissionInterviewDrafts: (...args: unknown[]) => mockFetchMissionInterviewDrafts(...args),
discardMissionInterviewDraft: (...args: unknown[]) => mockDiscardMissionInterviewDraft(...args),
deleteMission: (...args: unknown[]) => mockDeleteMission(...args),
deleteAssertion: (...args: unknown[]) => mockDeleteAssertion(...args),
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }),
};
});
@@ -139,6 +141,7 @@ function setupMocks() {
mockFetchMissionInterviewDrafts.mockResolvedValue([]);
mockDiscardMissionInterviewDraft.mockResolvedValue({ removed: true });
mockDeleteMission.mockResolvedValue(undefined);
mockDeleteAssertion.mockResolvedValue(undefined);
}
function renderMissionManager(addToast = vi.fn()) {
@@ -190,6 +193,49 @@ async function findMissionListItem(title: string): Promise<HTMLElement> {
return item as HTMLElement;
}
function makeAssertion(id: string) {
return {
id,
milestoneId: "MS-001",
title: `Assertion ${id}`,
assertion: "The contract holds",
status: "pending" as const,
orderIndex: 0,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
}
function missionWithAssertions() {
return {
...missionDetails.get("M-001"),
milestones: [{
id: "MS-001",
missionId: "M-001",
title: "Quality gate",
status: "active",
orderIndex: 0,
interviewState: "not_started",
dependencies: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
slices: [],
}],
};
}
async function openAssertionDeletePanel(assertionId: string) {
fireEvent.click(await findMissionListItem("Build Auth System"));
await screen.findByText("Quality gate");
const assertion = await screen.findByText(`Assertion ${assertionId}`);
const row = assertion.closest(".mission-assertion");
expect(row).not.toBeNull();
const deleteButton = (row as HTMLElement).querySelector('button[title="Delete assertion"]');
expect(deleteButton).not.toBeNull();
fireEvent.click(deleteButton as HTMLButtonElement);
return getConfirmPanel();
}
async function openListDeleteDialog(title: string, container: HTMLElement) {
const item = await findMissionListItem(title);
fireEvent.click(within(item).getByRole("button", { name: "Delete mission" }));
@@ -286,6 +332,59 @@ describe("MissionManager mission delete confirmation", () => {
expect(addToast).toHaveBeenCalledWith("delete failed upstream", "error");
});
it.each(["desktop", "mobile"] as const)("deletes an assertion and refreshes its milestone in the %s panel", async (viewport) => {
mockViewportMode.mockReturnValue(viewport);
mockFetchMission.mockResolvedValue(missionWithAssertions());
mockFetchAssertions.mockResolvedValue([makeAssertion("CA-CURRENT-0001-TEST")]);
renderMissionManager();
await openAssertionDeletePanel("CA-CURRENT-0001-TEST");
clickConfirmPanelAction("Delete");
await waitFor(() => {
expect(mockDeleteAssertion).toHaveBeenCalledWith("CA-CURRENT-0001-TEST", projectId);
});
await waitFor(() => {
expect(mockFetchAssertions.mock.calls.filter(([milestoneId]) => milestoneId === "MS-001")).toHaveLength(2);
expect(mockFetchMilestoneValidation).toHaveBeenCalledWith("MS-001", projectId);
});
});
it("surfaces an assertion delete failure instead of silently swallowing it", async () => {
const addToast = vi.fn();
mockFetchMission.mockResolvedValue(missionWithAssertions());
mockFetchAssertions.mockResolvedValue([makeAssertion("CA-CURRENT-0001-FAIL")]);
mockDeleteAssertion.mockRejectedValueOnce(new Error("assertion delete failed upstream"));
renderMissionManager(addToast);
const panel = await openAssertionDeletePanel("CA-CURRENT-0001-FAIL");
clickConfirmPanelAction("Delete");
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("assertion delete failed upstream", "error");
});
expect(panel).toBeInTheDocument();
});
it("keeps zero and multiple assertion states distinct", async () => {
mockFetchMission.mockResolvedValue(missionWithAssertions());
mockFetchAssertions.mockResolvedValueOnce([]).mockResolvedValueOnce([
makeAssertion("CA-CURRENT-0001-FIRST"),
makeAssertion("CA-CURRENT-0002-SECOND"),
]);
renderMissionManager();
fireEvent.click(await findMissionListItem("Build Auth System"));
const milestone = await screen.findByText("Quality gate");
await waitFor(() => {
expect(document.querySelectorAll(".mission-assertion")).toHaveLength(0);
});
fireEvent.click(milestone);
fireEvent.click(milestone);
expect(await screen.findByText("Assertion CA-CURRENT-0001-FIRST")).toBeInTheDocument();
expect(screen.getByText("Assertion CA-CURRENT-0002-SECOND")).toBeInTheDocument();
});
it("discards the selected desktop draft row without removing duplicate-titled drafts or missions", async () => {
mockFetchMissionInterviewDrafts.mockResolvedValue([
makeDraft({ id: "draft-duplicate-a", title: "Shared draft", status: "awaiting_input" }),

View File

@@ -0,0 +1,100 @@
// @vitest-environment node
/*
FNXC:MissionAssertions 2026-08-01-19:44:
Route validation must accept IDs emitted by MissionStore rather than a hand-written approximation. This protects every assertion CRUD and feature-link route when the store's generated ID segments evolve.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import express from "express";
import { MissionStore, type Database, type TaskStore } from "@fusion/core";
import { createMissionRouter } from "../mission-routes.js";
import { request } from "../test-request.js";
const FEATURE_ID = "F-TEST";
const LEGACY_ASSERTION_ID = "CA-A3B7CD-E9F2";
function createStoreGeneratedAssertionId(): string {
const database = {
prepare: vi.fn().mockReturnValue({ get: vi.fn().mockReturnValue(undefined) }),
bumpLastModified: vi.fn(),
} as unknown as Database;
const missionStore = new MissionStore("/tmp/mission-assertion-id-validation", database);
return (missionStore as unknown as { generateId(prefix: string): string }).generateId("CA");
}
function createFixture() {
const assertionId = createStoreGeneratedAssertionId();
const assertion = { id: assertionId, milestoneId: "MS-TEST", title: "Assertion", assertion: "It holds", status: "pending" };
const missionStore = {
getContractAssertion: vi.fn(async (id: string) => id.startsWith("CA-") ? { ...assertion, id } : undefined),
updateContractAssertion: vi.fn(async (id: string) => ({ ...assertion, id, status: "passed" })),
deleteContractAssertion: vi.fn(async () => undefined),
linkFeatureToAssertion: vi.fn(async () => undefined),
unlinkFeatureFromAssertion: vi.fn(async () => undefined),
listFeaturesForAssertion: vi.fn(async () => [{ id: FEATURE_ID }]),
on: vi.fn(),
off: vi.fn(),
};
const store = {
getMissionStore: () => missionStore,
getGoalStore: () => ({ getGoal: vi.fn(), listGoals: vi.fn() }),
getRootDir: () => "/tmp/mission-assertion-id-validation",
getSettings: vi.fn(async () => ({})),
backendMode: true,
} as unknown as TaskStore;
const app = express();
app.use(express.json());
app.use("/api/missions", createMissionRouter(store));
return { app, assertionId, missionStore };
}
describe("mission assertion ID validation", () => {
let fixture: ReturnType<typeof createFixture>;
beforeEach(() => {
fixture = createFixture();
});
it.each([
["GET /assertions/:assertionId", "GET", (id: string) => `/api/missions/assertions/${id}`],
["PATCH /assertions/:assertionId", "PATCH", (id: string) => `/api/missions/assertions/${id}`],
["DELETE /assertions/:assertionId", "DELETE", (id: string) => `/api/missions/assertions/${id}`],
["POST /features/:featureId/assertions/:assertionId/link", "POST", (id: string) => `/api/missions/features/${FEATURE_ID}/assertions/${id}/link`],
["POST /features/:featureId/assertions/:assertionId/unlink", "POST", (id: string) => `/api/missions/features/${FEATURE_ID}/assertions/${id}/unlink`],
["GET /assertions/:assertionId/features", "GET", (id: string) => `/api/missions/assertions/${id}/features`],
] as const)("accepts a current MissionStore ID on %s", async (_name, method, pathFor) => {
const response = await request(
fixture.app,
method,
pathFor(fixture.assertionId),
method === "PATCH" ? JSON.stringify({ status: "passed" }) : undefined,
method === "PATCH" ? { "content-type": "application/json" } : undefined,
);
expect(fixture.assertionId).toMatch(/^CA-[A-Z0-9]+-[A-Z0-9]+-[A-Z0-9]+$/);
expect(response.status).not.toBe(400);
expect(response.body).not.toEqual({ error: "Invalid assertion ID format" });
});
it("accepts legacy two-segment assertion IDs", async () => {
const response = await request(fixture.app, "GET", `/api/missions/assertions/${LEGACY_ASSERTION_ID}`);
expect(response.status).toBe(200);
});
it.each([
"M-A3B7CD-0001-E9F2",
"CA-",
"%20",
"CA-%2E%2E%2F%2E%2E%2Fetc",
"CA-A3B7CD%2F0001-E9F2",
"CA-A3B7CD-0001-INVALID!",
])("rejects invalid assertion ID %j", async (assertionId) => {
const response = await request(fixture.app, "GET", `/api/missions/assertions/${assertionId}`);
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: "Invalid assertion ID format" });
});
});

View File

@@ -155,10 +155,12 @@ function validateOptionalWorkflowId(workflowId: unknown): string | null | undefi
throw badRequest("workflowId must be a string or null");
}
/*
FNXC:MissionAssertions 2026-08-01-19:44:
The assertion guard landed on 2026-04-11 for two-segment IDs, but MissionStore.generateId added its idSequence segment on 2026-05-04. Keep this validator aligned with every dash-separated alphanumeric segment emitted by MissionStore while preserving legacy assertion rows.
*/
function validateAssertionId(id: string): boolean {
// Assertion IDs follow format: CA-{base36timestamp}-{random}
// e.g., CA-A3B7CD-E9F2
return /^CA-[A-Z0-9]+-[A-Z0-9]+$/i.test(id);
return /^CA-[A-Z0-9]+(?:-[A-Z0-9]+)*$/i.test(id);
}
function validateGoalId(id: string): boolean {