FN-5739: reconcile mission assertion labels and zero-assertion guard

Clarify MissionManager assertion enforcement semantics and surface zero-assertion guard states consistently.

- rename milestone assertion panel copy to emphasize validator-enforced behavior when assertions are linked
- add zero-assertion guard badge + warning copy when feature criteria exists without linked contract assertions
- add per-row enforcement badges for assertion rows and informational feature-criteria rollups
- update MissionManager tests for new empty-state copy, guard visibility, and enforcement indicators
- add MissionManager assertion enforcement badge styling

Files changed:
 packages/dashboard/app/components/MissionManager.css  |  22 ++++
 packages/dashboard/app/components/MissionManager.tsx  | 123 +++++++++++++++------
 packages/dashboard/app/components/__tests__/MissionManager.test.tsx |  84 +++++++++++++-
 3 files changed, 190 insertions(+), 39 deletions(-)

Fusion-Task-Id: FN-5739

Fusion-Task-Lineage: 1b6067e9-9772-4154-9cd2-f0b155484b69
This commit is contained in:
gsxdsm
2026-05-30 14:37:21 -07:00
parent ef1a19be1b
commit ac72f17cdb
3 changed files with 190 additions and 39 deletions

View File

@@ -1801,6 +1801,28 @@
margin-bottom: var(--space-sm);
}
.mission-assertion__enforcement {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
font-size: calc(var(--space-sm) + var(--space-xs) * 0.5);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: calc(var(--space-xs) * 0.5) var(--space-sm);
color: var(--text-dim);
background: color-mix(in srgb, var(--surface) 85%, var(--bg));
flex-shrink: 0;
}
.mission-assertion__enforcement--enforced {
color: var(--color-success);
border-color: color-mix(in srgb, var(--color-success) 40%, var(--border));
}
.mission-assertion__enforcement--informational {
color: var(--text-muted);
}
.mission-assertion__linked-count {
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
color: var(--text-dim);

View File

@@ -865,6 +865,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
const [missionHealthById, setMissionHealthById] = useState<Map<string, MissionHealth>>(new Map());
const [activeTab, setActiveTab] = useState<"structure" | "activity">("structure");
const milestoneAssertionGapSignatureRef = useRef<Map<string, string>>(new Map());
const [missionEvents, setMissionEvents] = useState<MissionEvent[]>([]);
const missionEventsRef = useRef<MissionEvent[]>([]);
const missionsRef = useRef<MissionWithSummary[]>([]);
@@ -880,6 +881,31 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
const [expandedEventMetadata, setExpandedEventMetadata] = useState<Set<string>>(new Set());
const activityEventsContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!selectedMission) return;
const nextSignatures = new Map<string, string>();
for (const milestone of selectedMission.milestones) {
const featuresWithAcceptanceCriteria = milestone.slices
.flatMap((slice) => slice.features)
.filter((feature) => (feature.acceptanceCriteria ?? "").trim().length > 0);
const assertionCount = assertionsByMilestone.get(milestone.id)?.length ?? 0;
const hasZeroAssertionGuard = featuresWithAcceptanceCriteria.length > 0 && assertionCount === 0;
const signature = `${hasZeroAssertionGuard}:${featuresWithAcceptanceCriteria.length}:${assertionCount}`;
const previousSignature = milestoneAssertionGapSignatureRef.current.get(milestone.id);
if (hasZeroAssertionGuard && previousSignature !== signature) {
console.warn("[MissionManager] milestone_zero_assertion_guard", {
milestoneId: milestone.id,
featureAcceptanceCriteriaCount: featuresWithAcceptanceCriteria.length,
assertionCount,
});
}
nextSignatures.set(milestone.id, signature);
}
milestoneAssertionGapSignatureRef.current = nextSignatures;
}, [assertionsByMilestone, selectedMission]);
const activityEventsEndRef = useRef<HTMLDivElement>(null);
// Keep latest state available to long-lived SSE handlers without reconnect churn.
@@ -2676,6 +2702,10 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
const featuresWithAcceptanceCriteria = milestone.slices
.flatMap((slice) => slice.features)
.filter((feature) => (feature.acceptanceCriteria ?? "").trim().length > 0);
const milestoneAssertions = Array.isArray(assertionsByMilestone.get(milestone.id))
? assertionsByMilestone.get(milestone.id)!
: [] as MissionContractAssertion[];
const hasZeroAssertionGuard = featuresWithAcceptanceCriteria.length > 0 && milestoneAssertions.length === 0;
return (
<div key={milestone.id} className="mission-milestone">
@@ -3486,15 +3516,15 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
{/* Assertions Panel */}
<div className="mission-assertions">
<div className="mission-assertions__header">
<span className="mission-assertions__title">Contract assertions (autopilot gate)</span>
<span className="mission-assertions__title">Contract assertions (validator-enforced when linked)</span>
<span className="mission-assertions__mode-tag" data-testid="milestone-assertions-enforced-indicator">
<span className="status-dot status-dot--running" />
Enforced by autopilot
</span>
{milestoneRollup?.hasProseButNoAssertions && (
<span className="mission-assertions__mode-tag mission-assertions__mode-tag--warning" data-testid="milestone-missing-structured-assertions-badge">
{hasZeroAssertionGuard && (
<span className="mission-assertions__mode-tag mission-assertions__mode-tag--warning" data-testid="milestone-zero-assertion-guard">
<span className="status-dot status-dot--pending" />
Prose criteria found; add contract assertions
Feature criteria present but no enforced contract assertions linked
</span>
)}
{milestoneRollup && (
@@ -3574,7 +3604,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
{/* Assertions list */}
<div className="mission-assertions__list">
{(Array.isArray(assertionsByMilestone.get(milestone.id)) ? assertionsByMilestone.get(milestone.id)! : [] as MissionContractAssertion[]).map((assertion) => (
{milestoneAssertions.map((assertion) => (
<div
key={assertion.id}
className="mission-assertion"
@@ -3630,11 +3660,23 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
{(() => {
const linked = linkedFeaturesByAssertion.get(assertion.id);
const count = linked?.length ?? 0;
return count > 0 ? (
<span className="mission-assertion__linked-count" title={`${count} linked feature${count !== 1 ? "s" : ""}`}>
({count} linked)
</span>
) : null;
const isEnforced = count > 0;
return (
<>
<span
className={`mission-assertion__enforcement ${isEnforced ? "mission-assertion__enforcement--enforced" : "mission-assertion__enforcement--informational"}`}
data-testid={`mission-assertion-enforcement-${assertion.id}`}
>
<span className={`status-dot ${isEnforced ? "status-dot--running" : "status-dot--pending"}`} />
{isEnforced ? "Enforced gate" : "Informational"}
</span>
{count > 0 ? (
<span className="mission-assertion__linked-count" title={`${count} linked feature${count !== 1 ? "s" : ""}`}>
({count} linked)
</span>
) : null}
</>
);
})()}
<button
className="mission-icon-btn"
@@ -3740,38 +3782,51 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
)}
</div>
))}
{(!assertionsByMilestone.get(milestone.id) || assertionsByMilestone.get(milestone.id)?.length === 0)
{(milestoneAssertions.length === 0)
&& !isCreatingAssertion
&& (
featuresWithAcceptanceCriteria.length > 0 ? (
// Product contract (2026-05-15 #product): completion criteria canonically
// live on MissionFeature.acceptanceCriteria; MissionContractAssertion rows
// are an additive milestone-level structure. Missing assertions do not
// mean missing criteria when child features already carry acceptance text,
// even when milestone.acceptanceCriteria is also populated (FN-4613/FN-4652).
// If FN-4578/4579/4580 (or successors) change the model, update this.
<div className="mission-assertions__list" data-testid="milestone-feature-acceptance-rollup">
<div className="mission-assertions__rollup-header">
<span className="mission-assertions__title">Feature acceptance criteria (informational)</span>
<span className="mission-assertions__mode-tag mission-assertions__mode-tag--informational" data-testid="milestone-feature-acceptance-informational-indicator">
<span className="status-dot status-dot--pending" />
Not enforced by autopilot
</span>
// Product contract source of truth: docs/missions-completion-contract.md (FN-5718).
// MissionFeature.acceptanceCriteria is informational authored intent; linked
// MissionContractAssertion rows are the validator-enforced completion gate.
// When criteria prose exists but assertions are absent, keep criteria visible
// and warn that the surface is informational until assertions are linked.
<>
<div className="mission-manager__empty mission-assertions__empty">
<span>No contract assertions are linked yet. Feature acceptance criteria are present below and remain informational until assertions are linked.</span>
</div>
{featuresWithAcceptanceCriteria.map((feature) => (
<div key={feature.id} className="mission-assertion">
<span className="mission-assertion__title">{feature.title}</span>
<div className="mission-assertion__text">
<strong>Acceptance:</strong>
{renderMarkdownText(feature.acceptanceCriteria ?? "")}
</div>
<div className="mission-assertions__list" data-testid="milestone-feature-acceptance-rollup">
<div className="mission-assertions__rollup-header">
<span className="mission-assertions__title">Feature acceptance criteria (informational source)</span>
<span className="mission-assertions__mode-tag mission-assertions__mode-tag--informational" data-testid="milestone-feature-acceptance-informational-indicator">
<span className="status-dot status-dot--pending" />
Not enforced by autopilot
</span>
</div>
))}
</div>
{featuresWithAcceptanceCriteria.map((feature) => (
<div key={feature.id} className="mission-assertion">
<div className="mission-assertion__header">
<span className="mission-assertion__title">{feature.title}</span>
<span
className="mission-assertion__enforcement mission-assertion__enforcement--informational"
data-testid={`mission-feature-acceptance-enforcement-${feature.id}`}
>
<span className="status-dot status-dot--pending" />
Informational
</span>
</div>
<div className="mission-assertion__text">
<strong>Acceptance:</strong>
{renderMarkdownText(feature.acceptanceCriteria ?? "")}
</div>
</div>
))}
</div>
</>
) : (
!milestone.acceptanceCriteria?.trim() ? (
<div className="mission-manager__empty mission-assertions__empty">
<span>No contract assertions defined yet. Feature acceptance criteria are informational until assertions are added.</span>
<span>No feature acceptance criteria or contract assertions defined yet.</span>
</div>
) : null
)

View File

@@ -4638,7 +4638,8 @@ describe("MissionManager", () => {
});
describe("milestone assertions empty-state", () => {
const emptyAssertionsCopy = "No contract assertions defined yet. Feature acceptance criteria are informational until assertions are added.";
const emptyAssertionsWithFeaturesCopy = "No contract assertions are linked yet. Feature acceptance criteria are present below and remain informational until assertions are linked.";
const emptyAssertionsNoFeaturesCopy = "No feature acceptance criteria or contract assertions defined yet.";
it("keeps empty-state nudge when assertions and feature acceptance criteria are both missing", async () => {
const missionDetail = JSON.parse(JSON.stringify(mockMissionDetail)) as typeof mockMissionDetail;
@@ -4656,7 +4657,7 @@ describe("MissionManager", () => {
fireEvent.click(await screen.findByText("Build Auth System"));
await waitForDetailLoaded();
expect(screen.getAllByText(emptyAssertionsCopy)).toHaveLength(1);
expect(screen.getAllByText(emptyAssertionsNoFeaturesCopy)).toHaveLength(1);
});
it("shows feature acceptance rollup when milestone acceptance criteria already exists (flip from prior suppression assertion)", async () => {
@@ -4715,15 +4716,16 @@ describe("MissionManager", () => {
fireEvent.click(await screen.findByText("Build Auth System"));
await waitForDetailLoaded();
expect(screen.queryByText(emptyAssertionsCopy)).not.toBeInTheDocument();
expect(screen.queryByText(emptyAssertionsNoFeaturesCopy)).not.toBeInTheDocument();
expect(screen.getByText(emptyAssertionsWithFeaturesCopy)).toBeInTheDocument();
const rollup = screen.getByTestId("milestone-feature-acceptance-rollup");
expect(within(rollup).getByText("Feature acceptance criteria (informational)")).toBeInTheDocument();
expect(within(rollup).getByText("Feature acceptance criteria (informational source)")).toBeInTheDocument();
expect(within(rollup).getByTestId("milestone-feature-acceptance-informational-indicator")).toHaveTextContent("Not enforced by autopilot");
expect(within(rollup).getByText("Session handling")).toBeInTheDocument();
expect(within(rollup).getByText("Session refresh succeeds without logout", { exact: false })).toBeInTheDocument();
expect(within(rollup).getByText("Token storage")).toBeInTheDocument();
expect(within(rollup).getByText("Tokens remain encrypted at rest", { exact: false })).toBeInTheDocument();
expect(screen.getByTestId("milestone-missing-structured-assertions-badge")).toHaveTextContent("Prose criteria found; add contract assertions");
expect(screen.getByTestId("milestone-zero-assertion-guard")).toHaveTextContent("Feature criteria present but no enforced contract assertions linked");
});
it("keeps structured assertions precedence and hides rollup when assertions exist", async () => {
@@ -4738,9 +4740,81 @@ describe("MissionManager", () => {
await waitForDetailLoaded();
expect(screen.getByText("Auth works")).toBeInTheDocument();
expect(screen.getByText("Contract assertions (validator-enforced when linked)")).toBeInTheDocument();
expect(screen.getByTestId("milestone-assertions-enforced-indicator")).toHaveTextContent("Enforced by autopilot");
expect(screen.queryByTestId("milestone-feature-acceptance-rollup")).not.toBeInTheDocument();
});
it("shows per-row informational and enforced indicators", async () => {
const missionDetail = JSON.parse(JSON.stringify(mockMissionDetail)) as typeof mockMissionDetail;
missionDetail.milestones[0].acceptanceCriteria = "";
const assertion = {
id: "CA-ENF-1",
milestoneId: "MS-001",
title: "Auth works",
assertion: "Users can log in",
status: "pending",
orderIndex: 0,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
globalThis.fetch = vi.fn().mockImplementation((url: string) => {
if (url.includes("/missions/health")) return Promise.resolve(mockApiResponse(mockMissionHealthById));
if (url.includes("/events")) return Promise.resolve(mockApiResponse(parseMissionEventsResponse(url, mockMissionEvents)));
if (url.includes("/health")) return Promise.resolve(mockApiResponse(getMockMissionHealth(extractMissionId(url) ?? "M-001")));
if (url.includes("/autopilot")) return Promise.resolve(mockApiResponse(mockAutopilotStatus));
if (url.includes("/milestones/MS-001/assertions/CA-ENF-1/features")) {
return Promise.resolve(mockApiResponse([{ id: "F-001", title: "User model" }]));
}
if (url.includes("/milestones/MS-001/assertions")) return Promise.resolve(mockApiResponse([assertion]));
const validationResponse = getValidationApiMock(url, mockMilestoneValidationTelemetryWithRounds);
if (validationResponse !== null) return Promise.resolve(mockApiResponse(validationResponse));
if (url.includes("/api/missions/") && !url.includes("/milestones") && !url.includes("/status")) {
return Promise.resolve(mockApiResponse(missionDetail));
}
return Promise.resolve(mockApiResponse(mockMissions));
});
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
fireEvent.click(await screen.findByText("Build Auth System"));
await waitForDetailLoaded();
expect(await screen.findByTestId("mission-assertion-enforcement-CA-ENF-1")).toHaveTextContent("Enforced gate");
const noAssertionMission = JSON.parse(JSON.stringify(missionDetail)) as typeof missionDetail;
noAssertionMission.milestones[0].slices[0].features[0].id = "F-INFO-1";
noAssertionMission.milestones[0].slices[0].features[0].title = "Feature Informational";
globalThis.fetch = createDetailFetchMockForMissionDetail(noAssertionMission);
cleanup();
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
fireEvent.click(await screen.findByText("Build Auth System"));
await waitForDetailLoaded();
expect(await screen.findByTestId("mission-feature-acceptance-enforcement-F-INFO-1")).toHaveTextContent("Informational");
});
it("shows zero-assertion guard only when milestone has feature acceptance criteria and no assertions", async () => {
const missionDetail = JSON.parse(JSON.stringify(mockMissionDetail)) as typeof mockMissionDetail;
missionDetail.milestones[0].acceptanceCriteria = "";
globalThis.fetch = createDetailFetchMockForMissionDetail(missionDetail);
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
fireEvent.click(await screen.findByText("Build Auth System"));
await waitForDetailLoaded();
expect(screen.getByTestId("milestone-zero-assertion-guard")).toBeInTheDocument();
cleanup();
globalThis.fetch = createDetailFetchMockForMissionDetail(
missionDetail,
mockMilestoneValidationTelemetryWithRounds,
mockMilestoneValidationTelemetryWithRounds.validationContract.assertions,
);
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
fireEvent.click(await screen.findByText("Build Auth System"));
await waitForDetailLoaded();
expect(screen.queryByTestId("milestone-zero-assertion-guard")).not.toBeInTheDocument();
});
});
describe("mission acceptance and verification visibility", () => {