FN-5733: enforce mission completion-gate assertions for live Goals mission

Realize the mission completion-gate contract by surfacing and enforcing structured assertion coverage for milestone completion.

- add milestone rollup signaling for prose-only acceptance criteria and wire it into mission-store completion gating
- update mission execution loop reliability paths so validator-trigger continuity is preserved across completion and startup recovery
- expose the new rollup flag in dashboard mission types/API and show an autopilot warning badge in MissionManager when prose exists without structured assertions
- extend core, engine, and dashboard tests plus mission docs, and add a patch changeset for @runfusion/fusion

Files changed:
 .changeset/fn-5733-mission-completion-gate.md      | 10 +++
 docs/missions-completion-contract.md               | 11 +++
 docs/missions.md                                   |  4 +
 packages/core/src/__tests__/mission-store.test.ts  | 81 ++++++++++++++++++++
 packages/core/src/mission-store.ts                 | 89 ++++++++++++++++++++++
 packages/core/src/mission-types.ts                 |  2 +
 packages/dashboard/app/api/legacy.ts               |  1 +
 .../dashboard/app/components/MissionManager.css    | 42 +++++++++-
 .../dashboard/app/components/MissionManager.tsx    | 22 +++++-
 .../components/__tests__/MissionManager.test.tsx   | 20 ++++-
 packages/dashboard/app/components/mission-types.ts |  1 +
 .../src/__tests__/mission-execution-loop.test.ts   | 23 ++++++
 .../mission-validation-trigger-gap.test.ts         | 41 ++++++++++
 packages/engine/src/mission-execution-loop.ts      | 21 ++++-
 14 files changed, 357 insertions(+), 11 deletions(-)

Fusion-Task-Id: FN-5733

Fusion-Task-Lineage: 259418c6-5404-4773-8fd2-db8d7e9cadd3
This commit is contained in:
gsxdsm
2026-05-30 14:07:20 -07:00
parent 90a23e9fc1
commit 9c4e8edf39
14 changed files with 357 additions and 11 deletions

View File

