diff --git a/docs/missions.md b/docs/missions.md index d5b9280896..bdc5b67f20 100644 --- a/docs/missions.md +++ b/docs/missions.md @@ -23,6 +23,21 @@ Mission: Improve Reliability Task: FN-214 ``` +## Canonical lineage approval for autonomous symbol locks + +Before autonomous scheduler work may acquire a symbol lock, it resolves the task's Mission → Milestone → Slice → Feature lineage and evaluates the single `@fusion/core` contract: `evaluateMissionLineageApproval`. Resolution and lock acquisition remain scheduler responsibilities; downstream schedulers must not redefine the approval rule. + +Approval requires every one of these statuses: + +- Mission: `active` +- Milestone: `active` +- Slice: `active` +- Feature: `triaged` or `in-progress` + +When the scheduler passes `planApprovalRequired: true`, the linked task must also have an `approvedPlanFingerprint` that is a non-empty string after trimming whitespace. The predicate does not recompute the fingerprint; `plan-approval.ts` owns its generation and validation. When plan approval is not required, the fingerprint is ignored. + +The predicate is pure and returns `{ approved, reason }`. Its stable reasons are `approved`, `missing-mission`, `missing-milestone`, `missing-slice`, `missing-feature`, `mission-not-active`, `milestone-not-active`, `slice-not-active`, `feature-not-implementable`, and `plan-not-approved`. A false result is the scheduler's `lineage-blocked` outcome; only an approved result is eligible for symbol-lock admission. + ## Mission → Goal linkage Missions and goals are stored independently, with an optional many-to-many linkage persisted in the `mission_goals` join table. diff --git a/packages/core/src/__tests__/symbol-lock-lineage-approval.test.ts b/packages/core/src/__tests__/symbol-lock-lineage-approval.test.ts new file mode 100644 index 0000000000..9e83588c4e --- /dev/null +++ b/packages/core/src/__tests__/symbol-lock-lineage-approval.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +import { + evaluateMissionLineageApproval, + isMissionLineageApproved, + MISSION_LINEAGE_APPROVAL_REQUIRED, + type MissionLineageSnapshot, +} from "../index.js"; +import type { Milestone, Mission, MissionFeature, Slice } from "../mission-types.js"; + +const timestamp = "2026-07-19T14:58:00.000Z"; + +function mission(status: Mission["status"] = "active"): Mission { + return { id: "M-1", title: "Mission", status, interviewState: "completed", createdAt: timestamp, updatedAt: timestamp }; +} + +function milestone(status: Milestone["status"] = "active"): Milestone { + return { + id: "MS-1", missionId: "M-1", title: "Milestone", status, orderIndex: 0, + interviewState: "completed", dependencies: [], createdAt: timestamp, updatedAt: timestamp, + }; +} + +function slice(status: Slice["status"] = "active"): Slice { + return { + id: "SL-1", milestoneId: "MS-1", title: "Slice", status, orderIndex: 0, + planState: "planned", createdAt: timestamp, updatedAt: timestamp, + }; +} + +function feature(status: MissionFeature["status"] = "triaged"): MissionFeature { + return { id: "F-1", sliceId: "SL-1", title: "Feature", status, createdAt: timestamp, updatedAt: timestamp }; +} + +function snapshot(overrides: Partial = {}): MissionLineageSnapshot { + return { + mission: mission(), + milestone: milestone(), + slice: slice(), + feature: feature(), + task: {}, + planApprovalRequired: false, + ...overrides, + }; +} + +describe("evaluateMissionLineageApproval", () => { + it.each([ + ["mission", "missing-mission"], + ["milestone", "missing-milestone"], + ["slice", "missing-slice"], + ["feature", "missing-feature"], + ] as const)("reports %s absence before later requirements", (link, reason) => { + expect(evaluateMissionLineageApproval(snapshot({ [link]: undefined }))).toEqual({ approved: false, reason }); + }); + + it.each(["planning", "blocked", "complete", "archived"] as const)("rejects %s missions", (status) => { + expect(evaluateMissionLineageApproval(snapshot({ mission: mission(status) }))).toEqual({ + approved: false, reason: "mission-not-active", + }); + }); + + it.each(["planning", "blocked", "complete"] as const)("rejects %s milestones", (status) => { + expect(evaluateMissionLineageApproval(snapshot({ milestone: milestone(status) }))).toEqual({ + approved: false, reason: "milestone-not-active", + }); + }); + + it.each(["pending", "complete"] as const)("rejects %s slices", (status) => { + expect(evaluateMissionLineageApproval(snapshot({ slice: slice(status) }))).toEqual({ + approved: false, reason: "slice-not-active", + }); + }); + + it.each(["defined", "done", "blocked"] as const)("rejects %s features", (status) => { + expect(evaluateMissionLineageApproval(snapshot({ feature: feature(status) }))).toEqual({ + approved: false, reason: "feature-not-implementable", + }); + }); + + it.each(["triaged", "in-progress"] as const)("accepts implementable %s features without plan approval", (status) => { + const input = snapshot({ feature: feature(status) }); + expect(evaluateMissionLineageApproval(input)).toEqual({ approved: true, reason: "approved" }); + expect(isMissionLineageApproved(input)).toBe(true); + }); + + it("ignores fingerprints when plan approval is not required", () => { + expect(evaluateMissionLineageApproval(snapshot({ task: { approvedPlanFingerprint: " " } }))).toEqual({ + approved: true, reason: "approved", + }); + }); + + it.each([undefined, "", " \t\n "])("requires a non-empty plan fingerprint when configured: %j", (approvedPlanFingerprint) => { + expect(evaluateMissionLineageApproval(snapshot({ + planApprovalRequired: true, + task: { approvedPlanFingerprint }, + }))).toEqual({ approved: false, reason: "plan-not-approved" }); + }); + + it("accepts a non-empty plan fingerprint when configured", () => { + expect(evaluateMissionLineageApproval(snapshot({ + planApprovalRequired: true, + task: { approvedPlanFingerprint: "sha256:approved" }, + }))).toEqual({ approved: true, reason: "approved" }); + }); + + it("does not mutate the resolved lineage snapshot", () => { + const input = snapshot({ planApprovalRequired: true, task: { approvedPlanFingerprint: "approved" } }); + const before = structuredClone(input); + + evaluateMissionLineageApproval(input); + + expect(input).toEqual(before); + }); + + it("exports required statuses matching the predicate contract", () => { + expect(MISSION_LINEAGE_APPROVAL_REQUIRED).toEqual({ + missionStatus: "active", + milestoneStatus: "active", + sliceStatus: "active", + featureStatuses: ["triaged", "in-progress"], + }); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 678ad10397..1d197e7072 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -18,6 +18,16 @@ export { extractSymbolLockIdentity, symbolLocksConflict, } from "./task-store/symbol-locks.js"; +export { + MISSION_LINEAGE_APPROVAL_REQUIRED, + evaluateMissionLineageApproval, + isMissionLineageApproved, +} from "./symbol-lock-lineage-approval.js"; +export type { + MissionLineageApprovalReason, + MissionLineageApprovalResult, + MissionLineageSnapshot, +} from "./symbol-lock-lineage-approval.js"; export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY, assertNotWorkspaceTaskMerge, isWorkspaceTask, WorkspaceTaskMergeError } from "./types.js"; export { resolveEntryPointBranchAssignment, diff --git a/packages/core/src/symbol-lock-lineage-approval.ts b/packages/core/src/symbol-lock-lineage-approval.ts new file mode 100644 index 0000000000..40443a518b --- /dev/null +++ b/packages/core/src/symbol-lock-lineage-approval.ts @@ -0,0 +1,87 @@ +import type { Milestone, Mission, MissionFeature, Slice } from "./mission-types.js"; +import type { Task } from "./types.js"; + +/** + * FNXC:SymbolLock 2026-07-19-14:58: + * Autonomous symbol-lock admission is allowed only for work already greenlit + * through the live Mission → Milestone → Slice → Feature path. An active + * Mission, Milestone, and Slice establish that implementation is currently + * authorized; triaged or in-progress Features are the only implementable + * states. Planning, blocked, completed, archived, defined, and done states + * must not be treated as approval. + * + * When plan approval is required, a non-empty approvedPlanFingerprint proves + * an operator or plan gate approved the task's current plan. This predicate + * checks presence only: plan-approval.ts owns fingerprint computation and + * validation, while this IO-free contract stays reusable by schedulers. + */ +export const MISSION_LINEAGE_APPROVAL_REQUIRED = { + missionStatus: "active", + milestoneStatus: "active", + sliceStatus: "active", + featureStatuses: ["triaged", "in-progress"] as const, +}; + +export type MissionLineageApprovalReason = + | "approved" + | "missing-mission" + | "missing-milestone" + | "missing-slice" + | "missing-feature" + | "mission-not-active" + | "milestone-not-active" + | "slice-not-active" + | "feature-not-implementable" + | "plan-not-approved"; + +export type MissionLineageApprovalResult = + | { approved: true; reason: "approved" } + | { approved: false; reason: Exclude }; + +/** A resolved lineage only; scheduler-owned resolution deliberately stays outside this pure seam. */ +export interface MissionLineageSnapshot { + mission?: Mission | null; + milestone?: Milestone | null; + slice?: Slice | null; + feature?: MissionFeature | null; + task: Pick; + planApprovalRequired: boolean; +} + +/** Evaluate the canonical symbol-lock admission contract in deterministic failure order. */ +export function evaluateMissionLineageApproval( + snapshot: MissionLineageSnapshot, +): MissionLineageApprovalResult { + const { mission, milestone, slice, feature, task, planApprovalRequired } = snapshot; + + if (!mission) return { approved: false, reason: "missing-mission" }; + if (!milestone) return { approved: false, reason: "missing-milestone" }; + if (!slice) return { approved: false, reason: "missing-slice" }; + if (!feature) return { approved: false, reason: "missing-feature" }; + + if (mission.status !== MISSION_LINEAGE_APPROVAL_REQUIRED.missionStatus) { + return { approved: false, reason: "mission-not-active" }; + } + if (milestone.status !== MISSION_LINEAGE_APPROVAL_REQUIRED.milestoneStatus) { + return { approved: false, reason: "milestone-not-active" }; + } + if (slice.status !== MISSION_LINEAGE_APPROVAL_REQUIRED.sliceStatus) { + return { approved: false, reason: "slice-not-active" }; + } + if (!MISSION_LINEAGE_APPROVAL_REQUIRED.featureStatuses.some((status) => status === feature.status)) { + return { approved: false, reason: "feature-not-implementable" }; + } + if ( + planApprovalRequired + && (typeof task.approvedPlanFingerprint !== "string" || task.approvedPlanFingerprint.trim().length === 0) + ) { + return { approved: false, reason: "plan-not-approved" }; + } + + return { approved: true, reason: "approved" }; +} + +/** Convenience projection for callers that do not need the rejection reason. */ +export function isMissionLineageApproved(snapshot: MissionLineageSnapshot): boolean { + return evaluateMissionLineageApproval(snapshot).approved; +}