fix: allow freeform chat task create without mission lineage (#2406)

## Summary

Chat-driven `fn_task_create` rejected freeform intake with `Approved
mission_lineage is required` even though the tool schema marks
`mission_lineage` as optional. That was FN-8307 mission admission
over-applied beyond autonomous heartbeat patrol.

This restores freeform chat/board-equivalent creates while keeping
idle-heartbeat mission-lineage enforcement.

## What changed

- **`fn_task_create` / `fn_delegate_task`**: omit `mission_lineage`
succeeds for user-directed surfaces; hard-require only when the tool is
registered with `requireMissionLineage` (idle heartbeat patrol).
- **Gates**: missing lineage is policy-governed (`allow` /
`require-approval` / `block`) instead of a hard pre-block, so
permanent-agent chat can create freeform tasks under normal policy.
- **Heartbeat no-task delegate**: also sets `requireMissionLineage:
true` so freeform off-mission work cannot bypass admission via
`fn_delegate_task`.
- Supplied lineage is still fully validated (Feature → Slice → Milestone
→ Mission) on every surface.
- Parent inheritance still applies when not in require mode.

## Test plan

- [x] Unit: freeform `fn_task_create` without lineage creates a task
with no `missionId`/`sliceId`
- [x] Unit: freeform `fn_delegate_task` without lineage succeeds
- [x] Unit: `requireMissionLineage: true` still hard-fails without
lineage
- [x] Unit: gates treat missing lineage as policy disposition, not hard
block
- [ ] CI gate green

## Symptom

**Original:** chat tool call `{ description: "Create a red button",
priority: "high" }` → `ERROR: Approved mission_lineage is required; no
task was created.`

**Expected after fix:** task is created freeform without mission fields.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **Bug Fixes**
- Freeform chat task creation and delegation can now proceed without
`mission_lineage`.
- Permission policies continue to govern these actions, including
approval requirements.
- Autonomous idle patrols still require approved mission lineage before
creating or delegating tasks.
- Task creation no longer receives mission-specific metadata when no
lineage is provided.

- **Tests**
- Expanded coverage for freeform and mission-linked task creation,
delegation, and policy-gating scenarios.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
gsxdsm
2026-07-22 13:36:23 -07:00
committed by GitHub
parent d194290a75
commit f63047871c
10 changed files with 211 additions and 95 deletions

View File

@@ -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.

View File

@@ -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",

View File

@@ -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<string, unknown>;
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<string, unknown>;
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);

View File

@@ -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,
});
}
}
});

View File

@@ -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();
});

View File

@@ -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<string, unknown>): boolean {
const lineage = args.mission_lineage;
if (!lineage || typeof lineage !== "object") return false;
const reference = lineage as Record<string, unknown>;
return ["mission_id", "slice_id", "feature_id"].every((key) =>
typeof reference[key] === "string" && reference[key].trim().length > 0,
);
}
function normalizeArgs(args: unknown): Record<string, unknown> {
return args && typeof args === "object" ? (args as Record<string, unknown>) : {};
@@ -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];

View File

@@ -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));

View File

@@ -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<MissionLineageReference | { error: string }> {
options?: { required?: boolean },
): Promise<MissionLineageReference | null | { error: string }> {
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<typeof taskCreateParams>) => {
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 } : {}),
},
},

View File

@@ -50,11 +50,13 @@ export const COMMAND_EXECUTION_FN_TOOLS: ReadonlySet<string> = 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<string> = 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,

View File

@@ -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<string, unknown>).mission_lineage;
if (!lineage || typeof lineage !== "object") return false;
const reference = lineage as Record<string, unknown>;
return ["mission_id", "slice_id", "feature_id"].every((key) =>
typeof reference[key] === "string" && reference[key].trim().length > 0,
);
}
function normalizeArgs(args: unknown): Record<string, unknown> {
return args && typeof args === "object" ? (args as Record<string, unknown>) : {};
@@ -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 {