diff --git a/.changeset/fix-chat-freeform-task-create.md b/.changeset/fix-chat-freeform-task-create.md new file mode 100644 index 0000000000..f3a15e8b60 --- /dev/null +++ b/.changeset/fix-chat-freeform-task-create.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Allow freeform chat task creation without mission lineage. +category: fix +dev: `fn_task_create` / `fn_delegate_task` only hard-require approved `mission_lineage` when registered with `requireMissionLineage` (idle heartbeat patrol). User-directed chat/create paths may omit lineage; gates no longer pre-block missing lineage so freeform intake remains policy-governed. diff --git a/packages/engine/src/__tests__/agent-action-gate.test.ts b/packages/engine/src/__tests__/agent-action-gate.test.ts index 2a5fdbbef7..ccaa4b38f6 100644 --- a/packages/engine/src/__tests__/agent-action-gate.test.ts +++ b/packages/engine/src/__tests__/agent-action-gate.test.ts @@ -536,10 +536,16 @@ describe("agent-action-gate", () => { "fn_task_import_gitlab_group_issues", "fn_task_import_gitlab_merge_requests", ] as const)("governs task creation/import tool %s as task_agent_mutation", (toolName) => { - const args = toolName === "fn_task_create" || toolName === "fn_delegate_task" - ? { mission_lineage: { mission_id: "M-1", slice_id: "SL-1", feature_id: "F-1" } } - : {}; - for (const argsValue of [args, args]) { + /* + FNXC:EngineTests 2026-07-22-13:07: + Freeform creates omit mission_lineage and still follow policy disposition + (require-approval / block) rather than a hard mission-admission pre-block. + */ + const argVariants = + toolName === "fn_task_create" || toolName === "fn_delegate_task" + ? [{}, { mission_lineage: { mission_id: "M-1", slice_id: "SL-1", feature_id: "F-1" } }] + : [{}]; + for (const argsValue of argVariants) { expect(evaluateAgentActionGate({ agentId: "a1", toolName, args: argsValue, permissionPolicy: approvalPolicy })).toMatchObject({ category: "task_agent_mutation", disposition: "require-approval", diff --git a/packages/engine/src/__tests__/agent-tools-delegation.test.ts b/packages/engine/src/__tests__/agent-tools-delegation.test.ts index b5fc3f71a3..e9d5ce9a3c 100644 --- a/packages/engine/src/__tests__/agent-tools-delegation.test.ts +++ b/packages/engine/src/__tests__/agent-tools-delegation.test.ts @@ -492,6 +492,67 @@ describe("createDelegateTaskTool", () => { expect(taskStore.createTask).not.toHaveBeenCalled(); }); + /* + FNXC:EngineTests 2026-07-22-13:07: + Chat/user-directed freeform intake omits mission_lineage. Schema marks it optional; + the tool factory must create the task without mission fields rather than hard-fail. + */ + it("creates freeform chat-style tasks when mission_lineage is omitted", async () => { + const tool = createTaskCreateTool(taskStore, { sourceType: "api" }, { rootDir: "/project" }); + + const result = await tool.execute( + "call-1", + { description: "Create a red button", priority: "high" }, + undefined as any, + undefined as any, + undefined as any, + ); + + expect(result).not.toMatchObject({ isError: true }); + expect(taskStore.createTask).toHaveBeenCalledWith( + expect.objectContaining({ + description: "Create a red button", + priority: "high", + source: expect.objectContaining({ sourceType: "api" }), + }), + expect.anything(), + ); + const createInput = vi.mocked(taskStore.createTask).mock.calls[0]?.[0] as Record; + expect(createInput.missionId).toBeUndefined(); + expect(createInput.sliceId).toBeUndefined(); + }); + + it("delegates freeform tasks when mission_lineage is omitted", async () => { + const agent = createAgent({ id: "agent-001", name: "Bob" }); + vi.mocked(agentStore.getAgent).mockResolvedValue(agent); + vi.mocked(taskStore.createTask).mockResolvedValue({ + id: "FN-060", + description: "Create a red button", + dependencies: [], + column: "todo" as const, + assignedAgentId: "agent-001", + steps: [], + currentStep: 0, + log: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + } as Task); + + const tool = createDelegateTaskTool(agentStore, taskStore); + const result = await tool.execute( + "call-1", + { agent_id: "agent-001", description: "Create a red button" }, + undefined as any, + undefined as any, + undefined as any, + ); + + expect(result).not.toMatchObject({ isError: true }); + const createInput = vi.mocked(taskStore.createTask).mock.calls[0]?.[0] as Record; + expect(createInput.missionId).toBeUndefined(); + expect(createInput.sliceId).toBeUndefined(); + }); + it("serializes three concurrent paraphrased creates from one parent", async () => { const tasks: Task[] = []; vi.mocked(taskStore.findRecentTasksBySourceParentTaskId).mockImplementation(async () => tasks); diff --git a/packages/engine/src/__tests__/gating-classifications.test.ts b/packages/engine/src/__tests__/gating-classifications.test.ts index f27ede5ed0..bd5e95148e 100644 --- a/packages/engine/src/__tests__/gating-classifications.test.ts +++ b/packages/engine/src/__tests__/gating-classifications.test.ts @@ -244,25 +244,31 @@ describe("gating-classifications parity", () => { recognized: true, }); - for (const [permissionPolicy, disposition] of policyMatrix) { - expect(resolvePermanentAgentToolDecision({ - toolName: "fn_task_create", - args: { mission_lineage: { mission_id: "M-1", slice_id: "SL-1", feature_id: "F-1" } }, - gating: { permissionPolicy }, - })).toMatchObject({ - category: "task_agent_mutation", - disposition, - recognized: true, - }); - expect(evaluateAgentActionGate({ - agentId: "a1", - toolName: "fn_task_create", - args: { mission_lineage: { mission_id: "M-1", slice_id: "SL-1", feature_id: "F-1" } }, - permissionPolicy, - })).toMatchObject({ - category: "task_agent_mutation", - disposition, - }); + /* + FNXC:EngineTests 2026-07-22-13:07: + Cover freeform (no lineage) and mission-linked args: both follow policy disposition. + */ + for (const args of [{}, { mission_lineage: { mission_id: "M-1", slice_id: "SL-1", feature_id: "F-1" } }]) { + for (const [permissionPolicy, disposition] of policyMatrix) { + expect(resolvePermanentAgentToolDecision({ + toolName: "fn_task_create", + args, + gating: { permissionPolicy }, + })).toMatchObject({ + category: "task_agent_mutation", + disposition, + recognized: true, + }); + expect(evaluateAgentActionGate({ + agentId: "a1", + toolName: "fn_task_create", + args, + permissionPolicy, + })).toMatchObject({ + category: "task_agent_mutation", + disposition, + }); + } } }); diff --git a/packages/engine/src/__tests__/pi-create-fn-agent.test.ts b/packages/engine/src/__tests__/pi-create-fn-agent.test.ts index 64334a04ac..3314787d4b 100644 --- a/packages/engine/src/__tests__/pi-create-fn-agent.test.ts +++ b/packages/engine/src/__tests__/pi-create-fn-agent.test.ts @@ -891,13 +891,17 @@ describe("wrapToolsWithPermanentAgentGating", () => { const result = await (wrapped[0] as any).execute("t1", { description: "create" }); expect((result as any).isError).toBe(true); - // FNXC:EngineTests 2026-07-20-23:55: governed fn_task_create without mission_lineage is hard-blocked (FN-8307) rather than approval-gated. + /* + FNXC:EngineTests 2026-07-22-13:07: + Freeform chat creates omit mission_lineage and remain policy-governed (require-approval + here), not hard-blocked. Autonomous heartbeat still enforces lineage at the tool factory. + */ expect((result as any).details).toEqual(expect.objectContaining({ category: "task_agent_mutation", - disposition: "block", + disposition: "require-approval", toolName: "fn_task_create", })); - expect(createApprovalRequest).not.toHaveBeenCalled(); + expect(createApprovalRequest).toHaveBeenCalledOnce(); expect(tool.execute).not.toHaveBeenCalled(); }); diff --git a/packages/engine/src/agent-action-gate.ts b/packages/engine/src/agent-action-gate.ts index 05fba9cd88..40787a9a1d 100644 --- a/packages/engine/src/agent-action-gate.ts +++ b/packages/engine/src/agent-action-gate.ts @@ -10,7 +10,6 @@ import { COMMAND_EXECUTION_FN_TOOLS, COORDINATION_EXEMPT_TOOLS, FILE_SCOPE_FN_TOOLS, - MISSION_LINEAGE_ADMISSION_TOOLS, READONLY_BUILTIN_TOOLS, REVIEW_GATE_BYPASS_FN_TOOLS, classifyGitCommand, @@ -97,16 +96,6 @@ const COMMAND_EXECUTION_TOOLS = COMMAND_EXECUTION_FN_TOOLS; const READONLY_DISCOVERY_TOOLS = READONLY_BUILTIN_TOOLS; const REVIEW_GATE_BYPASS_TOOLS = REVIEW_GATE_BYPASS_FN_TOOLS; const FILE_SCOPE_TOOLS = FILE_SCOPE_FN_TOOLS; -const MISSION_ADMISSION_TOOLS = MISSION_LINEAGE_ADMISSION_TOOLS; - -function hasMissionLineageReference(args: Record): boolean { - const lineage = args.mission_lineage; - if (!lineage || typeof lineage !== "object") return false; - const reference = lineage as Record; - return ["mission_id", "slice_id", "feature_id"].every((key) => - typeof reference[key] === "string" && reference[key].trim().length > 0, - ); -} function normalizeArgs(args: unknown): Record { return args && typeof args === "object" ? (args as Record) : {}; @@ -213,26 +202,21 @@ export function evaluateAgentActionGate(params: { } /* - FNXC:MissionAdmission 2026-07-30-00:00: - FN-8307 blocks incomplete lineage before policy disposition. Approval cannot - authorize off-mission implementation work; agent-tools.ts is the authoritative - full-chain validator before any task row is written. + FNXC:MissionAdmission 2026-07-22-13:07: + Freeform chat/user-directed creates omit mission_lineage and must remain policy- + governed (allow/require-approval/block), not hard-blocked at the gate. Autonomous + heartbeat patrol still enforces lineage via createTaskCreateTool/createDelegateTaskTool + requireMissionLineage + resolveApprovedMissionLineage before any task row is written. + Supplied lineage is validated at the tool factory; the gate does not re-encode that + admission rule so chat freeform intake and heartbeat requirements can diverge safely. */ - const missionAdmissionBlocked = MISSION_ADMISSION_TOOLS.has(params.toolName) && !hasMissionLineageReference(args); - if (missionAdmissionBlocked) { - category = "task_agent_mutation"; - resourceType = "task"; - operation = "mission-lineage-required"; - } /* FNXC:ToolPermissions 2026-07-01-00:00: Exact tool-name overrides must be resolved before category policy so operators can block a single governed tool such as `fn_task_create` without blocking every `task_agent_mutation` tool. Exempt coordination tools remain hard-bypassed to avoid heartbeat deadlocks. */ const exactDisposition = category === "exempt" ? undefined : params.permissionPolicy.toolRules?.[params.toolName]; - const disposition: AgentPermissionPolicyDisposition | "allow" = missionAdmissionBlocked - ? "block" - : category === "exempt" + const disposition: AgentPermissionPolicyDisposition | "allow" = category === "exempt" ? "allow" : exactDisposition ?? params.permissionPolicy.rules[category]; diff --git a/packages/engine/src/agent-heartbeat.ts b/packages/engine/src/agent-heartbeat.ts index e7216da54e..2d427cd49c 100644 --- a/packages/engine/src/agent-heartbeat.ts +++ b/packages/engine/src/agent-heartbeat.ts @@ -2528,7 +2528,17 @@ export class HeartbeatMonitor { // Agent delegation tools heartbeatTools.push(createListAgentsTool(this.store)); - heartbeatTools.push(createDelegateTaskTool(this.store, taskStore, { rootDir: this.rootDir, sourceAgentId: agentId })); + /* + FNXC:MissionAdmission 2026-07-22-13:07: + Idle-patrol delegation has no parent task to inherit lineage from. + Keep the same requireMissionLineage contract as fn_task_create so + freeform off-mission delegation cannot slip past FN-8307 via delegate. + */ + heartbeatTools.push(createDelegateTaskTool(this.store, taskStore, { + rootDir: this.rootDir, + sourceAgentId: agentId, + requireMissionLineage: true, + })); heartbeatTools.push(createTaskAssignTool(this.store, taskStore)); heartbeatTools.push(createGetAgentConfigTool(this.store, agentId)); heartbeatTools.push(createUpdateAgentConfigTool(this.store, agentId)); diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index 2b35a468fe..14d9b9c4b8 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -37,11 +37,25 @@ import { validateCodeNodeSources } from "./code-node-runner.js"; const TASK_CREATE_PRIORITY_VALUES = ["low", "normal", "high", "urgent"] as const; -const missionLineageParams = Type.Object({ - mission_id: Type.String({ description: "Approved mission ID for this implementation task" }), - slice_id: Type.String({ description: "Approved slice ID under the mission" }), - feature_id: Type.String({ description: "Approved feature ID under the slice" }), -}); +/* +FNXC:MissionAdmission 2026-07-22-13:07: +Chat/user-directed freeform intake may omit mission_lineage (same as board Quick Entry). +Autonomous heartbeat surfaces pass requireMissionLineage and hard-require an approved chain. +When supplied, the full Feature → Slice → Milestone → Mission chain is always validated. +*/ +const missionLineageParams = Type.Object( + { + mission_id: Type.String({ description: "Approved mission ID for this implementation task" }), + slice_id: Type.String({ description: "Approved slice ID under the mission" }), + feature_id: Type.String({ description: "Approved feature ID under the slice" }), + }, + { + description: + "Optional approved Feature → Slice → Mission linkage. Omit for freeform intake (chat/board). " + + "Required only on autonomous heartbeat patrol creates. When omitted on a follow-up, may inherit " + + "from a mission-linked parent task. When supplied, the full active chain is validated.", + }, +); export const taskCreateParams = Type.Object({ description: Type.String({ description: "What needs to be done" }), @@ -395,6 +409,11 @@ export const delegateTaskParams = Type.Object({ "Omit to inherit the project default workflow. Use fn_workflow_list to discover valid IDs.", }), ), + /* + FNXC:MissionAdmission 2026-07-22-13:07: + Same freeform-vs-autonomous contract as fn_task_create: optional for user-directed + delegation; required when the tool factory is registered with requireMissionLineage. + */ mission_lineage: Type.Optional(missionLineageParams), override: Type.Optional(Type.Boolean({ description: "Set true to bypass executor-role assignment policy" })), }); @@ -964,22 +983,29 @@ type MissionLineageReference = { /** * FNXC:MissionAdmission 2026-07-30-00:00: - * FN-8307 requires every autonomous implementation create/delegate operation to + * FN-8307 requires autonomous implementation create/delegate (heartbeat patrol) to * prove an active Feature → Slice → Milestone → Mission chain before persistence. * Decision A records that proof on the new task without calling linkFeatureToTask: * a feature's scalar taskId remains owned by its source task and cannot be stolen * by a follow-up task. + * + * FNXC:MissionAdmission 2026-07-22-13:07: + * User-directed freeform intake (chat, board-equivalent agent creates) must remain + * allowed without mission_lineage. Only surfaces that pass `required: true` (idle + * heartbeat with requireMissionLineage) hard-fail on a missing lineage. When a + * lineage is supplied on any surface, the full approved chain is still validated. + * Missing lineage with inheritance disabled returns null so callers omit mission fields. */ async function resolveApprovedMissionLineage( store: TaskStore, requested: { mission_id: string; slice_id: string; feature_id: string } | undefined, sourceTaskId: string | undefined, -): Promise { + options?: { required?: boolean }, +): Promise { const missionStore = store.getMissionStore?.(); - if (!missionStore) return { error: "Mission lineage is unavailable; no task was created." }; let requestedLineage = requested; - if (!requestedLineage && sourceTaskId) { + if (!requestedLineage && sourceTaskId && missionStore) { const sourceFeature = await missionStore.getFeatureByTaskId(sourceTaskId); if (sourceFeature) { const sourceSlice = await missionStore.getSlice(sourceFeature.sliceId); @@ -993,7 +1019,13 @@ async function resolveApprovedMissionLineage( } } } - if (!requestedLineage) return { error: "Approved mission_lineage is required; no task was created." }; + if (!requestedLineage) { + if (options?.required) { + return { error: "Approved mission_lineage is required; no task was created." }; + } + return null; + } + if (!missionStore) return { error: "Mission lineage is unavailable; no task was created." }; const [feature, slice, mission] = await Promise.all([ missionStore.getFeature(requestedLineage.feature_id), @@ -1224,7 +1256,8 @@ export function createTaskCreateTool( name: "fn_task_create", label: "Create Task", description: - "Create a new task for out-of-scope work discovered during execution. " + + "Create a new task for out-of-scope work discovered during execution, or freeform " + + "intake from chat. " + "The task enters the selected-or-default workflow's intake/planning column " + "where it will be specified by the AI (a custom workflow with a non-triage " + "intake column, e.g. Inbox, lands the card there instead and it stays inert " + @@ -1234,7 +1267,9 @@ export function createTaskCreateTool( "Optionally set dependencies (e.g., the new task depends on the current one, " + "or the current task should wait for the new one). " + "Optionally pass workflow_id to select a workflow at creation time; use " + - "fn_workflow_list to discover valid IDs.", + "fn_workflow_list to discover valid IDs. " + + "mission_lineage is optional for freeform intake; pass it only when linking to an " + + "approved Feature → Slice → Mission (required on autonomous heartbeat patrol).", parameters: taskCreateParams, execute: async (_id: string, params: Static) => { try { @@ -1266,12 +1301,19 @@ export function createTaskCreateTool( } } const workflowId = params.workflow_id?.trim() || undefined; + /* + FNXC:MissionAdmission 2026-07-22-13:07: + Freeform chat/user-directed creates omit mission_lineage and must succeed. + Only requireMissionLineage (idle heartbeat patrol) hard-requires an approved chain. + Supplied lineage is always validated; parent inheritance still applies when not required. + */ const lineage = await resolveApprovedMissionLineage( store, params.mission_lineage, options?.requireMissionLineage ? undefined : options?.sourceTaskId ?? provenance?.sourceParentTaskId, + { required: options?.requireMissionLineage === true }, ); - if ("error" in lineage) { + if (lineage && "error" in lineage) { return { content: [{ type: "text" as const, text: `ERROR: ${lineage.error}` }], details: { rule: "mission-lineage-required" }, isError: true }; } /* @@ -1291,15 +1333,14 @@ export function createTaskCreateTool( dependencies: params.dependencies, priority: params.priority, ...(workflowId ? { workflowId } : {}), - missionId: lineage.missionId, - sliceId: lineage.sliceId, + ...(lineage ? { missionId: lineage.missionId, sliceId: lineage.sliceId } : {}), source: { sourceType: provenance?.sourceType ?? "api", sourceAgentId: provenance?.sourceAgentId, sourceRunId: provenance?.sourceRunId, sourceParentTaskId: provenance?.sourceParentTaskId ?? options?.sourceTaskId, // Decision A: lineage metadata is deliberately distinct from feature.taskId. - sourceMetadata: { missionLineage: lineage }, + ...(lineage ? { sourceMetadata: { missionLineage: lineage } } : {}), }, }, options); const deps = task.dependencies.length ? ` (depends on: ${task.dependencies.join(", ")})` : ""; @@ -4519,8 +4560,18 @@ export function createDelegateTaskTool( try { const workflowId = params.workflow_id?.trim() || undefined; - const lineage = await resolveApprovedMissionLineage(taskStore, params.mission_lineage, options?.sourceTaskId); - if ("error" in lineage) { + /* + FNXC:MissionAdmission 2026-07-22-13:07: + Freeform chat/user-directed delegation may omit mission_lineage. + requireMissionLineage (idle heartbeat patrol) still hard-requires an approved chain. + */ + const lineage = await resolveApprovedMissionLineage( + taskStore, + params.mission_lineage, + options?.requireMissionLineage ? undefined : options?.sourceTaskId, + { required: options?.requireMissionLineage === true }, + ); + if (lineage && "error" in lineage) { return { content: [{ type: "text" as const, text: `ERROR: ${lineage.error}` }], details: { rule: "mission-lineage-required" }, isError: true }; } // Create task assigned to the target agent @@ -4530,14 +4581,13 @@ export function createDelegateTaskTool( column: "todo", assignedAgentId: params.agent_id, ...(workflowId ? { workflowId } : {}), - missionId: lineage.missionId, - sliceId: lineage.sliceId, + ...(lineage ? { missionId: lineage.missionId, sliceId: lineage.sliceId } : {}), source: { sourceType: "api", sourceParentTaskId: options?.sourceTaskId, sourceAgentId: options?.sourceAgentId, sourceMetadata: { - missionLineage: lineage, + ...(lineage ? { missionLineage: lineage } : {}), ...(override ? { executorRoleOverride: true } : {}), }, }, diff --git a/packages/engine/src/gating-classifications.ts b/packages/engine/src/gating-classifications.ts index a69ee634d2..5b620ac419 100644 --- a/packages/engine/src/gating-classifications.ts +++ b/packages/engine/src/gating-classifications.ts @@ -50,11 +50,13 @@ export const COMMAND_EXECUTION_FN_TOOLS: ReadonlySet = new Set([ /* FNXC:MissionAdmission 2026-07-30-00:00: FN-8307 treats autonomous implementation creation and delegation as one admission -class in both gate paths. They must never fall through as permanent-agent -coordination, because agent-tools.ts validates the referenced active lineage -before it can persist the task. +class at the tool factory (requireMissionLineage + resolveApprovedMissionLineage). + +FNXC:MissionAdmission 2026-07-22-13:07: +Gates no longer hard-block missing lineage so freeform chat/user-directed creates +remain policy-governed. Lineage enforcement for idle heartbeat patrol lives in +agent-tools.ts (requireMissionLineage), not a gate pre-check. */ -export const MISSION_LINEAGE_ADMISSION_TOOLS: ReadonlySet = new Set(["fn_task_create", "fn_delegate_task"]); const PERMANENT_AND_ACTION_TASK_AGENT_TOOLS = ["fn_task_create", "fn_delegate_task"] as const; const ACTION_GATE_TASK_AGENT_ONLY_TOOLS = [ ...PERMANENT_AND_ACTION_TASK_AGENT_TOOLS, diff --git a/packages/engine/src/permanent-agent-gating.ts b/packages/engine/src/permanent-agent-gating.ts index 12aad218d3..d0ca980f51 100644 --- a/packages/engine/src/permanent-agent-gating.ts +++ b/packages/engine/src/permanent-agent-gating.ts @@ -9,7 +9,6 @@ import { FILE_SCOPE_FN_TOOLS, FILE_WRITE_BUILTIN_TOOLS, FILE_WRITE_DELETE_FN_TOOLS, - MISSION_LINEAGE_ADMISSION_TOOLS, NETWORK_API_TOOLS, PERMANENT_AGENT_TASK_MUTATION_TOOLS, READONLY_BUILTIN_TOOLS, @@ -40,17 +39,6 @@ const COMMAND_EXECUTION_TOOLS = COMMAND_EXECUTION_FN_TOOLS; const REVIEW_GATE_BYPASS_TOOLS = REVIEW_GATE_BYPASS_FN_TOOLS; // FNXC:ToolGovernance 2026-07-09-08:30: FN-7737 — mirror agent-action-gate.ts's file_scope classification here so the permanent-agent gate resolves fn_task_file_scope_add identically (no two-path drift). const FILE_SCOPE_TOOLS = FILE_SCOPE_FN_TOOLS; -const MISSION_ADMISSION_TOOLS = MISSION_LINEAGE_ADMISSION_TOOLS; - -function hasMissionLineageReference(args: unknown): boolean { - if (!args || typeof args !== "object") return false; - const lineage = (args as Record).mission_lineage; - if (!lineage || typeof lineage !== "object") return false; - const reference = lineage as Record; - return ["mission_id", "slice_id", "feature_id"].every((key) => - typeof reference[key] === "string" && reference[key].trim().length > 0, - ); -} function normalizeArgs(args: unknown): Record { return args && typeof args === "object" ? (args as Record) : {}; @@ -176,14 +164,12 @@ export function resolvePermanentAgentToolDecision(input: { const classification = classifyPermanentAgentToolCall(input.toolName, input.args); /* - FNXC:MissionAdmission 2026-07-30-00:00: - Keep the permanent-agent result in lockstep with evaluateAgentActionGate: - incomplete lineage is a hard off-mission block, not a policy-approvable task - mutation. The tool factory performs the full persistence-time validation. + FNXC:MissionAdmission 2026-07-22-13:07: + Freeform chat creates omit mission_lineage and must honor policy disposition + (allow/require-approval/block), not a hard gate block. Autonomous heartbeat + patrol still enforces lineage at the tool factory via requireMissionLineage. + Keep permanent-agent results in lockstep with evaluateAgentActionGate. */ - if (MISSION_ADMISSION_TOOLS.has(input.toolName) && !hasMissionLineageReference(input.args)) { - return { ...classification, toolName: input.toolName, disposition: "block" }; - } if (!input.gating?.permissionPolicy) { return {