diff --git a/.changeset/fn-8542-feature-validation-scope.md b/.changeset/fn-8542-feature-validation-scope.md new file mode 100644 index 0000000000..be87559473 --- /dev/null +++ b/.changeset/fn-8542-feature-validation-scope.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Scope feature validation to linked assertions instead of unfinished milestone work. +category: fix +dev: Adds provenance-safe milestone assertion persistence and derived-origin uniqueness. diff --git a/docs/missions-completion-contract.md b/docs/missions-completion-contract.md index d9465fda54..259e70eb38 100644 --- a/docs/missions-completion-contract.md +++ b/docs/missions-completion-contract.md @@ -18,27 +18,20 @@ Mission completion now uses an **all-criteria AI-run contract**: - `feature.description` - `Verify implementation of: {feature.title}` 3. The mission validator must run for every feature completion trigger. Runtime validation may lazily call `ensureFeatureAssertionLinked(feature.id)` before starting the validator so legacy missing-link rows still become validator-backed. -4. `milestone.acceptanceCriteria` is also part of the enforced gate by being threaded into the validator prompt for every feature in that milestone. -5. Feature, slice, milestone, and mission advancement are gated by the validator result — **not** by an informational-only path. +4. `milestone.acceptanceCriteria` is synchronized to one provenance-identified, milestone-scoped assertion and is evaluated only at milestone rollup time. +5. Feature, slice, milestone, and mission advancement are gated by structured scoped assertion results — **not** by an informational-only path. ## Enforcement Model ### Feature-level enforcement -A feature is autopilot-complete only when the validator passes after evaluating: - -- the feature's linked contract assertions, including its store-managed assertion, and -- the parent milestone's `acceptanceCriteria` text when present. +A feature is autopilot-complete only when every linked **feature-scoped** contract assertion passes. The validator ignores parent milestone prose, model aggregate prose, and unmapped behavioral evidence when deriving the feature verdict. Behavioral verification may change only the linked behavioral assertion it identifies. ### Milestone-level enforcement -`milestone.acceptanceCriteria` is no longer informational-only. FN-5902 enforces it by threading the milestone pass-bar text into the validator prompt for each feature under that milestone. +`milestone.acceptanceCriteria` is no longer informational-only. It is synchronized to one canonical `scope: "milestone"`, `origin: "derived_milestone_acceptance"` assertion. The derived origin is unique per `(project_id, milestone_id)`; independently authored, imported, and legacy milestone assertions remain non-unique and are never identified by title or text. -This is intentionally **prompt-threading**, not per-feature milestone assertion row synthesis: - -- store-managed per-feature assertions remain the canonical feature assertion rows, -- milestone acceptance text remains milestone-authored prose, -- the validator sees both and must satisfy both. +Milestone assertions require no feature link. The dedicated milestone evaluator may run only after every acceptance-bearing feature has at least one linked feature-scoped assertion and every feature-scoped assertion is linked and `passed`; pending, blocked, failed, unlinked, missing, and prose-only feature contracts are not ready. An all-feature-complete milestone with no acceptance-bearing features may evaluate its milestone assertions directly. Unmet parent scope cannot fail a completed feature or mint a feature-scoped fix. ### Legacy data and lazy repair @@ -67,9 +60,9 @@ Instead, features are routed through validator execution after lazy assertion en - Result gates completion normally. 2. **Feature has acceptance criteria and milestone acceptance criteria** - - Validator evaluates the linked feature assertion(s). - - Validator also evaluates the milestone acceptance text in the prompt. - - Feature passes only when both are satisfied. + - Feature validator evaluates only linked feature assertion(s). + - Milestone prose is represented by its canonical milestone assertion. + - A passing feature remains passed even if sibling milestone work is incomplete; the parent assertion gates the later milestone rollup. 3. **Operator runs backfill on legacy data** - Backfill pre-restores missing managed assertions for visibility/reporting. @@ -100,7 +93,7 @@ For any mission feature that reaches validation trigger points: - a validator run must occur, - the feature must not auto-pass due to missing assertion links, -- milestone acceptance text must be visible to the validator when present, +- parent milestone acceptance text must never affect a feature validator; it is evaluated only by the ready milestone rollup evaluator, - a behavioral/bug assertion must not pass on the read-only judge's advisory verdict alone — it requires a confirming non-mutating verification run, - a non-passing verification must resolve to `fail` or `inconclusive`, never a default pass, - advancement decisions must derive from validator outcomes only. diff --git a/docs/missions.md b/docs/missions.md index 4b327b4ef1..0f061c731b 100644 --- a/docs/missions.md +++ b/docs/missions.md @@ -446,6 +446,8 @@ interface MissionContractAssertion { id: string; // e.g., "CA-A3B7CD-E9F2" milestoneId: string; // Parent milestone sourceFeatureId?: string;// Store-managed feature assertion owner + scope: "feature" | "milestone"; + origin: "authored" | "imported" | "derived_milestone_acceptance"; title: string; // Human-readable title assertion: string; // Behavioral plan status: AssertionStatus; // pending | passed | failed | blocked @@ -484,7 +486,9 @@ interface MilestoneValidationRollup { #### Completion Gate Contract -Canonical authored feature criteria live on `MissionFeature.acceptanceCriteria`, but mission autopilot enforcement runs through each feature's **linked contract assertions** (store-managed per-feature assertion plus any additive linked milestone assertions). `milestone.acceptanceCriteria` remains authored milestone pass-bar text for humans, while validator gating/advance decisions follow assertion linkage and outcomes; see [Mission Completion Gate Contract](./missions-completion-contract.md) for the authoritative enforced-vs-informational surface map and zero-assertion behavior. +Canonical authored feature criteria live on `MissionFeature.acceptanceCriteria`, and each feature validator derives its verdict only from its **linked feature-scoped assertions**. Model summary prose, milestone prose, and behavioral results that are not mapped to a linked behavioral assertion cannot override that verdict. + +Milestone prose is synchronized to one canonical milestone-scoped assertion with `origin: "derived_milestone_acceptance"`. PostgreSQL restricts uniqueness to that derived origin per project/milestone; authored, imported, and migrated legacy milestone assertions stay independent, are never inferred from title/text, and require no feature links. The rollup evaluates all milestone-scoped assertions after feature coverage and feature assertion passes are ready; unmet parent criteria therefore block milestone completion without failing an already-passing feature. See [Mission Completion Gate Contract](./missions-completion-contract.md). ### Phase 3: Feature Execution Loop diff --git a/packages/core/src/__tests__/mission-store.sync-loop-transition.test.ts b/packages/core/src/__tests__/mission-store.sync-loop-transition.test.ts index 56c9c0cf2a..076b4ad87a 100644 --- a/packages/core/src/__tests__/mission-store.sync-loop-transition.test.ts +++ b/packages/core/src/__tests__/mission-store.sync-loop-transition.test.ts @@ -41,3 +41,56 @@ describe("MissionStore synchronous loop transitions", () => { expect(updateFeature).toHaveBeenCalledWith(feature.id, { loopState: "implementing" }); }); }); + +describe("MissionStore synchronous assertion schema compatibility", () => { + it("adds scope and origin before querying legacy assertion rows", () => { + const executed: string[] = []; + const db = { + prepare: vi.fn((sql: string) => ({ + get: vi.fn().mockReturnValue(undefined), + all: vi.fn().mockReturnValue(sql.startsWith("PRAGMA table_info") ? [ + { name: "id" }, + { name: "milestoneId" }, + { name: "assertion" }, + ] : []), + run: vi.fn(() => executed.push(sql)), + })), + bumpLastModified: vi.fn(), + } as unknown as Database; + + new MissionStore("/tmp/fusion-mission-store-test", db); + + expect(executed).toEqual(expect.arrayContaining([ + expect.stringContaining("ADD COLUMN scope"), + expect.stringContaining("ADD COLUMN origin"), + expect.stringContaining("SET scope = 'feature'"), + expect.stringContaining("SET origin = 'authored'"), + ])); + }); +}); + +describe("MissionStore derived milestone assertion invariant", () => { + it("rejects a second canonical derived assertion before sync insertion", () => { + const db = { + prepare: vi.fn().mockReturnValue({ get: vi.fn().mockReturnValue(undefined) }), + bumpLastModified: vi.fn(), + } as unknown as Database; + const store = new MissionStore("/tmp/fusion-mission-store-test", db); + vi.spyOn(store, "getMilestone").mockReturnValue({ id: "MS-1" } as never); + vi.spyOn(store, "listContractAssertions").mockReturnValue([{ + id: "CA-DERIVED", + milestoneId: "MS-1", + scope: "milestone", + origin: "derived_milestone_acceptance", + } as never]); + (db.prepare as unknown as ReturnType).mockClear(); + + expect(() => store.addContractAssertion("MS-1", { + title: "Canonical milestone criteria", + assertion: "Parent contract", + scope: "milestone", + origin: "derived_milestone_acceptance", + })).toThrow("already has a derived milestone acceptance assertion"); + expect(db.prepare).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/async-mission-store-queries.ts b/packages/core/src/async-mission-store-queries.ts index 52b609c70d..62de492bd1 100644 --- a/packages/core/src/async-mission-store-queries.ts +++ b/packages/core/src/async-mission-store-queries.ts @@ -45,7 +45,7 @@ import { and, asc, desc, eq, inArray, ne, sql, type AnyColumn, type SQL } from "drizzle-orm"; import * as schema from "./postgres/schema/index.js"; import type { AsyncDataLayer, DbTransaction } from "./postgres/data-layer.js"; -import { normalizeMissionAssertionType } from "./mission-types.js"; +import { normalizeMissionAssertionOrigin, normalizeMissionAssertionScope, normalizeMissionAssertionType } from "./mission-types.js"; import type { Mission, MissionBranchStrategy, @@ -221,6 +221,8 @@ export interface AssertionRow { type: string | null; orderIndex: number; sourceFeatureId: string | null; + scope: string | null; + origin: string | null; createdAt: string; updatedAt: string; } @@ -367,6 +369,8 @@ export const assertionColumns = { type: schema.project.missionContractAssertions.type, orderIndex: schema.project.missionContractAssertions.orderIndex, sourceFeatureId: schema.project.missionContractAssertions.sourceFeatureId, + scope: schema.project.missionContractAssertions.scope, + origin: schema.project.missionContractAssertions.origin, createdAt: schema.project.missionContractAssertions.createdAt, updatedAt: schema.project.missionContractAssertions.updatedAt, }; @@ -535,6 +539,8 @@ export function rowToAssertion(row: AssertionRow): MissionContractAssertion { id: row.id, milestoneId: row.milestoneId, sourceFeatureId: row.sourceFeatureId ?? undefined, + scope: normalizeMissionAssertionScope(row.scope), + origin: normalizeMissionAssertionOrigin(row.origin), title: row.title, assertion: row.assertion, status: row.status as MissionContractAssertion["status"], @@ -1356,6 +1362,8 @@ export async function createContractAssertion( type: normalizeMissionAssertionType(assertion.type), orderIndex: assertion.orderIndex, sourceFeatureId: assertion.sourceFeatureId ?? null, + scope: assertion.scope ?? "feature", + origin: assertion.origin ?? "authored", createdAt: assertion.createdAt, updatedAt: assertion.updatedAt, }); @@ -2025,7 +2033,12 @@ export async function listAssertionsForFeature(handle: QueryHandle, featureId: s eq(schema.project.missionContractAssertions.id, schema.project.missionFeatureAssertions.assertionId), ), ) - .where(and(missionProjectScope(schema.project.missionFeatureAssertions.projectId), eq(schema.project.missionFeatureAssertions.featureId, featureId))) + // FNXC:MissionValidation 2026-07-23-17:20: Ignore legacy milestone links so parent scope cannot re-enter feature grading. + .where(and( + missionProjectScope(schema.project.missionFeatureAssertions.projectId), + eq(schema.project.missionFeatureAssertions.featureId, featureId), + sql`${schema.project.missionContractAssertions.scope} <> 'milestone'`, + )) .orderBy( asc(schema.project.missionContractAssertions.orderIndex), asc(schema.project.missionContractAssertions.createdAt), diff --git a/packages/core/src/async-mission-store.ts b/packages/core/src/async-mission-store.ts index 01f72a5e6c..2824064308 100644 --- a/packages/core/src/async-mission-store.ts +++ b/packages/core/src/async-mission-store.ts @@ -640,6 +640,7 @@ export class AsyncMissionStore extends EventEmitter { }; const created = await createMilestone(handle, milestone); this.emit("milestone:created", created); + await this.synchronizeMilestoneAcceptanceAssertion(created); return created; } @@ -664,10 +665,45 @@ export class AsyncMissionStore extends EventEmitter { }; await updateMilestone(this.db, updated); this.emit("milestone:updated", updated); + if (updates.acceptanceCriteria !== undefined) { + await this.synchronizeMilestoneAcceptanceAssertion(updated); + } await this.recomputeMissionStatus(updated.missionId); return updated; } + /* + FNXC:MissionValidation 2026-07-23-14:30: + Acceptance prose has exactly one store-managed milestone assertion selected by + durable origin. Authored/imported rows are never selected by text or title and + survive criteria edits/removal unchanged. + */ + private async synchronizeMilestoneAcceptanceAssertion(milestone: Milestone): Promise { + const existing = (await listContractAssertions(this.db, milestone.id)) + .find((assertion) => assertion.origin === "derived_milestone_acceptance"); + const criteria = milestone.acceptanceCriteria?.trim(); + if (!criteria) { + if (existing) await this.deleteContractAssertion(existing.id); + return; + } + if (!existing) { + await this.addContractAssertion(milestone.id, { + title: "Milestone acceptance criteria", + assertion: criteria, + scope: "milestone", + origin: "derived_milestone_acceptance", + }); + return; + } + if (existing.assertion !== criteria) { + await this.updateContractAssertion(existing.id, { + title: "Milestone acceptance criteria", + assertion: criteria, + status: "pending", + }); + } + } + async deleteMilestone(id: string, force = false): Promise { const milestone = await getMilestone(this.db, id); if (!milestone) throw new Error(`Milestone ${id} not found`); @@ -1594,13 +1630,25 @@ export class AsyncMissionStore extends EventEmitter { async addContractAssertion(milestoneId: string, input: ContractAssertionCreateInput): Promise { const milestone = await getMilestone(this.db, milestoneId); if (!milestone) throw new Error(`Milestone ${milestoneId} not found`); - const now = new Date().toISOString(); + const origin = input.origin ?? "authored"; const existing = await listContractAssertions(this.db, milestoneId); + if (origin === "derived_milestone_acceptance" + && existing.some((assertion) => assertion.origin === "derived_milestone_acceptance")) { + /* + FNXC:MissionValidation 2026-07-23-17:20: + Reject duplicate canonical provenance before insert; PostgreSQL also + enforces this at rest, while authored/imported rows stay non-unique. + */ + throw new Error(`Milestone ${milestoneId} already has a derived milestone acceptance assertion`); + } + const now = new Date().toISOString(); const orderIndex = existing.length > 0 ? Math.max(...existing.map((a) => a.orderIndex)) + 1 : 0; const assertion: MissionContractAssertion = { id: this.generateId("CA"), milestoneId, sourceFeatureId: input.sourceFeatureId, + scope: input.scope ?? "feature", + origin, title: input.title, assertion: input.assertion, status: input.status || "pending", @@ -1663,6 +1711,10 @@ export class AsyncMissionStore extends EventEmitter { if (!feature) throw new Error(`Feature ${featureId} not found`); const assertion = await getContractAssertion(this.db, assertionId); if (!assertion) throw new Error(`Assertion ${assertionId} not found`); + // FNXC:MissionValidation 2026-07-23-15:05: Rollup-owned assertions are never feature evidence. + if (assertion.scope === "milestone") { + throw new Error(`Milestone-scoped assertion ${assertionId} cannot be linked to feature ${featureId}`); + } if (await featureAssertionLinkExists(this.db, featureId, assertionId)) { throw new Error(`Feature ${featureId} is already linked to assertion ${assertionId}`); } @@ -1802,7 +1854,9 @@ export class AsyncMissionStore extends EventEmitter { case "blocked": blockedAssertions++; break; case "pending": pendingAssertions++; break; } - if (!linkedAssertionIds.has(assertion.id)) unlinkedAssertions++; + // Rollup assertions are milestone-owned and intentionally have no + // feature link; only feature-scoped assertions need coverage. + if (assertion.scope !== "milestone" && !linkedAssertionIds.has(assertion.id)) unlinkedAssertions++; } /* diff --git a/packages/core/src/mission-store.ts b/packages/core/src/mission-store.ts index f8efd5134a..41080290e5 100644 --- a/packages/core/src/mission-store.ts +++ b/packages/core/src/mission-store.ts @@ -14,7 +14,7 @@ import { EventEmitter } from "node:events"; import type { Database } from "./db.js"; import { fromJson, toJson, toJsonNullable } from "./db.js"; -import { FEATURE_LOOP_TRANSITIONS, normalizeMissionAssertionType, renderValidationCause } from "./mission-types.js"; +import { FEATURE_LOOP_TRANSITIONS, normalizeMissionAssertionOrigin, normalizeMissionAssertionScope, normalizeMissionAssertionType, renderValidationCause } from "./mission-types.js"; import type { Goal, GoalStatus } from "./goal-types.js"; import type { Mission, @@ -282,6 +282,8 @@ interface AssertionRow { type: string | null; orderIndex: number; sourceFeatureId: string | null; + scope: string | null; + origin: string | null; createdAt: string; updatedAt: string; } @@ -398,6 +400,7 @@ export class MissionStore extends EventEmitter { ) { super(); this.setMaxListeners(100); + this.ensureMissionContractAssertionColumns(); // Initialize sequence counter from existing events to ensure uniqueness across restarts const lastEvent = this.db.prepare(` SELECT seq FROM mission_events ORDER BY seq DESC LIMIT 1 @@ -408,6 +411,42 @@ export class MissionStore extends EventEmitter { private _eventSeq = 0; private _milestonesMissingStructuredAssertions = new Set(); + /* + FNXC:MissionValidation 2026-07-23-20:30: + Sync-store compatibility must add assertion scope and provenance before any + assertion query or write. Pre-FN-8542 rows have no reliable scope signal, so + preserve them as independently authored feature assertions; milestone sync + may later add its separate provenance-identified derived assertion. + */ + private ensureMissionContractAssertionColumns(): void { + const schemaStatement = this.db.prepare("PRAGMA table_info(mission_contract_assertions)") as unknown as { + all?: () => Array<{ name?: string }>; + }; + // The production runtime uses AsyncMissionStore. Keep lightweight sync test + // doubles usable when they do not implement SQLite statement iteration. + const columns = schemaStatement.all?.(); + if (!Array.isArray(columns) || columns.length === 0) return; + + const names = new Set(columns.map((column) => column.name)); + if (!names.has("scope")) { + this.db.prepare("ALTER TABLE mission_contract_assertions ADD COLUMN scope TEXT NOT NULL DEFAULT 'feature'").run(); + } + if (!names.has("origin")) { + this.db.prepare("ALTER TABLE mission_contract_assertions ADD COLUMN origin TEXT NOT NULL DEFAULT 'authored'").run(); + } + + this.db.prepare(` + UPDATE mission_contract_assertions + SET scope = 'feature' + WHERE scope IS NULL OR scope NOT IN ('feature', 'milestone') + `).run(); + this.db.prepare(` + UPDATE mission_contract_assertions + SET origin = 'authored' + WHERE origin IS NULL OR origin NOT IN ('authored', 'imported', 'derived_milestone_acceptance') + `).run(); + } + // ── Row-to-Object Converters ─────────────────────────────────────── /** @@ -502,6 +541,8 @@ export class MissionStore extends EventEmitter { id: row.id, milestoneId: row.milestoneId, sourceFeatureId: row.sourceFeatureId || undefined, + scope: normalizeMissionAssertionScope(row.scope), + origin: normalizeMissionAssertionOrigin(row.origin), title: row.title, assertion: row.assertion, status: row.status as import("./mission-types.js").MissionAssertionStatus, @@ -1523,6 +1564,7 @@ export class MissionStore extends EventEmitter { this.db.bumpLastModified(); this.emit("milestone:created", milestone); + this.synchronizeMilestoneAcceptanceAssertion(milestone); return milestone; } @@ -1605,6 +1647,9 @@ export class MissionStore extends EventEmitter { this.db.bumpLastModified(); this.emit("milestone:updated", updated); + if (updates.acceptanceCriteria !== undefined) { + this.synchronizeMilestoneAcceptanceAssertion(updated); + } // Recompute mission status after milestone update this.recomputeMissionStatus(updated.missionId); @@ -1612,6 +1657,42 @@ export class MissionStore extends EventEmitter { return updated; } + /* + FNXC:MissionValidation 2026-07-23-15:00: + Milestone prose is represented by exactly one durable derived assertion. Sync + storage selects it only by origin, preserving authored/imported rows even when + their text matches; blank prose retires only that derived contract. + */ + private synchronizeMilestoneAcceptanceAssertion(milestone: Milestone): void { + const derived = this.listContractAssertions(milestone.id) + .filter((assertion) => assertion.origin === "derived_milestone_acceptance"); + if (derived.length > 1) { + throw new Error(`Milestone ${milestone.id} has multiple derived acceptance assertions`); + } + const existing = derived[0]; + const criteria = milestone.acceptanceCriteria?.trim(); + if (!criteria) { + if (existing) this.deleteContractAssertion(existing.id); + return; + } + if (!existing) { + this.addContractAssertion(milestone.id, { + title: "Milestone acceptance criteria", + assertion: criteria, + scope: "milestone", + origin: "derived_milestone_acceptance", + }); + return; + } + if (existing.assertion !== criteria || existing.title !== "Milestone acceptance criteria") { + this.updateContractAssertion(existing.id, { + title: "Milestone acceptance criteria", + assertion: criteria, + status: "pending", + }); + } + } + /** * Delete a milestone. * Cascades to delete all slices and features. @@ -3406,6 +3487,18 @@ export class MissionStore extends EventEmitter { throw new Error(`Milestone ${milestoneId} not found`); } + const origin = input.origin ?? "authored"; + if (origin === "derived_milestone_acceptance" + && this.listContractAssertions(milestoneId).some((assertion) => assertion.origin === "derived_milestone_acceptance")) { + /* + FNXC:MissionValidation 2026-07-23-17:20: + The sync store has no PostgreSQL partial index, so it must reject a second + canonical milestone-prose assertion before inserting it. Authored and + imported assertions remain intentionally non-unique. + */ + throw new Error(`Milestone ${milestoneId} already has a derived milestone acceptance assertion`); + } + const now = new Date().toISOString(); const id = this.generateAssertionId(); @@ -3419,6 +3512,8 @@ export class MissionStore extends EventEmitter { id, milestoneId, sourceFeatureId: input.sourceFeatureId, + scope: input.scope ?? "feature", + origin, title: input.title, assertion: input.assertion, status: input.status || "pending", @@ -3429,8 +3524,8 @@ export class MissionStore extends EventEmitter { }; this.db.prepare(` - INSERT INTO mission_contract_assertions (id, milestoneId, title, assertion, status, type, orderIndex, sourceFeatureId, createdAt, updatedAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO mission_contract_assertions (id, milestoneId, title, assertion, status, type, orderIndex, sourceFeatureId, scope, origin, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( assertion.id, assertion.milestoneId, @@ -3440,6 +3535,8 @@ export class MissionStore extends EventEmitter { assertion.type, assertion.orderIndex, assertion.sourceFeatureId ?? null, + assertion.scope ?? "feature", + assertion.origin ?? "authored", assertion.createdAt, assertion.updatedAt, ); @@ -3610,6 +3707,9 @@ export class MissionStore extends EventEmitter { if (!assertion) { throw new Error(`Assertion ${assertionId} not found`); } + if (assertion.scope === "milestone") { + throw new Error(`Milestone-scoped assertion ${assertionId} cannot be linked to feature ${featureId}`); + } // Check if link already exists const existing = this.db.prepare( @@ -3672,7 +3772,7 @@ export class MissionStore extends EventEmitter { const rows = this.db.prepare(` SELECT ca.* FROM mission_contract_assertions ca INNER JOIN mission_feature_assertions fa ON ca.id = fa.assertionId - WHERE fa.featureId = ? + WHERE fa.featureId = ? AND ca.scope != 'milestone' ORDER BY ca.orderIndex ASC, ca.createdAt ASC, ca.id ASC `).all(featureId); return (rows as unknown as AssertionRow[]).map((row) => this.rowToAssertion(row)); @@ -3762,10 +3862,12 @@ export class MissionStore extends EventEmitter { break; } - // Check if assertion is linked to any feature - const linkedFeatures = this.listFeaturesForAssertion(assertion.id); - if (linkedFeatures.length === 0) { - unlinkedAssertions++; + // Milestone-scoped assertions are evaluated at rollup and deliberately + // require no feature coverage. Only feature assertions participate in + // the coverage invariant. + if (assertion.scope !== "milestone") { + const linkedFeatures = this.listFeaturesForAssertion(assertion.id); + if (linkedFeatures.length === 0) unlinkedAssertions++; } } diff --git a/packages/core/src/mission-types.ts b/packages/core/src/mission-types.ts index 1cd8aa0c41..dc23f545af 100644 --- a/packages/core/src/mission-types.ts +++ b/packages/core/src/mission-types.ts @@ -723,6 +723,23 @@ export type MissionAssertionType = (typeof MISSION_ASSERTION_TYPES)[number]; /** The conservative default assertion type (preserves legacy static judging). */ export const DEFAULT_MISSION_ASSERTION_TYPE: MissionAssertionType = "static"; +/** Assertions belong either to an individual feature or to milestone rollup. */ +export const MISSION_ASSERTION_SCOPES = ["feature", "milestone"] as const; +export type MissionAssertionScope = (typeof MISSION_ASSERTION_SCOPES)[number]; + +/** Provenance separates the one store-managed milestone criterion from authored rows. */ +export const MISSION_ASSERTION_ORIGINS = ["authored", "imported", "derived_milestone_acceptance"] as const; +export type MissionAssertionOrigin = (typeof MISSION_ASSERTION_ORIGINS)[number]; + +export function normalizeMissionAssertionOrigin(value: unknown): MissionAssertionOrigin { + return value === "imported" || value === "derived_milestone_acceptance" ? value : "authored"; +} + +/** Normalize legacy rows to feature scope until explicitly migrated. */ +export function normalizeMissionAssertionScope(value: unknown): MissionAssertionScope { + return value === "milestone" ? "milestone" : "feature"; +} + /** Normalize an arbitrary stored value to a valid assertion type, defaulting conservatively. */ export function normalizeMissionAssertionType(value: unknown): MissionAssertionType { return value === "behavioral" ? "behavioral" : DEFAULT_MISSION_ASSERTION_TYPE; @@ -764,6 +781,10 @@ export interface MissionContractAssertion { milestoneId: string; /** Feature ID when this assertion is store-managed for a specific feature */ sourceFeatureId?: string; + /** Validation boundary; milestone assertions are never feature-link coverage. */ + scope?: MissionAssertionScope; + /** Stable provenance; only the derived milestone origin is unique per milestone. */ + origin?: MissionAssertionOrigin; /** Human-readable title describing the assertion */ title: string; /** The behavioral specification or acceptance test content */ @@ -834,6 +855,10 @@ export interface ContractAssertionCreateInput { type?: MissionAssertionType; /** Feature ID when this assertion is store-managed for a specific feature */ sourceFeatureId?: string; + /** Validation boundary; defaults to a feature assertion. */ + scope?: MissionAssertionScope; + /** Origin defaults to independently authored. */ + origin?: MissionAssertionOrigin; } /** diff --git a/packages/core/src/postgres/migrations/0000_initial.sql b/packages/core/src/postgres/migrations/0000_initial.sql index db13b4dd4b..054f815a4e 100644 --- a/packages/core/src/postgres/migrations/0000_initial.sql +++ b/packages/core/src/postgres/migrations/0000_initial.sql @@ -1439,6 +1439,8 @@ CREATE TABLE IF NOT EXISTS project.mission_contract_assertions ( type text NOT NULL DEFAULT 'static', order_index integer NOT NULL DEFAULT 0, source_feature_id text, + scope text NOT NULL DEFAULT 'feature', + origin text NOT NULL DEFAULT 'authored', created_at text NOT NULL, updated_at text NOT NULL ); diff --git a/packages/core/src/postgres/migrations/0034_milestone_assertion_provenance.sql b/packages/core/src/postgres/migrations/0034_milestone_assertion_provenance.sql new file mode 100644 index 0000000000..bc484801a5 --- /dev/null +++ b/packages/core/src/postgres/migrations/0034_milestone_assertion_provenance.sql @@ -0,0 +1,30 @@ +/* +FNXC:MissionValidation 2026-07-23-14:30: +FN-8542 keeps historic milestone assertions independently authored. A canonical +row derived from Milestone.acceptanceCriteria is identifiable only by origin, +never mutable title/text; the partial index permits any number of authored or +imported rows while enforcing one derived row per project/milestone. +*/ +/* +FNXC:MissionValidation 2026-07-23-19:10: +Pre-FN-8542 PostgreSQL baselines have neither assertion scope nor provenance. +Upgrade scope first and backfill it to feature so project-scoped reads and writes +can safely distinguish feature coverage before provenance synchronization runs. +*/ +ALTER TABLE project.mission_contract_assertions + ADD COLUMN IF NOT EXISTS scope text NOT NULL DEFAULT 'feature'; + +ALTER TABLE project.mission_contract_assertions + ADD COLUMN IF NOT EXISTS origin text NOT NULL DEFAULT 'authored'; + +UPDATE project.mission_contract_assertions +SET scope = 'feature' +WHERE scope IS NULL OR scope NOT IN ('feature', 'milestone'); + +UPDATE project.mission_contract_assertions +SET origin = 'authored' +WHERE origin IS NULL OR origin NOT IN ('authored', 'imported', 'derived_milestone_acceptance'); + +CREATE UNIQUE INDEX IF NOT EXISTS "uqContractAssertionsDerivedMilestone" + ON project.mission_contract_assertions(project_id, milestone_id) + WHERE origin = 'derived_milestone_acceptance'; diff --git a/packages/core/src/postgres/schema-applier.ts b/packages/core/src/postgres/schema-applier.ts index 51dee47efb..0ac2c31637 100644 --- a/packages/core/src/postgres/schema-applier.ts +++ b/packages/core/src/postgres/schema-applier.ts @@ -50,7 +50,7 @@ Advance the PostgreSQL schema ceiling for the durable wedge episode column. The forward migration must run before TaskStore writes the new field on fresh and upgraded databases. */ -export const SCHEMA_BASELINE_VERSION = "0033"; +export const SCHEMA_BASELINE_VERSION = "0034"; /** FNXC:SymbolLock 2026-07-31-10:00: upgrades need durable task declarations before admission resolves symbols. */ export const TASK_DECLARED_SYMBOLS_VERSION = "0028"; const INITIAL_SCHEMA_VERSION = "0000"; @@ -146,6 +146,8 @@ export const WORKFLOW_TASK_CONTINUATIONS_VERSION = "0031"; export const LEGACY_ADOPTION_DRAINED_MARKER_RUNTIME_GRANTS_VERSION = "0032"; /** FNXC:TaskWedgeNotifications 2026-10-19-00:00: manually register the durable wedge episode migration for PostgreSQL upgrades. */ export const TASK_WEDGE_NOTIFICATION_VERSION = "0033"; +/** FNXC:MissionValidation 2026-07-23-14:30: provenance-safe milestone criteria require an explicit upgrade. */ +export const MILESTONE_ASSERTION_PROVENANCE_VERSION = "0034"; /** SECURITY DEFINER helper that only inserts LEGACY_ADOPTION_DRAINED_MARKER. */ export const LEGACY_ADOPTION_DRAINED_MARKER_FUNCTION = "fusion_mark_legacy_adoption_drained"; @@ -350,6 +352,10 @@ const TASK_WEDGE_NOTIFICATION_MIGRATION_PATH = join( MIGRATIONS_DIR, "0033_fn-8505_wedge_notification.sql", ); +const MILESTONE_ASSERTION_PROVENANCE_MIGRATION_PATH = join( + MIGRATIONS_DIR, + "0034_milestone_assertion_provenance.sql", +); /** * Ensure the migration bookkeeping table exists. Lives in the public schema so @@ -453,6 +459,7 @@ export async function applySchemaBaseline( LEGACY_ADOPTION_DRAINED_MARKER_RUNTIME_GRANTS_VERSION, ); const taskWedgeNotificationAlreadyApplied = applied.includes(TASK_WEDGE_NOTIFICATION_VERSION); + const milestoneAssertionProvenanceAlreadyApplied = applied.includes(MILESTONE_ASSERTION_PROVENANCE_VERSION); assertBinaryNotOlderThanDatabase(applied); let schemaChanged = false; @@ -939,6 +946,21 @@ export async function applySchemaBaseline( schemaChanged = true; } + /* + FNXC:MissionValidation 2026-07-23-14:30: + Register every forward migration explicitly: discovery is intentionally + disabled, and an unregistered provenance migration would silently leave + upgraded clusters unable to identify the canonical milestone assertion. + */ + if (!milestoneAssertionProvenanceAlreadyApplied) { + const migrationSql = await readFile(MILESTONE_ASSERTION_PROVENANCE_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(migrationSql)); + await tx.execute( + sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${MILESTONE_ASSERTION_PROVENANCE_VERSION}) ON CONFLICT (version) DO NOTHING`, + ); + schemaChanged = true; + } + return { applied: schemaChanged, pluginHooksRun: pluginHooks.length }; }); } diff --git a/packages/core/src/postgres/schema/project.ts b/packages/core/src/postgres/schema/project.ts index 0e8203a8bc..2df9213bb6 100644 --- a/packages/core/src/postgres/schema/project.ts +++ b/packages/core/src/postgres/schema/project.ts @@ -1984,6 +1984,7 @@ export const missionContractAssertions = projectSchema.table("mission_contract_a orderIndex: integer("order_index").notNull().default(0), sourceFeatureId: text("source_feature_id"), scope: text("scope").notNull().default("feature"), + origin: text("origin").notNull().default("authored"), createdAt: text("created_at").notNull(), updatedAt: text("updated_at").notNull(), }, (t) => [ diff --git a/packages/engine/src/__tests__/mission-execution-loop.test.ts b/packages/engine/src/__tests__/mission-execution-loop.test.ts index db39bfa9db..a4781aee50 100644 --- a/packages/engine/src/__tests__/mission-execution-loop.test.ts +++ b/packages/engine/src/__tests__/mission-execution-loop.test.ts @@ -1117,7 +1117,7 @@ describe("MissionExecutionLoop", () => { expect(missionStore.startValidatorRun).toHaveBeenCalledWith("F-LATER", "task_completion", "FN-LATER"); }); - it("threads milestone acceptance criteria into validator prompts", () => { + it("excludes milestone acceptance criteria from feature validator prompts", () => { const feature = createMockFeature({ id: "F-MILESTONE", title: "Feature under milestone", @@ -1149,10 +1149,10 @@ describe("MissionExecutionLoop", () => { const prompt = (loop as any).buildValidationPrompt(feature, assertions, milestone); const systemPrompt = (loop as any).buildValidationSystemPrompt(feature, assertions, "Task context", milestone); - expect(prompt).toContain("Milestone pass bar text"); - expect(prompt).toContain("must also be satisfied for this feature to pass"); - expect(systemPrompt).toContain("Milestone pass bar text"); - expect(systemPrompt).toContain("validator-executed requirements"); + expect(prompt).not.toContain("Milestone pass bar text"); + expect(prompt).toContain("only the following linked feature contract assertions"); + expect(systemPrompt).not.toContain("Milestone pass bar text"); + expect(systemPrompt).toContain("linked feature contract assertions"); }); it("does NOT create a board task for single-feature validation", async () => { @@ -1643,6 +1643,51 @@ describe("MissionExecutionLoop", () => { expectNoValidationBoardTaskMutation(taskStore); }); + it("derives pass from complete linked assertions despite contradictory model fail", async () => { + const assertions = makeAssertions(2); + mockSessionHolder.session.state.messages = [{ + role: "assistant", + content: JSON.stringify({ + status: "fail", + summary: "parent milestone work is unfinished", + assertions: [ + { assertionId: "CA-1", passed: true }, + { assertionId: "CA-2", passed: true }, + { assertionId: "unknown-parent", passed: false }, + ], + }), + }]; + loop = new MissionExecutionLoop({ taskStore: taskStore as any, missionStore: missionStore as any, rootDir: "/tmp" }); + + await expect((loop as any).parseValidationResult(mockSessionHolder.session, assertions)).resolves.toMatchObject({ + status: "pass", + assertions: [{ assertionId: "CA-1", passed: true }, { assertionId: "CA-2", passed: true }], + }); + }); + + it("fails closed for omitted and duplicate linked assertion results", async () => { + const assertions = makeAssertions(2); + mockSessionHolder.session.state.messages = [{ + role: "assistant", + content: JSON.stringify({ + status: "pass", + assertions: [ + { assertionId: "CA-1", passed: true }, + { assertionId: "CA-1", passed: true }, + ], + }), + }]; + loop = new MissionExecutionLoop({ taskStore: taskStore as any, missionStore: missionStore as any, rootDir: "/tmp" }); + + await expect((loop as any).parseValidationResult(mockSessionHolder.session, assertions)).resolves.toMatchObject({ + status: "fail", + assertions: [ + { assertionId: "CA-1", passed: false }, + { assertionId: "CA-2", passed: false }, + ], + }); + }); + it("should parse fail result from JSON in markdown code block", async () => { const assertions = makeAssertions(2); const response = { @@ -2570,6 +2615,112 @@ describe("MissionExecutionLoop", () => { }); }); + // ── milestone-only contract readiness ─────────────────────────────────── + + describe("milestone-only contract readiness", () => { + it("grades milestone assertions directly after all no-assertion feature work completes", async () => { + const feature = createMockFeature({ + id: "F-PARENT-ONLY", + loopState: "implementing", + taskId: "FN-PARENT-ONLY", + acceptanceCriteria: undefined, + }); + missionStore._setFeature(feature); + missionStore.getFeatureByTaskId = vi.fn().mockReturnValue(feature); + missionStore.listAssertionsForFeature = vi.fn().mockReturnValue([]); + missionStore.ensureFeatureAssertionLinked = vi.fn().mockReturnValue([]); + missionStore.listSlices = vi.fn().mockReturnValue([createMockSlice({ id: "SL-001", milestoneId: "MS-001" })]); + missionStore.listFeatures = vi.fn(() => [missionStore.getFeature("F-PARENT-ONLY")]); + missionStore.listContractAssertions = vi.fn().mockReturnValue([{ + ...makeAssertions(1)[0], + id: "CA-MILESTONE", + scope: "milestone", + }]); + missionStore.listFeaturesForAssertion = vi.fn().mockReturnValue([]); + missionStore.updateContractAssertion = vi.fn(); + taskStore._setTask({ id: "FN-PARENT-ONLY", title: "Parent-only feature", description: "done", log: [] }); + + loop = new MissionExecutionLoop({ taskStore: taskStore as any, missionStore: missionStore as any, rootDir: "/tmp" }); + vi.spyOn(loop as any, "runValidation").mockResolvedValue({ + result: { + status: "pass", + assertions: [{ assertionId: "CA-MILESTONE", passed: true, message: "parent criterion met" }], + summary: "parent criterion met", + }, + inspection: { inspectionRoot: "/tmp", landedSha: undefined, fallbackUsed: true, workspaceStale: false }, + }); + loop.start(); + + await loop.processTaskOutcome("FN-PARENT-ONLY"); + + expect(missionStore.updateContractAssertion).toHaveBeenCalledWith("CA-MILESTONE", { status: "passed" }); + }); + + it("runs a parent-only milestone rollup during recovery when no feature can trigger it", async () => { + const milestone = createMockMilestone({ id: "MS-NO-FEATURES", missionId: "M-TEST1" }); + missionStore.getMissionWithHierarchy = vi.fn(() => ({ + ...missionStore.getMission("M-TEST1"), + milestones: [{ ...milestone, slices: [] }], + })); + missionStore.listSlices = vi.fn().mockReturnValue([]); + missionStore.listFeatures = vi.fn().mockReturnValue([]); + missionStore.listContractAssertions = vi.fn().mockReturnValue([{ + ...makeAssertions(1)[0], + id: "CA-EMPTY-MILESTONE", + milestoneId: milestone.id, + scope: "milestone", + }]); + missionStore.listFeaturesForAssertion = vi.fn().mockReturnValue([]); + missionStore.updateContractAssertion = vi.fn(); + + loop = new MissionExecutionLoop({ taskStore: taskStore as any, missionStore: missionStore as any, rootDir: "/tmp" }); + vi.spyOn(loop as any, "runValidation").mockResolvedValue({ + result: { + status: "pass", + assertions: [{ assertionId: "CA-EMPTY-MILESTONE", passed: true, message: "parent criterion met" }], + summary: "parent criterion met", + }, + inspection: { inspectionRoot: "/tmp", landedSha: undefined, fallbackUsed: true, workspaceStale: false }, + }); + + await loop.recoverActiveMissions(); + + expect(missionStore.updateContractAssertion).toHaveBeenCalledWith("CA-EMPTY-MILESTONE", { status: "passed" }); + expect((loop as any).runValidation).toHaveBeenCalledWith( + expect.objectContaining({ id: `milestone:${milestone.id}` }), + expect.any(Array), + expect.any(Object), + "milestone", + ); + }); + + it("does not persist a failed assertion from an untrusted pre-merge inspection", async () => { + const feature = createMockFeature({ id: "F-DEFERRED", loopState: "implementing", taskId: "FN-DEFERRED" }); + missionStore._setFeature(feature); + missionStore.getFeatureByTaskId = vi.fn().mockReturnValue(feature); + const assertion = makeAssertions(1)[0]; + missionStore.listAssertionsForFeature = vi.fn().mockReturnValue([assertion]); + missionStore.updateContractAssertion = vi.fn(); + taskStore._setTask({ id: "FN-DEFERRED", title: "Unmerged", description: "pending merge", log: [], column: "in-review" }); + + loop = new MissionExecutionLoop({ taskStore: taskStore as any, missionStore: missionStore as any, rootDir: "/tmp" }); + vi.spyOn(loop as any, "runValidation").mockResolvedValue({ + result: { + status: "fail", + assertions: [{ assertionId: assertion.id, passed: false, message: "not trustworthy yet" }], + summary: "not trustworthy yet", + }, + inspection: { inspectionRoot: "/tmp", landedSha: undefined, fallbackUsed: true, workspaceStale: false }, + }); + loop.start(); + + await loop.processTaskOutcome("FN-DEFERRED"); + + expect(missionStore.updateContractAssertion).not.toHaveBeenCalled(); + expect(missionStore.createGeneratedFixFeature).not.toHaveBeenCalled(); + }); + }); + // ── Retry budget enforcement ───────────────────────────────────────────── describe("retry budget enforcement", () => { diff --git a/packages/engine/src/mission-execution-loop.ts b/packages/engine/src/mission-execution-loop.ts index 88bbc62569..5d2af125a5 100644 --- a/packages/engine/src/mission-execution-loop.ts +++ b/packages/engine/src/mission-execution-loop.ts @@ -441,6 +441,19 @@ export class MissionExecutionLoop extends EventEmitter { } } } + + /* + FNXC:MissionValidation 2026-07-23-20:30: + A parent-only contract has no feature-completion event to enter the + rollup path. Recovery is also the milestone-completion trigger for + zero-feature and no-feature-assertion milestones, but the shared + readiness gate still requires all feature work to be done. + */ + try { + await this.runMilestoneValidationForMilestoneIfReady(milestone); + } catch (err) { + loopLog.error(`Recovery failed to validate milestone ${milestone.id}:`, err); + } } } @@ -548,8 +561,12 @@ export class MissionExecutionLoop extends EventEmitter { assertions = await this.missionStore.ensureFeatureAssertionLinked(feature.id); } if (assertions.length === 0) { - // FNXC:MissionValidation 2026-07-17-16:45: no-assertion features remain a valid completion path when linkage cannot derive an assertion. + // FNXC:MissionValidation 2026-07-23-18:00: A feature without a derivable + // contract can complete, but it must still trigger the direct milestone + // path. That path independently proves all sibling work is done before + // grading parent-only assertions; parent prose never becomes this feature's fail. await this.handleValidationPass(feature.id, undefined, "No assertions linked to feature"); + await this.runMilestoneValidationIfReady(feature); return; } @@ -565,9 +582,34 @@ export class MissionExecutionLoop extends EventEmitter { const { result, inspection } = await this.runValidation(feature, assertions, run); + // A fail is not durable evidence until its inspection root is trusted. + // Do this before mutating assertion state: a pre-merge or stale checkout + // must leave linked assertions pending for a later, trustworthy validator. + const premergeColumn = result.status === "fail" + ? await this.getPremergeTaskColumn(feature.taskId) + : null; + const deferredFail = result.status === "fail" + && (Boolean(premergeColumn) || inspection.workspaceStale || Boolean(inspection.inspectionUnavailableReason)); + + // Persist only authoritative results from a trusted inspection. The rollup + // readiness gate consumes these statuses instead of model summary prose. + const updateAssertion = (this.missionStore as unknown as { + updateContractAssertion?: (id: string, updates: { status: "passed" | "blocked" | "failed" }) => unknown; + }).updateContractAssertion; + if (!deferredFail && typeof updateAssertion === "function") { + for (const assertion of assertions) { + const verdict = result.assertions.find((entry) => entry.assertionId === assertion.id); + if (!verdict) continue; + await updateAssertion.call(this.missionStore, assertion.id, { + status: verdict.passed ? "passed" : verdict.verdict === "blocked" ? "blocked" : "failed", + }); + } + } + // Handle the result if (result.status === "pass") { await this.handleValidationPass(feature.id, run.id, result.summary); + await this.runMilestoneValidationIfReady(feature); } else if (result.status === "fail") { // A "fail" verdict is only trustworthy once the linked task's code has // actually landed (done/archived). If the task is still mid-pipeline @@ -576,7 +618,6 @@ export class MissionExecutionLoop extends EventEmitter { // inconclusive outcome (R21, no Fix Feature) and let a later validation // judge the merged code. Missing task / unknown column falls through to // the normal fail handling (defer only on affirmative evidence). - const premergeColumn = await this.getPremergeTaskColumn(feature.taskId); if (premergeColumn) { await this.handleValidationInconclusive( feature.id, @@ -666,13 +707,15 @@ export class MissionExecutionLoop extends EventEmitter { feature: MissionFeature, assertions: MissionContractAssertion[], _run: MissionValidatorRun, + scope: "feature" | "milestone" = "feature", ): Promise { loopLog.log(`Running validation for feature ${feature.id} with ${assertions.length} assertions`); - const milestone = await this.resolveFeatureMilestone(feature); - - // Build the validation prompt - const prompt = this.buildValidationPrompt(feature, assertions, milestone); + // FNXC:MissionValidation 2026-07-23-14:00: + // FN-8542 confines an individual feature verdict to its linked feature + // assertions. Parent milestone criteria are evaluated by the rollup lane, + // so they are deliberately not supplied to this feature-validation session. + const prompt = this.buildValidationPrompt(feature, assertions, scope); // Get task context for validation const task = feature.taskId ? await this.taskStore.getTask(feature.taskId) : null; @@ -730,7 +773,7 @@ export class MissionExecutionLoop extends EventEmitter { runtimeHint: validationRuntimeHint, pluginRunner: this.pluginRunner, cwd: inspectionRoot, - systemPrompt: this.buildValidationSystemPrompt(feature, assertions, taskContext, milestone), + systemPrompt: this.buildValidationSystemPrompt(feature, assertions, taskContext, scope), tools: "readonly", defaultProvider: validationSessionModel.provider, defaultModelId: validationSessionModel.modelId, @@ -865,10 +908,12 @@ export class MissionExecutionLoop extends EventEmitter { if (t === "behavioral") hasBehavioral = true; } - // Fast path: no behavioral assertions → existing static path is preserved - // exactly. This keeps every existing (untyped/static) test green. + // All parser outputs are canonicalized against the supplied linked assertion + // set before this posture runs, including static-only runs. The absence of a + // behavioral assertion merely avoids verification work; it must not restore + // trust in a model-provided aggregate status. if (!hasBehavioral) { - return judgeResult; + return this.deriveFeatureValidationStatus(judgeResult, false); } const textById = new Map(assertions.map((a) => [a.id, a.assertion])); @@ -910,6 +955,20 @@ export class MissionExecutionLoop extends EventEmitter { outcome = { verdict: "inconclusive", assertionId: judged.assertionId, reason: `verification error: ${message}` }; } + // A verifier must identify the same linked behavioral assertion it was + // asked to exercise. Unmapped evidence is inconclusive diagnostics, not + // permission to override another assertion or mint a feature fix. + if (outcome.assertionId !== judged.assertionId || !typeById.has(outcome.assertionId)) { + sawInconclusive = true; + inconclusiveReason = inconclusiveReason ?? "behavioral verification returned an unmapped assertion"; + return { + ...judged, + verdict: "blocked", + passed: false, + message: "Behavioral verification returned unmapped evidence.", + }; + } + // FNXC:MissionValidationDiagnostics 2026-07-23-12:30: Behavioral verification is an authoritative execution path, so its reason/detail must join judge evidence before the shared normalizer bounds and redacts it. const behavioralEvidence = [{ kind: "behavioral-verification", @@ -951,10 +1010,8 @@ export class MissionExecutionLoop extends EventEmitter { let status: ValidationResult["status"]; if (sawInconclusive && !allPassed) { status = "inconclusive"; - } else if (allPassed) { - status = "pass"; } else { - status = "fail"; + status = this.deriveFeatureValidationStatus({ ...judgeResult, assertions: newAssertionResults }, false).status; } const summary = status === "pass" @@ -1075,12 +1132,12 @@ export class MissionExecutionLoop extends EventEmitter { const summary = typeof parsed.summary === "string" ? parsed.summary : `Validation ${status}`; const blockedReason = typeof parsed.blockedReason === "string" ? parsed.blockedReason : undefined; - return { + return this.deriveFeatureValidationStatus({ status, assertions: assertionResults, summary, blockedReason, - }; + }, true); } catch (err) { loopLog.error("Error parsing validation result", err); return this.createErrorValidationResult(`Error parsing validation: ${err}`, assertions); @@ -1191,9 +1248,14 @@ export class MissionExecutionLoop extends EventEmitter { parsed: Record, assertions: MissionContractAssertion[], ): ValidationResult["assertions"] { - const results: ValidationResult["assertions"] = []; + const byId = new Map(); + const authoritativeIds = new Set(assertions.map((assertion) => assertion.id)); + const duplicateIds = new Set(); - // If assertions array is provided in the response, use it + // FNXC:MissionValidation 2026-07-23-14:00: + // FN-8542 makes a contradictory aggregate structurally impossible. Only one + // result for every authoritative linked assertion participates; unknown IDs + // are ignored, duplicates and omissions are non-passing evidence. if (Array.isArray(parsed.assertions)) { for (const item of parsed.assertions) { if (typeof item === "object" && item !== null) { @@ -1222,8 +1284,13 @@ export class MissionExecutionLoop extends EventEmitter { return kind || text ? [{ ...(kind ? { kind } : {}), ...(text ? { text } : {}) }] : []; }) : undefined; - results.push({ - assertionId: assertionId || "unknown", + if (!assertionId || !authoritativeIds.has(assertionId)) continue; + if (byId.has(assertionId)) { + duplicateIds.add(assertionId); + continue; + } + byId.set(assertionId, { + assertionId, verdict, passed, message: typeof assertionItem.message === "string" ? assertionItem.message : undefined, @@ -1235,25 +1302,42 @@ export class MissionExecutionLoop extends EventEmitter { } } - // Backfill any linked assertions the judge omitted from its response. A - // partial judge response must not silently drop assertions: every linked - // assertion needs a result so behavioral assertions still reach - // verifyBehavioralAssertion and the aggregate is computed over the full set. - if (assertions.length > 0) { - const seen = new Set(results.map((r) => r.assertionId)); - const overallPassed = parsed.status === "pass"; - for (const assertion of assertions) { - if (seen.has(assertion.id)) continue; - results.push({ + return assertions.map((assertion) => { + if (duplicateIds.has(assertion.id)) { + return { assertionId: assertion.id, - verdict: overallPassed ? "pass" : "fail", - passed: overallPassed, - message: overallPassed ? "Passed" : "Failed", - }); + verdict: "fail" as const, + passed: false, + message: "Duplicate validator result for linked assertion.", + }; } - } + return byId.get(assertion.id) ?? { + assertionId: assertion.id, + verdict: "fail" as const, + passed: false, + message: "Validator omitted linked assertion result.", + }; + }); + } - return results; + /** + * Derive an aggregate only from canonical linked assertion results. + * Model summary prose and its aggregate status are intentionally excluded. + */ + private deriveFeatureValidationStatus( + result: ValidationResult, + preserveTerminal: boolean, + ): ValidationResult { + if (preserveTerminal && (result.status === "error" || result.status === "blocked")) return result; + if (result.assertions.some((assertion) => assertion.verdict === "blocked")) { + return { ...result, status: "blocked" }; + } + return { + ...result, + status: result.assertions.length > 0 && result.assertions.every((assertion) => assertion.passed) + ? "pass" + : "fail", + }; } /** @@ -1281,24 +1365,21 @@ export class MissionExecutionLoop extends EventEmitter { private buildValidationPrompt( feature: MissionFeature, assertions: MissionContractAssertion[], - milestone?: Milestone, + scope: "feature" | "milestone" = "feature", ): string { const assertionTexts = assertions .map((a, i) => `${i + 1}. **${a.title}**: ${a.assertion}`) .join("\n"); - const milestoneAcceptanceCriteria = milestone?.acceptanceCriteria?.trim(); - const milestoneContext = milestoneAcceptanceCriteria - ? `\nMilestone acceptance criteria (must also be satisfied for this feature to pass):\n${milestoneAcceptanceCriteria}\n` - : ""; - return `Evaluate the implementation for feature "${feature.title}" against the following contract assertions: + const subject = scope === "milestone" ? "milestone rollup" : `feature "${feature.title}"`; + const boundary = scope === "milestone" ? "milestone-scoped" : "linked feature"; + return `Evaluate the implementation for ${subject} against only the following ${boundary} contract assertions: -${assertionTexts}${milestoneContext} +${assertionTexts} For each assertion: - Determine if the implementation satisfies the assertion (pass/fail/blocked) - If failed, explain what was expected vs what was actually observed - If blocked, explain what external factor prevented validation -- Also verify that the implementation satisfies any milestone acceptance criteria provided above Respond with a JSON object in this format: { @@ -1328,22 +1409,21 @@ Be thorough and objective. If any assertion fails, the overall status should be _feature: MissionFeature, _assertions: MissionContractAssertion[], taskContext: string, - milestone?: Milestone, + scope: "feature" | "milestone" = "feature", ): string { - const milestoneAcceptanceCriteria = milestone?.acceptanceCriteria?.trim(); - return `You are a validation agent responsible for evaluating whether an implementation satisfies its contract assertions. + const boundary = scope === "milestone" ? "milestone-scoped" : "linked feature"; + return `You are a validation agent responsible for evaluating whether an implementation satisfies its ${boundary} contract assertions. You will receive: 1. A feature description with its acceptance criteria -2. Contract assertions to evaluate against -3. Task context including the implementation details${milestoneAcceptanceCriteria ? `\n4. Milestone acceptance criteria text that also applies to this feature: ${milestoneAcceptanceCriteria}` : ""} +2. ${boundary} contract assertions to evaluate against +3. Task context including the implementation details Your job is to: 1. Carefully review the implementation as described in the task context -2. Evaluate each contract assertion objectively -3. Determine if the implementation fully satisfies each assertion -4. Verify the implementation also satisfies any milestone acceptance criteria provided for the parent milestone -5. Return a structured JSON response with your findings +2. Evaluate each supplied ${boundary} contract assertion objectively +3. Determine if the implementation fully satisfies each supplied assertion +4. Return a structured JSON response with your findings Be thorough and precise. A contract assertion represents a commitment made during planning - the implementation must fully satisfy it or it is considered failed. @@ -1352,7 +1432,6 @@ Evaluation guidance: - "fail" means one or more assertions are unmet or only partially satisfied. - "blocked" means you cannot evaluate due to missing/insufficient evidence or external constraints. - Partial satisfaction must be marked as failed with clear expected vs actual details. -- Milestone acceptance criteria are validator-executed requirements, not informational context. - For every assertion, include the concrete evidence you considered. Evidence must identify the relevant file, command, or concise test output; do not include secrets or full unbounded command output. Response format: Return ONLY a JSON object (no additional text) with this structure: @@ -1396,6 +1475,81 @@ ${taskContext ? `\n\nImplementation context:\n${taskContext}` : ""}`; return lines.join("\n"); } + /* + FNXC:MissionValidation 2026-07-23-15:20: + Parent acceptance criteria are judged only after every feature assertion is + linked and terminal-passed. This separate rollup pass can update only + milestone assertions; it never routes parent failures through feature fixes. + */ + private async runMilestoneValidationIfReady(feature: MissionFeature): Promise { + const milestone = await this.resolveFeatureMilestone(feature); + if (!milestone) return; + await this.runMilestoneValidationForMilestoneIfReady(milestone, feature); + } + + private async runMilestoneValidationForMilestoneIfReady( + milestone: Milestone, + contextFeature?: MissionFeature, + ): Promise { + if (typeof this.missionStore.listContractAssertions !== "function" + || typeof this.missionStore.listFeaturesForAssertion !== "function" + || typeof this.missionStore.updateContractAssertion !== "function") return; + /* + FNXC:MissionValidation 2026-07-23-17:20: + A parent pass must wait for coverage of every acceptance-bearing sibling, + not merely the feature assertions that happened to exist when this feature + finished. Lazy-link those siblings before checking terminal feature scope. + */ + const slices = await this.missionStore.listSlices(milestone.id); + const features = (await Promise.all(slices.map((slice) => this.missionStore.listFeatures(slice.id)))).flat(); + for (const sibling of features) { + if ((sibling.acceptanceCriteria ?? "").trim()) { + const linked = await this.missionStore.ensureFeatureAssertionLinked(sibling.id); + if (linked.length === 0) return; + } + } + + const assertions = await this.missionStore.listContractAssertions(milestone.id); + const featureAssertions = assertions.filter((assertion) => assertion.scope !== "milestone"); + const milestoneAssertions = assertions.filter((assertion) => assertion.scope === "milestone"); + if (milestoneAssertions.length === 0) return; + + /* + FNXC:MissionValidation 2026-07-23-18:00: + Derived or authored parent assertions may be the only contract in a + milestone. Permit their direct rollup only after every feature's work is + done; otherwise an early no-assertion feature could grade parent scope. + */ + if (featureAssertions.length === 0) { + if (!features.every((sibling) => sibling.status === "done")) return; + } + + for (const assertion of featureAssertions) { + const linked = await this.missionStore.listFeaturesForAssertion(assertion.id); + if (linked.length === 0 || assertion.status !== "passed") return; + } + + const validationFeature = contextFeature ?? features[0]; + const rollupContext = validationFeature ?? { + id: `milestone:${milestone.id}`, + sliceId: slices[0]?.id ?? "", + title: milestone.title, + status: "done" as const, + loopState: "passed" as const, + implementationAttemptCount: 0, + validatorAttemptCount: 0, + createdAt: milestone.createdAt, + updatedAt: milestone.updatedAt, + }; + const { result } = await this.runValidation(rollupContext, milestoneAssertions, {} as MissionValidatorRun, "milestone"); + for (const assertion of milestoneAssertions) { + const verdict = result.assertions.find((entry) => entry.assertionId === assertion.id); + // Unknown, duplicate, omitted, or non-passing evidence remains non-passing. + const status = verdict?.passed ? "passed" : result.status === "blocked" ? "blocked" : "failed"; + await this.missionStore.updateContractAssertion(assertion.id, { status }); + } + } + private async resolveFeatureMilestone(feature: MissionFeature): Promise { const slice = await this.missionStore.getSlice(feature.sliceId); if (!slice) {