FN-8387: add canonical lineage approval predicate
Centralize symbol-lock admission rules for resolved mission lineage. - Add a pure core predicate with stable approval and rejection reasons. - Export the lineage contract and cover status and plan-fingerprint cases. - Document scheduler ownership and the admission requirements. Files changed: docs/missions.md | 15 +++ .../__tests__/symbol-lock-lineage-approval.test.ts | 123 +++++++++++++++++++++ packages/core/src/index.ts | 10 ++ packages/core/src/symbol-lock-lineage-approval.ts | 87 +++++++++++++++ 4 files changed, 235 insertions(+) Fusion-Task-Id: FN-8387 Fusion-Task-Lineage: dc2d8518-ffff-4062-b11f-a162dd1b10da Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -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.
|
||||
|
||||
123
packages/core/src/__tests__/symbol-lock-lineage-approval.test.ts
Normal file
123
packages/core/src/__tests__/symbol-lock-lineage-approval.test.ts
Normal file
@@ -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> = {}): 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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
87
packages/core/src/symbol-lock-lineage-approval.ts
Normal file
87
packages/core/src/symbol-lock-lineage-approval.ts
Normal file
@@ -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<MissionLineageApprovalReason, "approved"> };
|
||||
|
||||
/** 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<Task, "approvedPlanFingerprint">;
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user