@@ -3032,6 +3032,49 @@ describe("MissionStore", () => {
expect(events[events.length - 1].state).toBe("ready"); // linked but not passed
expect(events[events.length - 1].rollup.unlinkedAssertions).toBe(0);
});
it("flags rollup when milestone prose exists but no assertions are linked", () => {
const updatedMission = store.updateMission(mission.id, { status: "active" });
expect(updatedMission.status).toBe("active");
store.updateMilestone(milestone.id, { acceptanceCriteria: "Milestone prose" });
const warningEvents: Array<{ id: string; code: unknown }> = [];
store.on("mission:event", (event) => {
if (event.eventType === "warning") {
warningEvents.push({ id: event.id, code: event.metadata?.code });
}
});
const rollup = store.getMilestoneValidationRollup(milestone.id);
expect(rollup.hasProseButNoAssertions).toBe(true);
expect(store.milestoneHasProseButNoAssertions(milestone.id)).toBe(true);
const assertion = store.addContractAssertion(milestone.id, { title: "A1", assertion: "Temp" });
store.deleteContractAssertion(assertion.id);
expect(warningEvents.some((event) => event.code === "milestone_missing_structured_assertions")).toBe(true);
});
it("does not flag rollup when assertions exist", () => {
store.updateMilestone(milestone.id, { acceptanceCriteria: "Milestone prose" });
const assertion = store.addContractAssertion(milestone.id, { title: "A1", assertion: "Test" });
const slice = store.addSlice(milestone.id, { title: "Slice" });
const feature = store.addFeature(slice.id, { title: "Feature" });
store.linkFeatureToAssertion(feature.id, assertion.id);
const rollup = store.getMilestoneValidationRollup(milestone.id);
expect(rollup.hasProseButNoAssertions).toBe(false);
expect(store.milestoneHasProseButNoAssertions(milestone.id)).toBe(false);
});
it("does not flag rollup when neither milestone nor features have prose", () => {
const slice = store.addSlice(milestone.id, { title: "Slice" });
store.addFeature(slice.id, { title: "Feature" });
const rollup = store.getMilestoneValidationRollup(milestone.id);
expect(rollup.hasProseButNoAssertions).toBe(false);
expect(store.milestoneHasProseButNoAssertions(milestone.id)).toBe(false);
});
});
// ── buildEnrichedDescription with Assertions Tests ────────────────────
@@ -3149,6 +3192,44 @@ describe("MissionStore", () => {
});
});
describe("seedContractAssertionsForFeatures", () => {
it("seeds and links authored assertions idempotently", () => {
const mission = store.createMission({ title: "Seed mission" });
const milestone = store.addMilestone(mission.id, { title: "M1" });
const slice = store.addSlice(milestone.id, { title: "S1" });
const feature = store.addFeature(slice.id, { title: "F1", acceptanceCriteria: "AC" });
const beforeManaged = store.listAssertionsForFeature(feature.id).length;
const first = store.seedContractAssertionsForFeatures([
{
featureId: feature.id,
milestoneId: milestone.id,
title: "Authored assertion",
assertion: "Feature output is deterministic",
},
]);
expect(first.created).toBe(1);
expect(first.linked).toBe(1);
expect(first.skippedExisting).toBe(0);
const second = store.seedContractAssertionsForFeatures([
{
featureId: feature.id,
milestoneId: milestone.id,
title: "Authored assertion",
assertion: "Feature output is deterministic",
},
]);
expect(second.created).toBe(0);
expect(second.linked).toBe(0);
expect(second.skippedExisting).toBe(1);
expect(store.listAssertionsForFeature(feature.id).length).toBe(beforeManaged + 1);
});
});
describe("backfillFeatureAssertions", () => {
const makeLegacyFeature = (sliceId: string, input: { title: string; description?: string; acceptanceCriteria?: string }) => {
const feature = store.addFeature(sliceId, input);

View File

@@ -143,6 +143,20 @@ export interface MissionAssertionBackfillReport {
skippedErrors: MissionAssertionBackfillErrorRow[];
}
export interface MissionAssertionSeedInput {
featureId: string;
milestoneId: string;
title: string;
assertion: string;
}
export interface MissionAssertionSeedReport {
scanned: number;
created: number;
linked: number;
skippedExisting: number;
}
// ── Event Types ─────────────────────────────────────────────────────
export interface MissionStoreEvents {
@@ -367,6 +381,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
}
private _eventSeq = 0;
private _milestonesMissingStructuredAssertions = new Set<string>();
// ── Row-to-Object Converters ───────────────────────────────────────
@@ -1967,6 +1982,48 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
}
}
/**
* Idempotently seed authored contract assertions for specific features.
*
* Re-running this method is safe: existing equivalent feature-linked assertions are skipped.
*/
seedContractAssertionsForFeatures(inputs: MissionAssertionSeedInput[]): MissionAssertionSeedReport {
let created = 0;
let linked = 0;
let skippedExisting = 0;
for (const input of inputs) {
const existingLinked = this.listAssertionsForFeature(input.featureId).find((assertion) =>
assertion.milestoneId === input.milestoneId
&& assertion.title.trim() === input.title.trim()
&& assertion.assertion.trim() === input.assertion.trim(),
);
if (existingLinked) {
skippedExisting += 1;
continue;
}
const createdAssertion = this.addContractAssertion(input.milestoneId, {
title: input.title,
assertion: input.assertion,
status: "pending",
sourceFeatureId: input.featureId,
});
created += 1;
this.linkFeatureToAssertion(input.featureId, createdAssertion.id);
linked += 1;
}
return {
scanned: inputs.length,
created,
linked,
skippedExisting,
};
}
/**
* Backfill assertion links for legacy features that predate the FN-5695 creation-path fix.
* Reuses deriveFeatureAssertion()/ensureFeatureAssertion text-source rules so create/update
@@ -3102,6 +3159,11 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
const assertions = this.listContractAssertions(milestoneId);
const totalAssertions = assertions.length;
const proseOnMilestone = (milestone.acceptanceCriteria ?? "").trim().length > 0;
const proseOnFeatures = this.listSlices(milestoneId)
.flatMap((slice) => this.listFeatures(slice.id))
.some((feature) => (feature.acceptanceCriteria ?? "").trim().length > 0);
const hasProseButNoAssertions = totalAssertions === 0 && (proseOnMilestone || proseOnFeatures);
// Count by status
let passedAssertions = 0;
@@ -3156,6 +3218,8 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
state = "ready";
}
this.reconcileMissingStructuredAssertionsSignal(milestone, hasProseButNoAssertions);
return {
milestoneId,
totalAssertions,
@@ -3164,10 +3228,35 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
blockedAssertions,
pendingAssertions,
unlinkedAssertions,
hasProseButNoAssertions,
state,
};
}
milestoneHasProseButNoAssertions(milestoneId: string): boolean {
return this.getMilestoneValidationRollup(milestoneId).hasProseButNoAssertions;
}
private reconcileMissingStructuredAssertionsSignal(milestone: Milestone, hasProseButNoAssertions: boolean): void {
if (hasProseButNoAssertions) {
// Debounce per process: emit on first transition into this condition so
// operators can detect regressions without flooding every recompute cycle.
if (!this._milestonesMissingStructuredAssertions.has(milestone.id)) {
const mission = this.getMission(milestone.missionId);
if (mission) {
this.logMissionEvent(mission.id, "warning", `Milestone ${milestone.id} has prose acceptance criteria but no structured assertions.`, {
code: "milestone_missing_structured_assertions",
milestoneId: milestone.id,
});
}
}
this._milestonesMissingStructuredAssertions.add(milestone.id);
return;
}
this._milestonesMissingStructuredAssertions.delete(milestone.id);
}
/**
* Recompute and persist the milestone's validation state.
* This is called automatically after assertion or link changes.

View File

@@ -615,6 +615,8 @@ export interface MilestoneValidationRollup {
pendingAssertions: number;
/** Number of assertions not linked to any feature */
unlinkedAssertions: number;
/** True when milestone/feature prose criteria exist but no structured assertions are linked */
hasProseButNoAssertions: boolean;
/** The computed validation state */
state: MilestoneValidationState;
}