FN-8660: add credential instance selection persistence

Persist optional credential-instance selections across model configuration without changing runtime credential behavior.

- Add credential instance IDs to global, project, task, preset, and workflow IR model lanes.
- Preserve selected instance IDs through model resolution and task persistence.
- Add PostgreSQL migration coverage, unit tests, documentation, and a minor changeset.

Files changed:
 .../fn-8660-credential-instance-selection.md       |  7 ++
 docs/settings-reference.md                         | 17 +++++
 .../core/src/__tests__/model-resolution.test.ts    | 37 ++++++++++
 .../credential-instance-selection.pg.test.ts       | 81 +++++++++++++++++++++
 .../postgres/settings-persistence.pg.test.ts       | 83 ++++++++++++++++++++++
 .../src/__tests__/workflow-ir-settings.test.ts     | 66 +++++++++++++++++
 packages/core/src/builtin-workflow-settings.ts     | 41 +++++++++++
 packages/core/src/model-resolution.ts              | 57 ++++++++++++++-
 .../0039_fn_8660_credential_instance_selection.sql |  9 +++
 packages/core/src/postgres/schema-applier.ts       | 14 +++-
 packages/core/src/postgres/schema/project.ts       |  4 ++
 packages/core/src/settings-schema.ts               | 25 +++++++
 packages/core/src/store.ts                         |  2 +-
 .../core/src/task-store/archive-lifecycle-2.ts     |  8 +++
 .../core/src/task-store/branch-and-pr-entities.ts  |  2 +-
 packages/core/src/task-store/persistence.ts        |  8 +++
 packages/core/src/task-store/serialization.ts      |  6 ++
 packages/core/src/task-store/settings-ops.ts       | 30 ++++++++
 packages/core/src/task-store/task-creation.ts      | 18 ++++-
 packages/core/src/task-store/task-mutation-ops.ts  |  6 +-
 packages/core/src/task-store/task-row-mappers.ts   |  6 +-
 packages/core/src/task-store/task-update.ts        | 24 +++++++
 packages/core/src/types/archive-planning.ts        |  5 ++
 packages/core/src/types/settings-scope.ts          | 40 +++++++++++
 packages/core/src/types/task-core.ts               | 30 ++++++++
 packages/core/src/types/workflow-steps.ts          |  9 +++
 packages/core/src/workflow-ir.ts                   | 18 +++++
 packages/core/src/workflow-settings.ts             | 10 +++
 28 files changed, 650 insertions(+), 13 deletions(-)

Fusion-Task-Id: FN-8660

Fusion-Task-Lineage: a3f625eb-018c-4084-954e-488b1d37691e

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-31 23:08:51 -07:00
parent f5a776d923
commit 7334cffebd
28 changed files with 650 additions and 13 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Persist optional credential-instance selections across Fusion model configuration.
category: feature
dev: Adds *CredentialInstanceId settings, presets, task and workflow IR fields, settings-schema registration, and Postgres migration 0039; persisted-but-inert this slice.

View File

@@ -1865,3 +1865,20 @@ Project-scoped ordered list of up to six mobile footer quick actions. The defaul
### Plugin-provided MCP servers
Enabled plugins may declare `mcpServers` declaratively. The contribution is project-scoped: only plugins enabled in that project's plugin state participate. Resolution is global → enabled plugin declarations → project settings, by server `name`; later declarations win and a project `enabled:false` entry removes an inherited plugin server. The Global MCP card never includes plugin declarations. Project MCP UI identifies them as `plugin:<id>` and writes only project overrides or tombstones.
### Credential instance companions
Every provider/model selection can now persist an optional `*CredentialInstanceId` companion: the
project/global default and fallback pairs, per-lane project/global pairs, selected-workflow lane
values, and task overrides. `ModelPreset` entries similarly accept
`executorCredentialInstanceId` and `validatorCredentialInstanceId`. These fields follow the same
scope and precedence as their sibling `*Provider`/`*ModelId` pair; the instance belongs only to the
winning pair and is omitted when unset.
Credential instance ids are validated when authored. Invalid values (including empty,
whitespace-only, bracket-containing, oversized, or non-string values) are rejected for project and
global settings, workflow setting values, workflow IR node overrides, and task writes. Settings
validation also visits every `modelPresets[]` element: one invalid executor or validator instance
id rejects the entire settings write without changing the stored presets. These values are
persisted-but-inert in this release; runtime credential resolution will consume them in the
follow-up runtime-resolution work.

View File

@@ -601,3 +601,40 @@ describe("model-resolution", () => {
expect(applyTestModeOverrides(resolved, {})).toEqual(resolved);
});
});
describe("credential instance selection", () => {
it("carries the winning pair's instance without mixing a losing tier", () => {
expect(resolveExecutionSettingsModel({
executionProvider: "project-provider",
executionModelId: "project-model",
executionCredentialInstanceId: "project-instance",
executionGlobalProvider: "global-provider",
executionGlobalModelId: "global-model",
executionGlobalCredentialInstanceId: "global-instance",
})).toEqual({ provider: "project-provider", modelId: "project-model", credentialInstanceId: "project-instance" });
expect(resolveExecutionSettingsModel({
executionProvider: "project-provider",
executionModelId: "project-model",
executionGlobalCredentialInstanceId: "losing-instance",
})).toEqual({ provider: "project-provider", modelId: "project-model" });
});
it("carries workflow, fallback, and task credential instances with their complete pairs", () => {
expect(resolvePlanningSettingsModel({
selectedWorkflowModelLanes: {
planningProvider: "workflow-provider",
planningModelId: "workflow-model",
planningCredentialInstanceId: "workflow-instance",
},
})).toEqual({ provider: "workflow-provider", modelId: "workflow-model", credentialInstanceId: "workflow-instance" });
expect(resolveValidatorFallbackModel({
validatorFallbackProvider: "fallback-provider",
validatorFallbackModelId: "fallback-model",
validatorFallbackCredentialInstanceId: "fallback-instance",
})).toEqual({ provider: "fallback-provider", modelId: "fallback-model", credentialInstanceId: "fallback-instance" });
expect(resolveTaskExecutionModel({
modelProvider: "task-provider", modelId: "task-model", credentialInstanceId: "task-instance",
})).toEqual({ provider: "task-provider", modelId: "task-model", credentialInstanceId: "task-instance" });
});
});

View File

@@ -0,0 +1,81 @@
/*
* FNXC:CredentialInstanceSelection 2026-08-01-05:53:
* Credential-instance selection is persisted data in this slice. Exercise each task authoring
* route and lifecycle projection so a future runtime consumer receives only durable, validated ids.
*/
import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from "vitest";
import {
createSharedPgTaskStoreTestHarness,
pgDescribe,
type SharedPgTaskStoreHarness,
} from "../../__test-utils__/pg-test-harness.js";
const credentialFields = {
credentialInstanceId: "executor-instance",
validatorCredentialInstanceId: "validator-instance",
planningCredentialInstanceId: "planning-instance",
mergerCredentialInstanceId: "merger-instance",
};
pgDescribe("credential-instance task persistence (PostgreSQL)", () => {
const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({
prefix: "fusion_credential_instance_selection",
});
beforeAll(h.beforeAll);
beforeEach(h.beforeEach);
afterEach(h.afterEach);
afterAll(h.afterAll);
it("round-trips all task fields through create, read, update, archive, restore, and branch-group reads", async () => {
const store = h.store();
const group = await store.ensureBranchGroupForSource("planning", "credential-selection", {
name: "credential selection group",
branchName: "fusion/credential-selection",
baseBranch: "main",
});
const created = await store.createTask({
description: "persist credential instances",
branchContext: { groupId: group.id, source: "planning", assignmentMode: "shared" },
...credentialFields,
} as never);
expect(await store.getTask(created.id)).toMatchObject(credentialFields);
expect(await store.listTasksByBranchGroup(group.id)).toEqual([
expect.objectContaining({ id: created.id, ...credentialFields }),
]);
const updatedFields = {
credentialInstanceId: "executor-updated",
validatorCredentialInstanceId: "validator-updated",
planningCredentialInstanceId: "planning-updated",
mergerCredentialInstanceId: "merger-updated",
};
await store.updateTask(created.id, updatedFields as never);
expect(await store.getTask(created.id)).toMatchObject(updatedFields);
await store.archiveTask(created.id, { cleanup: false } as never);
const restored = await store.unarchiveTask(created.id);
expect(restored).toMatchObject(updatedFields);
});
it("keeps absent task fields absent after a database read", async () => {
const task = await h.store().createTask({ description: "no credential instance" });
const read = await h.store().getTask(task.id);
for (const key of Object.keys(credentialFields)) expect(read).not.toHaveProperty(key);
});
it("rejects malformed create, update, and atomic mutation inputs without partial persistence", async () => {
const store = h.store();
for (const invalid of ["", " ", "bad[id]", "x".repeat(257), 42]) {
await expect(store.createTask({ description: "invalid create", credentialInstanceId: invalid } as never)).rejects.toThrow();
}
const task = await store.createTask({ description: "invalid update", ...credentialFields } as never);
for (const invalid of ["", " ", "bad[id]", "x".repeat(257), 42]) {
await expect(store.updateTask(task.id, { validatorCredentialInstanceId: invalid } as never)).rejects.toThrow();
await expect(store.updateTaskAtomic(task.id, () => ({ planningCredentialInstanceId: invalid } as never))).rejects.toThrow();
expect(await store.getTask(task.id)).toMatchObject(credentialFields);
}
});
});

View File

@@ -11,6 +11,14 @@ import {
createSharedPgTaskStoreTestHarness,
type SharedPgTaskStoreHarness,
} from "../../__test-utils__/pg-test-harness.js";
import { GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS } from "../../settings-schema.js";
const credentialLaneKeys = [
["defaultProvider", "defaultCredentialInstanceId"],
["executionGlobalProvider", "executionGlobalCredentialInstanceId"],
["titleSummarizerProvider", "titleSummarizerCredentialInstanceId"],
["defaultProviderOverride", "defaultCredentialInstanceIdOverride"],
] as const;
const pgTest = pgDescribe;
@@ -60,4 +68,79 @@ pgTest("VAL-CROSS-004: Settings persistence (PostgreSQL)", () => {
const settings2 = await store.getSettings();
expect(settings2.maxConcurrentTasks).toBe(5);
});
it("persists global, project, lane, fallback, and preset credential instances", async () => {
const store = h.store();
await store.updateGlobalSettings({
defaultCredentialInstanceId: "global-default",
executionGlobalCredentialInstanceId: "global-execution",
fallbackCredentialInstanceId: "global-fallback",
});
await store.updateSettings({
defaultCredentialInstanceIdOverride: "project-default",
titleSummarizerCredentialInstanceId: "project-title",
titleSummarizerFallbackCredentialInstanceId: "project-title-fallback",
modelPresets: [{
id: "credential-preset",
name: "Credential preset",
executorProvider: "anthropic",
executorModelId: "claude",
executorCredentialInstanceId: "preset-executor",
validatorProvider: "openai",
validatorModelId: "gpt",
validatorCredentialInstanceId: "preset-validator",
}],
} as never);
const settings = await store.getSettings();
expect(settings).toMatchObject({
defaultCredentialInstanceId: "global-default",
executionGlobalCredentialInstanceId: "global-execution",
fallbackCredentialInstanceId: "global-fallback",
defaultCredentialInstanceIdOverride: "project-default",
titleSummarizerCredentialInstanceId: "project-title",
titleSummarizerFallbackCredentialInstanceId: "project-title-fallback",
});
expect(settings.modelPresets).toEqual([expect.objectContaining({
executorCredentialInstanceId: "preset-executor",
validatorCredentialInstanceId: "preset-validator",
})]);
});
it("persists a workflow-lane credential instance through its scoped settings row", async () => {
const store = h.store();
const projectId = store.getWorkflowSettingsProjectId();
await store.updateWorkflowSettingValues("builtin:coding", projectId, {
executionCredentialInstanceId: "workflow-execution",
});
expect(await store.getWorkflowSettingValuesAsync("builtin:coding", projectId))
.toMatchObject({ executionCredentialInstanceId: "workflow-execution" });
});
it("keeps credential-instance keys in the same settings scope as their provider", () => {
for (const [providerKey, instanceKey] of credentialLaneKeys) {
expect(GLOBAL_SETTINGS_KEYS.includes(providerKey as never)).toBe(GLOBAL_SETTINGS_KEYS.includes(instanceKey as never));
expect(PROJECT_SETTINGS_KEYS.includes(providerKey as never)).toBe(PROJECT_SETTINGS_KEYS.includes(instanceKey as never));
}
});
it("rejects invalid global/project ids and atomically preserves stored presets", async () => {
const store = h.store();
const priorPresets = [{ id: "prior", name: "Prior", executorProvider: "anthropic", executorModelId: "claude" }];
await store.updateSettings({ modelPresets: priorPresets } as never);
await expect(store.updateGlobalSettings({ defaultCredentialInstanceId: "bad[id]" } as never)).rejects.toThrow();
await expect(store.updateSettings({ titleSummarizerCredentialInstanceId: "bad[id]" } as never)).rejects.toThrow();
await expect(store.updateSettings({ modelPresets: [
...priorPresets,
{ id: "bad-executor", name: "Bad", executorCredentialInstanceId: "bad[id]" },
] } as never)).rejects.toThrow();
expect((await store.getSettings()).modelPresets).toEqual(priorPresets);
await expect(store.updateSettings({ modelPresets: [
...priorPresets,
{ id: "bad-validator", name: "Bad", validatorCredentialInstanceId: " " },
] } as never)).rejects.toThrow();
expect((await store.getSettings()).modelPresets).toEqual(priorPresets);
});
});

View File

@@ -279,3 +279,69 @@ describe("built-in workflow settings parity anchor (U1, R4)", () => {
expect((DEFAULT_PROJECT_SETTINGS as Record<string, unknown>).buildTimeoutMs).toBe(300_000);
});
});
describe("credential-instance workflow settings", () => {
it("accepts valid ids and atomically rejects malformed companion values", async () => {
const { validateSettingValuePatch } = await import("../workflow-settings.js");
const declarations: WorkflowSettingDefinition[] = [
{ id: "executionCredentialInstanceId", name: "Instance", type: "string" },
];
expect(validateSettingValuePatch(declarations, { executionCredentialInstanceId: "work" }).accepted)
.toEqual({ executionCredentialInstanceId: "work" });
for (const executionCredentialInstanceId of ["", " ", "bad[id]", "x".repeat(257), 42]) {
const rejected = validateSettingValuePatch(declarations, { executionCredentialInstanceId });
expect(rejected.accepted).toEqual({});
expect(rejected.rejections).toHaveLength(1);
}
});
});
describe("credential-instance workflow node config", () => {
const valid = {
version: "v2" as const, name: "credential-instance", columns: [],
nodes: [{ id: "start", kind: "start" }, { id: "end", kind: "end", config: { credentialInstanceId: "work" } }],
edges: [{ from: "start", to: "end" }],
};
it("round-trips valid ids and preserves omission", () => {
expect(parseWorkflowIr(valid).nodes[1]?.config?.credentialInstanceId).toBe("work");
const omitted = parseWorkflowIr({ ...valid, nodes: [{ id: "start", kind: "start" }, { id: "end", kind: "end" }] });
expect(omitted.nodes[1]?.config).toBeUndefined();
});
it("rejects malformed and non-string ids", () => {
for (const credentialInstanceId of ["", " ", "bad[id]", "x".repeat(257), 42]) {
expect(() => parseWorkflowIr({
...valid,
nodes: [{ id: "start", kind: "start" }, { id: "end", kind: "end", config: { credentialInstanceId } }],
})).toThrow(WorkflowIrError);
}
});
it("validates credential instances inside foreach templates", () => {
const template = {
version: "v2" as const,
name: "credential-instance-template",
columns: [],
artifacts: [{ key: "plan" }],
nodes: [
{ id: "start", kind: "start" },
{ id: "steps", kind: "parse-steps", config: { artifact: "plan", parser: "step-headings" } },
{ id: "each", kind: "foreach", config: { source: "task-steps", template: {
nodes: [{ id: "execute", kind: "step-execute", config: { credentialInstanceId: "template-instance" } }],
edges: [],
} } },
{ id: "end", kind: "end" },
],
edges: [{ from: "start", to: "steps" }, { from: "steps", to: "each" }, { from: "each", to: "end" }],
};
expect(parseWorkflowIr(template).nodes[2]?.config?.template?.nodes[0]?.config?.credentialInstanceId).toBe("template-instance");
expect(() => parseWorkflowIr({
...template,
nodes: [...template.nodes.slice(0, 2), { ...template.nodes[2], config: {
...template.nodes[2].config,
template: { nodes: [{ id: "execute", kind: "step-execute", config: { credentialInstanceId: "bad[id]" } }], edges: [] },
} }, template.nodes[3]],
})).toThrow(WorkflowIrError);
});
});

View File

@@ -208,6 +208,11 @@ export const BUILTIN_MOVED_WORKFLOW_SETTINGS: WorkflowSettingDefinition[] = [
// workflow settings. `reflectionEnabled` is kept because executor.ts reads it
// (gate for reflection tools).
/*
* FNXC:CredentialInstanceSelection 2026-08-01-05:38:
* Workflow lanes persist optional credential-instance ids beside their provider/model pairs.
* Values are validated at write time and remain inert until runtime credential resolution.
*/
// ── Per-phase model lanes ──────────────────────────────────────────────
// Legacy defaults are all `undefined`; `default` is omitted so resolution
// falls through to the global lane / project default (KTD-7).
@@ -224,6 +229,12 @@ export const BUILTIN_MOVED_WORKFLOW_SETTINGS: WorkflowSettingDefinition[] = [
type: "string",
description: "Provider for the execution phase. Empty falls through to the global lane.",
},
{
id: "executionCredentialInstanceId",
name: "Execution credential instance",
type: "string",
description: "Optional credential instance for the execution model pair.",
},
{
id: "executionModelId",
name: "Execution model",
@@ -248,6 +259,12 @@ export const BUILTIN_MOVED_WORKFLOW_SETTINGS: WorkflowSettingDefinition[] = [
type: "string",
description: "Fallback provider for the execution phase.",
},
{
id: "executionFallbackCredentialInstanceId",
name: "Execution fallback credential instance",
type: "string",
description: "Optional credential instance for the execution fallback model pair.",
},
{
id: "executionFallbackModelId",
name: "Executor fallback model",
@@ -267,6 +284,12 @@ export const BUILTIN_MOVED_WORKFLOW_SETTINGS: WorkflowSettingDefinition[] = [
type: "string",
description: "Provider for the planning phase. Empty falls through to the global lane.",
},
{
id: "planningCredentialInstanceId",
name: "Planning credential instance",
type: "string",
description: "Optional credential instance for the planning model pair.",
},
{
id: "planningModelId",
name: "Planning model",
@@ -286,6 +309,12 @@ export const BUILTIN_MOVED_WORKFLOW_SETTINGS: WorkflowSettingDefinition[] = [
type: "string",
description: "Fallback provider for the planning phase.",
},
{
id: "planningFallbackCredentialInstanceId",
name: "Planning fallback credential instance",
type: "string",
description: "Optional credential instance for the planning fallback model pair.",
},
{
id: "planningFallbackModelId",
name: "Planning fallback model",
@@ -309,6 +338,12 @@ export const BUILTIN_MOVED_WORKFLOW_SETTINGS: WorkflowSettingDefinition[] = [
type: "string",
description: "Provider for the validation phase. Empty falls through to the global lane.",
},
{
id: "validatorCredentialInstanceId",
name: "Validator credential instance",
type: "string",
description: "Optional credential instance for the validator model pair.",
},
{
id: "validatorModelId",
name: "Validator model",
@@ -328,6 +363,12 @@ export const BUILTIN_MOVED_WORKFLOW_SETTINGS: WorkflowSettingDefinition[] = [
type: "string",
description: "Fallback provider for the validation phase.",
},
{
id: "validatorFallbackCredentialInstanceId",
name: "Validator fallback credential instance",
type: "string",
description: "Optional credential instance for the validator fallback model pair.",
},
{
id: "validatorFallbackModelId",
name: "Validator fallback model",

View File

@@ -10,6 +10,7 @@ import { routeModel } from "./model-router.js";
export interface ResolvedModelSelection {
provider?: string;
modelId?: string;
credentialInstanceId?: string;
}
export type ModelThinkingPhase = "execution" | "planning" | "validation";
@@ -32,6 +33,7 @@ type ModelPair =
| {
provider?: string | null;
modelId?: string | null;
credentialInstanceId?: string | null;
}
| undefined;
@@ -44,16 +46,31 @@ type TaskModelLike = {
planningModelId?: string | null;
mergerModelProvider?: string | null;
mergerModelId?: string | null;
credentialInstanceId?: string | null;
validatorCredentialInstanceId?: string | null;
planningCredentialInstanceId?: string | null;
mergerCredentialInstanceId?: string | null;
};
function hasCompleteModelPair(pair: ModelPair): pair is { provider: string; modelId: string } {
function hasCompleteModelPair(pair: ModelPair): pair is { provider: string; modelId: string; credentialInstanceId?: string | null } {
return Boolean(pair?.provider && pair?.modelId);
}
/*
* FNXC:CredentialInstanceSelection 2026-08-01-05:38:
* Credential instance selection is persisted but inert in this slice. A winning provider/model
* pair carries only its own optional instance id so existing resolution precedence is unchanged.
*/
function pickFirstModelPair(...pairs: ModelPair[]): ResolvedModelSelection {
for (const pair of pairs) {
if (hasCompleteModelPair(pair)) {
return { provider: pair.provider, modelId: pair.modelId };
return {
provider: pair.provider,
modelId: pair.modelId,
...(typeof pair.credentialInstanceId === "string" && pair.credentialInstanceId.length > 0
? { credentialInstanceId: pair.credentialInstanceId }
: {}),
};
}
}
return {};
@@ -150,10 +167,12 @@ export function resolveProjectDefaultModel(settings?: Partial<Settings>): Resolv
pickFirstModelPair(
{
provider: settings?.defaultProviderOverride,
credentialInstanceId: settings?.defaultCredentialInstanceIdOverride,
modelId: settings?.defaultModelIdOverride,
},
{
provider: settings?.defaultProvider,
credentialInstanceId: settings?.defaultCredentialInstanceId,
modelId: settings?.defaultModelId,
},
),
@@ -166,14 +185,17 @@ export function resolveExecutionSettingsModel(settings?: Partial<Settings>): Res
pickFirstModelPair(
{
provider: settings?.executionProvider,
credentialInstanceId: settings?.executionCredentialInstanceId,
modelId: settings?.executionModelId,
},
{
provider: settings?.executionGlobalProvider,
credentialInstanceId: settings?.executionGlobalCredentialInstanceId,
modelId: settings?.executionGlobalModelId,
},
{
provider: resolveSelectedWorkflowModelLane(settings, "executionProvider"),
credentialInstanceId: resolveSelectedWorkflowModelLane(settings, "executionCredentialInstanceId"),
modelId: resolveSelectedWorkflowModelLane(settings, "executionModelId"),
},
resolveProjectDefaultModel(settings),
@@ -187,14 +209,17 @@ export function resolvePlanningSettingsModel(settings?: Partial<Settings>): Reso
pickFirstModelPair(
{
provider: settings?.planningProvider,
credentialInstanceId: settings?.planningCredentialInstanceId,
modelId: settings?.planningModelId,
},
{
provider: settings?.planningGlobalProvider,
credentialInstanceId: settings?.planningGlobalCredentialInstanceId,
modelId: settings?.planningGlobalModelId,
},
{
provider: resolveSelectedWorkflowModelLane(settings, "planningProvider"),
credentialInstanceId: resolveSelectedWorkflowModelLane(settings, "planningCredentialInstanceId"),
modelId: resolveSelectedWorkflowModelLane(settings, "planningModelId"),
},
resolveProjectDefaultModel(settings),
@@ -208,14 +233,17 @@ export function resolveValidatorSettingsModel(settings?: Partial<Settings>): Res
pickFirstModelPair(
{
provider: settings?.validatorProvider,
credentialInstanceId: settings?.validatorCredentialInstanceId,
modelId: settings?.validatorModelId,
},
{
provider: settings?.validatorGlobalProvider,
credentialInstanceId: settings?.validatorGlobalCredentialInstanceId,
modelId: settings?.validatorGlobalModelId,
},
{
provider: resolveSelectedWorkflowModelLane(settings, "validatorProvider"),
credentialInstanceId: resolveSelectedWorkflowModelLane(settings, "validatorCredentialInstanceId"),
modelId: resolveSelectedWorkflowModelLane(settings, "validatorModelId"),
},
resolveProjectDefaultModel(settings),
@@ -229,14 +257,17 @@ export function resolveTitleSummarizerSettingsModel(settings?: Partial<Settings>
pickFirstModelPair(
{
provider: settings?.titleSummarizerProvider,
credentialInstanceId: settings?.titleSummarizerCredentialInstanceId,
modelId: settings?.titleSummarizerModelId,
},
{
provider: settings?.titleSummarizerGlobalProvider,
credentialInstanceId: settings?.titleSummarizerGlobalCredentialInstanceId,
modelId: settings?.titleSummarizerGlobalModelId,
},
{
provider: settings?.planningProvider,
credentialInstanceId: settings?.planningCredentialInstanceId,
modelId: settings?.planningModelId,
},
resolveProjectDefaultModel(settings),
@@ -256,18 +287,22 @@ export function resolveImportTranslateSettingsModel(settings?: Partial<Settings>
pickFirstModelPair(
{
provider: settings?.importTranslateProvider,
credentialInstanceId: settings?.importTranslateCredentialInstanceId,
modelId: settings?.importTranslateModelId,
},
{
provider: settings?.importTranslateGlobalProvider,
credentialInstanceId: settings?.importTranslateGlobalCredentialInstanceId,
modelId: settings?.importTranslateGlobalModelId,
},
{
provider: settings?.titleSummarizerProvider,
credentialInstanceId: settings?.titleSummarizerCredentialInstanceId,
modelId: settings?.titleSummarizerModelId,
},
{
provider: settings?.titleSummarizerGlobalProvider,
credentialInstanceId: settings?.titleSummarizerGlobalCredentialInstanceId,
modelId: settings?.titleSummarizerGlobalModelId,
},
resolveProjectDefaultModel(settings),
@@ -287,10 +322,12 @@ export function resolveMergerSettingsModel(settings?: Partial<Settings>): Resolv
pickFirstModelPair(
{
provider: settings?.mergerProvider,
credentialInstanceId: settings?.mergerCredentialInstanceId,
modelId: settings?.mergerModelId,
},
{
provider: settings?.mergerGlobalProvider,
credentialInstanceId: settings?.mergerGlobalCredentialInstanceId,
modelId: settings?.mergerGlobalModelId,
},
resolveProjectDefaultModel(settings),
@@ -310,10 +347,12 @@ export function resolveMergerFallbackModel(settings?: Partial<Settings>): Resolv
pickFirstModelPair(
{
provider: settings?.mergerFallbackProvider,
credentialInstanceId: settings?.mergerFallbackCredentialInstanceId,
modelId: settings?.mergerFallbackModelId,
},
{
provider: settings?.fallbackProvider,
credentialInstanceId: settings?.fallbackCredentialInstanceId,
modelId: settings?.fallbackModelId,
},
),
@@ -331,14 +370,17 @@ export function resolveExecutorFallbackModel(settings?: Partial<Settings>): Reso
pickFirstModelPair(
{
provider: settings?.executionFallbackProvider,
credentialInstanceId: settings?.executionFallbackCredentialInstanceId,
modelId: settings?.executionFallbackModelId,
},
{
provider: settings?.fallbackProvider,
credentialInstanceId: settings?.fallbackCredentialInstanceId,
modelId: settings?.fallbackModelId,
},
{
provider: resolveSelectedWorkflowModelLane(settings, "executionFallbackProvider"),
credentialInstanceId: resolveSelectedWorkflowModelLane(settings, "executionFallbackCredentialInstanceId"),
modelId: resolveSelectedWorkflowModelLane(settings, "executionFallbackModelId"),
},
),
@@ -351,14 +393,17 @@ export function resolvePlanningFallbackModel(settings?: Partial<Settings>): Reso
pickFirstModelPair(
{
provider: settings?.planningFallbackProvider,
credentialInstanceId: settings?.planningFallbackCredentialInstanceId,
modelId: settings?.planningFallbackModelId,
},
{
provider: settings?.fallbackProvider,
credentialInstanceId: settings?.fallbackCredentialInstanceId,
modelId: settings?.fallbackModelId,
},
{
provider: resolveSelectedWorkflowModelLane(settings, "planningFallbackProvider"),
credentialInstanceId: resolveSelectedWorkflowModelLane(settings, "planningFallbackCredentialInstanceId"),
modelId: resolveSelectedWorkflowModelLane(settings, "planningFallbackModelId"),
},
),
@@ -371,14 +416,17 @@ export function resolveValidatorFallbackModel(settings?: Partial<Settings>): Res
pickFirstModelPair(
{
provider: settings?.validatorFallbackProvider,
credentialInstanceId: settings?.validatorFallbackCredentialInstanceId,
modelId: settings?.validatorFallbackModelId,
},
{
provider: settings?.fallbackProvider,
credentialInstanceId: settings?.fallbackCredentialInstanceId,
modelId: settings?.fallbackModelId,
},
{
provider: resolveSelectedWorkflowModelLane(settings, "validatorFallbackProvider"),
credentialInstanceId: resolveSelectedWorkflowModelLane(settings, "validatorFallbackCredentialInstanceId"),
modelId: resolveSelectedWorkflowModelLane(settings, "validatorFallbackModelId"),
},
),
@@ -394,6 +442,7 @@ export function resolveTaskExecutionModel(
pickFirstModelPair(
{
provider: task.modelProvider,
credentialInstanceId: task.credentialInstanceId,
modelId: task.modelId,
},
resolveExecutionSettingsModel(settings),
@@ -410,6 +459,7 @@ export function resolveTaskValidatorModel(
pickFirstModelPair(
{
provider: task.validatorModelProvider,
credentialInstanceId: task.validatorCredentialInstanceId,
modelId: task.validatorModelId,
},
resolveValidatorSettingsModel(settings),
@@ -426,6 +476,7 @@ export function resolveTaskPlanningModel(
pickFirstModelPair(
{
provider: task.planningModelProvider,
credentialInstanceId: task.planningCredentialInstanceId,
modelId: task.planningModelId,
},
resolvePlanningSettingsModel(settings),
@@ -445,7 +496,7 @@ export function resolveTaskMergerModel(
): ResolvedModelSelection {
return applyTestModeOverrides(
pickFirstModelPair(
{ provider: task.mergerModelProvider, modelId: task.mergerModelId },
{ provider: task.mergerModelProvider, modelId: task.mergerModelId, credentialInstanceId: task.mergerCredentialInstanceId },
resolveMergerSettingsModel(settings),
),
settings,

View File

@@ -0,0 +1,9 @@
/*
FNXC:CredentialInstanceSelection 2026-08-01-05:53:
Task credential-instance companions are persisted-but-inert in this slice. The project-partitioned
`tasks` table owns model overrides, so upgrades must add these nullable columns in the same schema.
*/
ALTER TABLE project.tasks ADD COLUMN IF NOT EXISTS credential_instance_id text;
ALTER TABLE project.tasks ADD COLUMN IF NOT EXISTS validator_credential_instance_id text;
ALTER TABLE project.tasks ADD COLUMN IF NOT EXISTS planning_credential_instance_id text;
ALTER TABLE project.tasks ADD COLUMN IF NOT EXISTS merger_credential_instance_id text;

View File

@@ -54,7 +54,7 @@ FNXC:MissionTaskPrefix 2026-07-30-21:10 (rebase onto migrated main):
SCHEMA_BASELINE_VERSION advances to 0038 for optional per-mission task_prefix — 0037 is the
capacity-model table drop that landed while this PR was open.
*/
export const SCHEMA_BASELINE_VERSION = "0038";
export const SCHEMA_BASELINE_VERSION = "0039";
/** FNXC:SymbolLock 2026-07-20-10:00: upgrades need durable task declarations before admission resolves symbols. */
export const TASK_DECLARED_SYMBOLS_VERSION = "0028";
const INITIAL_SCHEMA_VERSION = "0000";
@@ -173,6 +173,8 @@ second would read as already-applied and silently never run. Renumbered rather t
is landed on real databases and its identity is immutable, per the MONITOR_APPROVAL note above.
*/
export const MISSION_TASK_PREFIX_VERSION = "0038";
/** FNXC:CredentialInstanceSelection 2026-08-01-05:43: explicit registration prevents migration 0039 from being silently skipped on upgraded PostgreSQL databases. */
export const CREDENTIAL_INSTANCE_SELECTION_VERSION = "0039";
/** SECURITY DEFINER helper that only inserts LEGACY_ADOPTION_DRAINED_MARKER. */
export const LEGACY_ADOPTION_DRAINED_MARKER_FUNCTION = "fusion_mark_legacy_adoption_drained";
@@ -385,6 +387,7 @@ const MISSION_LINEAGE_STOP_MIGRATION_PATH = join(MIGRATIONS_DIR, "0035_fn_8543_m
const CHAT_SESSION_TAGS_MIGRATION_PATH = join(MIGRATIONS_DIR, "0036_chat_session_tags.sql");
const DROP_GLOBAL_CONCURRENCY_MIGRATION_PATH = join(MIGRATIONS_DIR, "0037_drop_global_concurrency.sql");
const MISSION_TASK_PREFIX_MIGRATION_PATH = join(MIGRATIONS_DIR, "0038_mission_task_prefix.sql");
const CREDENTIAL_INSTANCE_SELECTION_MIGRATION_PATH = join(MIGRATIONS_DIR, "0039_fn_8660_credential_instance_selection.sql");
/**
* Ensure the migration bookkeeping table exists. Lives in the public schema so
@@ -493,6 +496,7 @@ export async function applySchemaBaseline(
const chatSessionTagsAlreadyApplied = applied.includes(CHAT_SESSION_TAGS_VERSION);
const dropGlobalConcurrencyAlreadyApplied = applied.includes(DROP_GLOBAL_CONCURRENCY_VERSION);
const missionTaskPrefixAlreadyApplied = applied.includes(MISSION_TASK_PREFIX_VERSION);
const credentialInstanceSelectionAlreadyApplied = applied.includes(CREDENTIAL_INSTANCE_SELECTION_VERSION);
assertBinaryNotOlderThanDatabase(applied);
let schemaChanged = false;
@@ -1042,6 +1046,14 @@ export async function applySchemaBaseline(
schemaChanged = true;
}
/* FNXC:CredentialInstanceSelection 2026-08-01-05:43: upgraded task rows need nullable persisted instance companions before model-selection writers can store them; this is data-only and does not resolve credentials at runtime. */
if (!credentialInstanceSelectionAlreadyApplied) {
const migrationSql = await readFile(CREDENTIAL_INSTANCE_SELECTION_MIGRATION_PATH, "utf8");
await tx.execute(sql.raw(migrationSql));
await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${CREDENTIAL_INSTANCE_SELECTION_VERSION}) ON CONFLICT (version) DO NOTHING`);
schemaChanged = true;
}
return { applied: schemaChanged, pluginHooksRun: pluginHooks.length };
});
}

View File

@@ -92,12 +92,16 @@ export const tasks = projectSchema.table("tasks", {
baseCommitSha: text("base_commit_sha"),
modelPresetId: text("model_preset_id"),
modelProvider: text("model_provider"),
credentialInstanceId: text("credential_instance_id"),
modelId: text("model_id"),
validatorModelProvider: text("validator_model_provider"),
validatorCredentialInstanceId: text("validator_credential_instance_id"),
validatorModelId: text("validator_model_id"),
planningModelProvider: text("planning_model_provider"),
planningCredentialInstanceId: text("planning_credential_instance_id"),
planningModelId: text("planning_model_id"),
mergerModelProvider: text("merger_model_provider"),
mergerCredentialInstanceId: text("merger_credential_instance_id"),
mergerModelId: text("merger_model_id"),
mergerThinkingLevel: text("merger_thinking_level"),
mergeRetries: integer("merge_retries"),

View File

@@ -41,21 +41,27 @@ type MovedProjectSettingsKey =
| "maxReviewerFallbackRetries"
| "reflectionEnabled"
| "executionProvider"
| "executionCredentialInstanceId"
| "executionModelId"
| "executionThinkingLevel"
| "executionFallbackProvider"
| "executionFallbackCredentialInstanceId"
| "executionFallbackModelId"
| "executionFallbackThinkingLevel"
| "planningProvider"
| "planningCredentialInstanceId"
| "planningModelId"
| "planningThinkingLevel"
| "planningFallbackProvider"
| "planningFallbackCredentialInstanceId"
| "planningFallbackModelId"
| "planningFallbackThinkingLevel"
| "validatorProvider"
| "validatorCredentialInstanceId"
| "validatorModelId"
| "validatorThinkingLevel"
| "validatorFallbackProvider"
| "validatorFallbackCredentialInstanceId"
| "validatorFallbackModelId"
| "validatorFallbackThinkingLevel";
@@ -141,6 +147,7 @@ export const DEFAULT_GLOBAL_SETTINGS = {
skipConfirmationDialogs: false,
language: undefined,
defaultProvider: undefined,
defaultCredentialInstanceId: undefined,
defaultModelId: undefined,
testMode: undefined,
voiceInput: undefined,
@@ -152,6 +159,7 @@ export const DEFAULT_GLOBAL_SETTINGS = {
modelRouterCheapModelId: undefined,
mergeRequestContractShadowEnabled: false,
fallbackProvider: undefined,
fallbackCredentialInstanceId: undefined,
fallbackModelId: undefined,
/*
FNXC:Settings-ThinkingLevel 2026-07-10-11:13:
@@ -248,24 +256,30 @@ export const DEFAULT_GLOBAL_SETTINGS = {
ompCliBinaryPath: undefined,
// Global baseline lanes for per-role model selection
executionGlobalProvider: undefined,
executionGlobalCredentialInstanceId: undefined,
executionGlobalModelId: undefined,
planningGlobalProvider: undefined,
planningGlobalCredentialInstanceId: undefined,
planningGlobalModelId: undefined,
validatorGlobalProvider: undefined,
validatorGlobalCredentialInstanceId: undefined,
validatorGlobalModelId: undefined,
titleSummarizerGlobalProvider: undefined,
titleSummarizerGlobalCredentialInstanceId: undefined,
titleSummarizerGlobalModelId: undefined,
/*
FNXC:Settings-MergerModel 2026-07-13-07:52:
Global merger baseline lane (provider/model/thinking) is independent of executor/planner/reviewer so operators can pin a merge-capable model under Settings → Global Models without changing other lanes. Undefined falls through to defaultProvider/defaultModelId at resolve time.
*/
mergerGlobalProvider: undefined,
mergerGlobalCredentialInstanceId: undefined,
mergerGlobalModelId: undefined,
/*
FNXC:GitHubImportTranslate 2026-07-15-09:30:
Global import-translate baseline lane. Undefined falls through to the summarization lane then defaultProvider/defaultModelId at resolve time.
*/
importTranslateGlobalProvider: undefined,
importTranslateGlobalCredentialInstanceId: undefined,
importTranslateGlobalModelId: undefined,
importTranslateGlobalThinkingLevel: undefined,
/*
@@ -536,6 +550,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
// (executionGlobalProvider etc.) stay global; project default overrides stay.
// Project-level default override (NOT moved — stays project-scoped)
defaultProviderOverride: undefined,
defaultCredentialInstanceIdOverride: undefined,
defaultModelIdOverride: undefined,
/*
FNXC:Settings-ThinkingLevel 2026-07-10-00:00:
@@ -716,9 +731,11 @@ export const DEFAULT_PROJECT_SETTINGS = {
useAiMergeCommitSummary: true,
// Title-summarizer model lanes stay project-scoped (not moved in U4).
titleSummarizerProvider: undefined,
titleSummarizerCredentialInstanceId: undefined,
titleSummarizerModelId: undefined,
titleSummarizerThinkingLevel: undefined,
titleSummarizerFallbackProvider: undefined,
titleSummarizerFallbackCredentialInstanceId: undefined,
titleSummarizerFallbackModelId: undefined,
titleSummarizerFallbackThinkingLevel: undefined,
/*
@@ -728,6 +745,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
githubImportAutoTranslate: false,
importTranslateTargetLocale: undefined,
importTranslateProvider: undefined,
importTranslateCredentialInstanceId: undefined,
importTranslateModelId: undefined,
importTranslateThinkingLevel: undefined,
/*
@@ -735,10 +753,12 @@ export const DEFAULT_PROJECT_SETTINGS = {
Merger model lane stays project-scoped (not workflow-moved) like title summarizer: Settings → Project Models can override the global merger baseline without binding the choice to a workflow graph.
*/
mergerProvider: undefined,
mergerCredentialInstanceId: undefined,
mergerModelId: undefined,
mergerThinkingLevel: undefined,
// FNXC:Settings-MergerModel 2026-07-16-00:00: project merger fallback overrides shared global fallback only when its provider/model pair is complete.
mergerFallbackProvider: undefined,
mergerFallbackCredentialInstanceId: undefined,
mergerFallbackModelId: undefined,
mergerFallbackThinkingLevel: undefined,
prTitlePromptInstructions: undefined,
@@ -858,6 +878,11 @@ export const DEFAULT_PROJECT_SETTINGS = {
* that matches the legacy `DEFAULT_SETTINGS` shape.
*/
export const DEFAULT_SETTINGS: Settings = {
/*
* FNXC:CredentialInstanceSelection 2026-08-01-05:38:
* Optional credential-instance settings share each provider lane's scope and remain inert
* until runtime credential selection is introduced by the follow-up slice.
*/
...DEFAULT_GLOBAL_SETTINGS,
...DEFAULT_PROJECT_SETTINGS,
};

View File

@@ -1319,7 +1319,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
}
async updateTask(
id: string,
updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; workspaceWorktrees?: import("./types.js").Task["workspaceWorktrees"]; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; customFields?: Record<string, unknown>; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; wedgeNotification?: import("./types.js").TaskWedgeNotificationState | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("./types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("./types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; executeRequeueLoopCount?: number | null; graphResumeRetryCount?: number | null; consecutiveToolFailureRetryCount?: number | null; executorEscalationAttempted?: boolean | null; toolFailureDetectorLogCursor?: number | null; toolFailureRetryExhaustedAuditEmitted?: boolean | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; executeRequeueLoopSignature?: string | null; postReviewFixCount?: number | null; planReviewReplanCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; bulkCompletionRefusalAt?: string | null; workflowIrPin?: string | null; workflowIrPinNodeId?: string | null; workflowIrPinColumnId?: string | null; legacyAdoptedAt?: string | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; mergerModelProvider?: string | null; mergerModelId?: string | null; thinkingLevel?: string | null; validatorThinkingLevel?: string | null; planningThinkingLevel?: string | null; mergerThinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; cumulativePlanningMs?: number | null; planningStartedAt?: string | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record<string, unknown> | null; githubTracking?: import("./types.js").TaskGithubTracking | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; declaredSymbols?: string[] | null | undefined; missionId?: string | null; sliceId?: string | null; workflowTransitionNotification?: import("./types.js").WorkflowTransitionNotificationMarker | undefined; plannerOversightLevel?: string | null; sessionAdvisorEnabled?: boolean | null; approvedPlanFingerprint?: string | null }, runContext?: RunMutationContext,
updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; workspaceWorktrees?: import("./types.js").Task["workspaceWorktrees"]; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; customFields?: Record<string, unknown>; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; wedgeNotification?: import("./types.js").TaskWedgeNotificationState | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("./types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("./types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; executeRequeueLoopCount?: number | null; graphResumeRetryCount?: number | null; consecutiveToolFailureRetryCount?: number | null; executorEscalationAttempted?: boolean | null; toolFailureDetectorLogCursor?: number | null; toolFailureRetryExhaustedAuditEmitted?: boolean | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; executeRequeueLoopSignature?: string | null; postReviewFixCount?: number | null; planReviewReplanCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; bulkCompletionRefusalAt?: string | null; workflowIrPin?: string | null; workflowIrPinNodeId?: string | null; workflowIrPinColumnId?: string | null; legacyAdoptedAt?: string | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; credentialInstanceId?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorCredentialInstanceId?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningCredentialInstanceId?: string | null; planningModelId?: string | null; mergerModelProvider?: string | null; mergerCredentialInstanceId?: string | null; mergerModelId?: string | null; thinkingLevel?: string | null; validatorThinkingLevel?: string | null; planningThinkingLevel?: string | null; mergerThinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; cumulativePlanningMs?: number | null; planningStartedAt?: string | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record<string, unknown> | null; githubTracking?: import("./types.js").TaskGithubTracking | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; declaredSymbols?: string[] | null | undefined; missionId?: string | null; sliceId?: string | null; workflowTransitionNotification?: import("./types.js").WorkflowTransitionNotificationMarker | undefined; plannerOversightLevel?: string | null; sessionAdvisorEnabled?: boolean | null; approvedPlanFingerprint?: string | null }, runContext?: RunMutationContext,
): Promise<Task> {
return updateTaskImpl(this, id, updates, runContext);
}

View File

@@ -107,12 +107,16 @@ export async function taskToArchiveEntryImpl(store: TaskStore, task: Task, archi
archivedAt,
modelPresetId: task.modelPresetId,
modelProvider: task.modelProvider,
credentialInstanceId: task.credentialInstanceId,
modelId: task.modelId,
validatorModelProvider: task.validatorModelProvider,
validatorCredentialInstanceId: task.validatorCredentialInstanceId,
validatorModelId: task.validatorModelId,
planningModelProvider: task.planningModelProvider,
planningCredentialInstanceId: task.planningCredentialInstanceId,
planningModelId: task.planningModelId,
mergerModelProvider: task.mergerModelProvider,
mergerCredentialInstanceId: task.mergerCredentialInstanceId,
mergerModelId: task.mergerModelId,
mergerThinkingLevel: task.mergerThinkingLevel,
breakIntoSubtasks: task.breakIntoSubtasks,
@@ -571,12 +575,16 @@ export async function restoreFromArchiveImpl(store: TaskStore, entry: import("..
columnMovedAt: entry.columnMovedAt,
modelPresetId: entry.modelPresetId,
modelProvider: entry.modelProvider,
credentialInstanceId: entry.credentialInstanceId,
modelId: entry.modelId,
validatorModelProvider: entry.validatorModelProvider,
validatorCredentialInstanceId: entry.validatorCredentialInstanceId,
validatorModelId: entry.validatorModelId,
planningModelProvider: entry.planningModelProvider,
planningCredentialInstanceId: entry.planningCredentialInstanceId,
planningModelId: entry.planningModelId,
mergerModelProvider: entry.mergerModelProvider,
mergerCredentialInstanceId: entry.mergerCredentialInstanceId,
mergerModelId: entry.mergerModelId,
mergerThinkingLevel: entry.mergerThinkingLevel,
breakIntoSubtasks: entry.breakIntoSubtasks,

View File

@@ -600,7 +600,7 @@ export async function resetPromptCheckboxesImpl(store: TaskStore, dir: string):
export async function updateTaskImpl(store: TaskStore,
id: string,
updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; workspaceWorktrees?: import("../types.js").Task["workspaceWorktrees"]; status?: string | null; dependencies?: string[]; steps?: import("../types.js").TaskStep[]; customFields?: Record<string, unknown>; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("../types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("../types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("../types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; executeRequeueLoopCount?: number | null; graphResumeRetryCount?: number | null; consecutiveToolFailureRetryCount?: number | null; executorEscalationAttempted?: boolean | null; toolFailureDetectorLogCursor?: number | null; toolFailureRetryExhaustedAuditEmitted?: boolean | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; executeRequeueLoopSignature?: string | null; postReviewFixCount?: number | null; planReviewReplanCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; bulkCompletionRefusalAt?: string | null; workflowIrPin?: string | null; workflowIrPinNodeId?: string | null; workflowIrPinColumnId?: string | null; legacyAdoptedAt?: string | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; mergerModelProvider?: string | null; mergerModelId?: string | null; thinkingLevel?: string | null; validatorThinkingLevel?: string | null; planningThinkingLevel?: string | null; mergerThinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("../types.js").TaskReview | null; reviewState?: import("../types.js").TaskReviewState | null; workflowStepResults?: import("../types.js").WorkflowStepResult[] | null; mergeDetails?: import("../types.js").MergeDetails | null; sourceIssue?: import("../types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record<string, unknown> | null; githubTracking?: import("../types.js").TaskGithubTracking | null; tokenUsage?: import("../types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null; workflowTransitionNotification?: import("../types.js").WorkflowTransitionNotificationMarker | undefined; sessionAdvisorEnabled?: boolean | null }, runContext?: RunMutationContext,
updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; workspaceWorktrees?: import("../types.js").Task["workspaceWorktrees"]; status?: string | null; dependencies?: string[]; steps?: import("../types.js").TaskStep[]; customFields?: Record<string, unknown>; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("../types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("../types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("../types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; executeRequeueLoopCount?: number | null; graphResumeRetryCount?: number | null; consecutiveToolFailureRetryCount?: number | null; executorEscalationAttempted?: boolean | null; toolFailureDetectorLogCursor?: number | null; toolFailureRetryExhaustedAuditEmitted?: boolean | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; executeRequeueLoopSignature?: string | null; postReviewFixCount?: number | null; planReviewReplanCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; bulkCompletionRefusalAt?: string | null; workflowIrPin?: string | null; workflowIrPinNodeId?: string | null; workflowIrPinColumnId?: string | null; legacyAdoptedAt?: string | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; credentialInstanceId?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorCredentialInstanceId?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningCredentialInstanceId?: string | null; planningModelId?: string | null; mergerModelProvider?: string | null; mergerCredentialInstanceId?: string | null; mergerModelId?: string | null; thinkingLevel?: string | null; validatorThinkingLevel?: string | null; planningThinkingLevel?: string | null; mergerThinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("../types.js").TaskReview | null; reviewState?: import("../types.js").TaskReviewState | null; workflowStepResults?: import("../types.js").WorkflowStepResult[] | null; mergeDetails?: import("../types.js").MergeDetails | null; sourceIssue?: import("../types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record<string, unknown> | null; githubTracking?: import("../types.js").TaskGithubTracking | null; tokenUsage?: import("../types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null; workflowTransitionNotification?: import("../types.js").WorkflowTransitionNotificationMarker | undefined; sessionAdvisorEnabled?: boolean | null }, runContext?: RunMutationContext,
): Promise<Task> {
/*
FNXC:StateMachine 2026-07-07-12:00:

View File

@@ -38,12 +38,16 @@ export interface TaskRow {
baseCommitSha: string | null;
modelPresetId: string | null;
modelProvider: string | null;
credentialInstanceId: string | null;
modelId: string | null;
validatorModelProvider: string | null;
validatorCredentialInstanceId: string | null;
validatorModelId: string | null;
planningModelProvider: string | null;
planningCredentialInstanceId: string | null;
planningModelId: string | null;
mergerModelProvider: string | null;
mergerCredentialInstanceId: string | null;
mergerModelId: string | null;
mergeRetries: number | null;
workflowStepRetries: number | null;
@@ -255,12 +259,16 @@ export const TASK_COLUMN_DESCRIPTORS: TaskColumnDescriptor[] = [
defineTaskColumn("baseCommitSha", (task) => task.baseCommitSha ?? null),
defineTaskColumn("modelPresetId", (task) => task.modelPresetId ?? null),
defineTaskColumn("modelProvider", (task) => task.modelProvider ?? null),
defineTaskColumn("credentialInstanceId", (task) => task.credentialInstanceId ?? null),
defineTaskColumn("modelId", (task) => task.modelId ?? null),
defineTaskColumn("validatorModelProvider", (task) => task.validatorModelProvider ?? null),
defineTaskColumn("validatorCredentialInstanceId", (task) => task.validatorCredentialInstanceId ?? null),
defineTaskColumn("validatorModelId", (task) => task.validatorModelId ?? null),
defineTaskColumn("planningModelProvider", (task) => task.planningModelProvider ?? null),
defineTaskColumn("planningCredentialInstanceId", (task) => task.planningCredentialInstanceId ?? null),
defineTaskColumn("planningModelId", (task) => task.planningModelId ?? null),
defineTaskColumn("mergerModelProvider", (task) => task.mergerModelProvider ?? null),
defineTaskColumn("mergerCredentialInstanceId", (task) => task.mergerCredentialInstanceId ?? null),
defineTaskColumn("mergerModelId", (task) => task.mergerModelId ?? null),
defineTaskColumn("mergeRetries", (task) => task.mergeRetries ?? null),
defineTaskColumn("workflowStepRetries", (task) => task.workflowStepRetries ?? null),

View File

@@ -93,12 +93,18 @@ export function rowToTask(row: TaskRow): Task {
scopeAutoWiden: fromJson<string[]>(row.scopeAutoWiden) ?? [],
modelPresetId: row.modelPresetId || undefined,
modelProvider: row.modelProvider || undefined,
// FNXC:CredentialInstanceSelection 2026-08-01-05:53: database NULL means omitted,
// not an own `undefined` key, so legacy task reads stay byte-identical in this inert slice.
...(row.credentialInstanceId ? { credentialInstanceId: row.credentialInstanceId } : {}),
modelId: row.modelId || undefined,
validatorModelProvider: row.validatorModelProvider || undefined,
...(row.validatorCredentialInstanceId ? { validatorCredentialInstanceId: row.validatorCredentialInstanceId } : {}),
validatorModelId: row.validatorModelId || undefined,
planningModelProvider: row.planningModelProvider || undefined,
...(row.planningCredentialInstanceId ? { planningCredentialInstanceId: row.planningCredentialInstanceId } : {}),
planningModelId: row.planningModelId || undefined,
mergerModelProvider: row.mergerModelProvider || undefined,
...(row.mergerCredentialInstanceId ? { mergerCredentialInstanceId: row.mergerCredentialInstanceId } : {}),
mergerModelId: row.mergerModelId || undefined,
mergeRetries: row.mergeRetries ?? undefined,
workflowStepRetries: row.workflowStepRetries ?? undefined,

View File

@@ -18,6 +18,34 @@ import {__setTaskActivityLogLimitsForTesting} from "../task-store/comments.js";
import {isPlainObject, deepMergeWithNullDelete} from "../task-store/settings-helpers.js";
import {readProjectConfig as readProjectConfigAsync, writeProjectConfig as writeProjectConfigAsync} from "../task-store/async-settings.js";
import {appendConfigurationRevision, createConfigurationRevision} from "../async-configuration-revision-store.js";
import {isValidProviderInstanceId} from "../provider-instance.js";
/*
* FNXC:CredentialInstanceSelection 2026-08-01-05:38:
* Settings authoring validates persisted-but-inert credential instance ids before either project
* or global persistence. Nested presets are atomic: one malformed element rejects the whole write.
*/
function assertValidCredentialInstanceSettingsPatch(patch: Record<string, unknown>): void {
for (const [key, value] of Object.entries(patch)) {
if (key.endsWith("CredentialInstanceId") || key === "defaultCredentialInstanceIdOverride") {
if (value !== null && value !== undefined && !isValidProviderInstanceId(value)) {
throw new Error(`invalid credential instance id for settings key '${key}'`);
}
}
}
if (patch.modelPresets !== null && patch.modelPresets !== undefined) {
if (!Array.isArray(patch.modelPresets)) throw new Error("modelPresets must be an array");
for (const [index, preset] of patch.modelPresets.entries()) {
if (typeof preset !== "object" || preset === null) throw new Error(`modelPresets[${index}] must be an object`);
for (const key of ["executorCredentialInstanceId", "validatorCredentialInstanceId"] as const) {
const value = (preset as Record<string, unknown>)[key];
if (value !== undefined && !isValidProviderInstanceId(value)) {
throw new Error(`invalid credential instance id for modelPresets[${index}].${key}`);
}
}
}
}
}
/** Publish committed setting snapshots and run the normal post-commit effects. */
export async function publishSettingsUpdated(store: TaskStore, previous: Settings, settings: Settings): Promise<void> {
@@ -30,6 +58,7 @@ export async function publishSettingsUpdated(store: TaskStore, previous: Setting
}
export async function updateSettingsImpl(store: TaskStore, patch: Partial<Settings>, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise<Settings> {
assertValidCredentialInstanceSettingsPatch(patch as Record<string, unknown>);
/*
FNXC:ConfigVersioning 2026-07-18-12:15:
Keep the compatibility SQLite settings path writable while projects migrate
@@ -165,6 +194,7 @@ export async function updateSettingsImpl(store: TaskStore, patch: Partial<Settin
}
export async function updateGlobalSettingsImpl(store: TaskStore, patch: Partial<GlobalSettings>, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise<Settings> {
assertValidCredentialInstanceSettingsPatch(patch as Record<string, unknown>);
// Read previous state BEFORE writing so the diff is correct
const previousGlobal = await store.globalSettingsStore.getSettings();
/*

View File

@@ -37,6 +37,7 @@ import {softDeleteTaskRow as softDeleteTaskRowAsync, insertTaskRowInTransaction,
import {recordRunAuditEvent as recordRunAuditEventAsync} from "../task-store/async-audit.js";
import type {DbTransaction} from "../postgres/data-layer.js";
import { resolveTaskPrefix } from "./task-prefix.js";
import {assertValidProviderInstanceId} from "../provider-instance.js";
type CreateTaskWithAfterInsert = TaskCreateInput & {
/** Internal transaction hook; never persisted in task source metadata. */
@@ -148,7 +149,14 @@ async function resolveDefaultWorkflowIntakeColumn(store: TaskStore): Promise<str
}
}
export async function createTaskBackendImpl(store: TaskStore, input: TaskCreateInput, options?: { onSummarize?: (description: string) => Promise<string | null>; settings?: { autoSummarizeTitles?: boolean }; invokeTaskCreatedHook?: boolean; onProposalClaimConflict?: (task: Task) => void; },): Promise<Task> {
export async function createTaskBackendImpl(store: TaskStore, input: TaskCreateInput, options?: {
onSummarize?: (description: string) => Promise<string | null>; settings?: { autoSummarizeTitles?: boolean }; invokeTaskCreatedHook?: boolean; onProposalClaimConflict?: (task: Task) => void; },): Promise<Task> {
/* FNXC:CredentialInstanceSelection 2026-08-01-05:43: validate task authoring input before persistence; ids are stored but runtime credential resolution remains unchanged. */
for (const key of ["credentialInstanceId", "validatorCredentialInstanceId", "planningCredentialInstanceId", "mergerCredentialInstanceId"] as const) {
const value = (input as unknown as Record<string, unknown>)[key];
if (value !== undefined && value !== null) assertValidProviderInstanceId(value);
}
// U8/R6: apply the reviewLevel creation-time preset (maps level -> enabledWorkflowSteps; explicit wins).
input = applyReviewLevelPreset(input);
if (!input.description?.trim()) {
@@ -471,12 +479,16 @@ export async function _createTaskInternalBackendImpl(store: TaskStore, input: Ta
scopeOverrideReason: input.scopeOverrideReason,
nodeId: input.nodeId,
modelProvider: input.modelProvider,
credentialInstanceId: input.credentialInstanceId,
modelId: input.modelId,
validatorModelProvider: input.validatorModelProvider,
validatorCredentialInstanceId: input.validatorCredentialInstanceId,
validatorModelId: input.validatorModelId,
planningModelProvider: input.planningModelProvider,
planningCredentialInstanceId: input.planningCredentialInstanceId,
planningModelId: input.planningModelId,
mergerModelProvider: input.mergerModelProvider,
mergerCredentialInstanceId: input.mergerCredentialInstanceId,
mergerModelId: input.mergerModelId,
thinkingLevel: input.thinkingLevel,
validatorThinkingLevel: input.validatorThinkingLevel,
@@ -941,12 +953,16 @@ export async function _createTaskInternalImpl(store: TaskStore, input: TaskCreat
scopeOverrideReason: input.scopeOverrideReason,
nodeId: input.nodeId,
modelProvider: input.modelProvider,
credentialInstanceId: input.credentialInstanceId,
modelId: input.modelId,
validatorModelProvider: input.validatorModelProvider,
validatorCredentialInstanceId: input.validatorCredentialInstanceId,
validatorModelId: input.validatorModelId,
planningModelProvider: input.planningModelProvider,
planningCredentialInstanceId: input.planningCredentialInstanceId,
planningModelId: input.planningModelId,
mergerModelProvider: input.mergerModelProvider,
mergerCredentialInstanceId: input.mergerCredentialInstanceId,
mergerModelId: input.mergerModelId,
thinkingLevel: input.thinkingLevel,
validatorThinkingLevel: input.validatorThinkingLevel,

View File

@@ -48,9 +48,9 @@ export function getTaskSelectClauseWithActivityLogLimitImpl(store: TaskStore, li
const columns = [
"id", "lineageId", "title", "description", "priority", "\"column\"", "status", "size", "reviewLevel", "currentStep",
"worktree", "blockedBy", "overlapBlockedBy", "paused", "pausedReason", "userPaused", "baseBranch", "branch", "autoMerge", "autoMergeProvenance", "executionStartBranch", "baseCommitSha",
"modelPresetId", "modelProvider", "modelId",
"validatorModelProvider", "validatorModelId",
"planningModelProvider", "planningModelId", "mergerModelProvider", "mergerModelId",
"modelPresetId", "modelProvider", "credentialInstanceId", "modelId",
"validatorModelProvider", "validatorCredentialInstanceId", "validatorModelId",
"planningModelProvider", "planningCredentialInstanceId", "planningModelId", "mergerModelProvider", "mergerCredentialInstanceId", "mergerModelId",
"mergeRetries", "workflowStepRetries", "stuckKillCount", "resumeLimboCount", "executeRequeueLoopCount", "graphResumeRetryCount", "consecutiveToolFailureRetryCount", "executorEscalationAttempted", "toolFailureDetectorLogCursor", "toolFailureRetryExhaustedAuditEmitted", "resumeLimboTipSha", "resumeLimboStepSignature", "executeRequeueLoopSignature", "postReviewFixCount", "planReviewReplanCount", "recoveryRetryCount", "taskDoneRetryCount", "bulkCompletionRefusalAt", "worktreeSessionRetryCount", "completionHandoffLimboRecoveryCount", "verificationFailureCount", "mergeConflictBounceCount", "mergeAuditBounceCount", "mergeTransientRetryCount", "branchConflictRecoveryCount", "reviewerContextRetryCount", "reviewerFallbackRetryCount", "nextRecoveryAt",
// FNXC:WorkflowIrPin 2026-07-19-03:10 (U9b / KTD-3 + KTD-8): this projection is a SECOND
// copy of the slim column list (see getTaskSelectClauseImpl2). The IR pin, its node entry,

View File

@@ -34,9 +34,9 @@ export function getTaskSelectClauseImpl2(store: TaskStore, slim: boolean, tableA
return [
"id", "lineageId", "title", "description", "priority", "\"column\"", "status", "size", "reviewLevel", "currentStep",
"worktree", "blockedBy", "overlapBlockedBy", "paused", "pausedReason", "wedgeNotification", "userPaused", "baseBranch", "branch", "autoMerge", "autoMergeProvenance", "executionStartBranch", "baseCommitSha",
"modelPresetId", "modelProvider", "modelId",
"validatorModelProvider", "validatorModelId",
"planningModelProvider", "planningModelId", "mergerModelProvider", "mergerModelId",
"modelPresetId", "modelProvider", "credentialInstanceId", "modelId",
"validatorModelProvider", "validatorCredentialInstanceId", "validatorModelId",
"planningModelProvider", "planningCredentialInstanceId", "planningModelId", "mergerModelProvider", "mergerCredentialInstanceId", "mergerModelId",
"mergeRetries", "workflowStepRetries", "stuckKillCount", "resumeLimboCount", "executeRequeueLoopCount", "graphResumeRetryCount", "consecutiveToolFailureRetryCount", "executorEscalationAttempted", "toolFailureDetectorLogCursor", "toolFailureRetryExhaustedAuditEmitted", "resumeLimboTipSha", "resumeLimboStepSignature", "executeRequeueLoopSignature", "postReviewFixCount", "planReviewReplanCount", "recoveryRetryCount", "taskDoneRetryCount", "bulkCompletionRefusalAt", "worktreeSessionRetryCount", "completionHandoffLimboRecoveryCount", "verificationFailureCount", "mergeConflictBounceCount", "mergeAuditBounceCount", "mergeTransientRetryCount", "branchConflictRecoveryCount", "reviewerContextRetryCount", "reviewerFallbackRetryCount", "nextRecoveryAt",
"error", "summary", "thinkingLevel", "validatorThinkingLevel", "planningThinkingLevel", "mergerThinkingLevel", "executionMode",
"tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenUsageModelProvider", "tokenUsageModelId", "tokenUsagePerModel", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride",

View File

@@ -27,8 +27,15 @@ import {__setTaskActivityLogLimitsForTesting, isBootstrapPromptStub, rewriteHead
import {applyOriginalDescription} from "../original-description-policy.js";
import {normalizeTaskReviewState} from "../task-store/review-state.js";
import {hasOwnDeclaredSymbols, normalizeDeclaredSymbols, extractDeclaredSymbolsFromPrompt, resolveTaskSymbolsForTask} from "../task-symbol-resolution.js";
import {assertValidProviderInstanceId} from "../provider-instance.js";
export async function updateTaskUnlockedImpl(store: TaskStore, id: string, updates: Parameters<TaskStore["updateTask"]>[1], runContext?: RunMutationContext,): Promise<Task> {
/* FNXC:CredentialInstanceSelection 2026-08-01-05:43: validate task authoring input before persistence; ids are stored but runtime credential resolution remains unchanged. */
for (const key of ["credentialInstanceId", "validatorCredentialInstanceId", "planningCredentialInstanceId", "mergerCredentialInstanceId"] as const) {
const value = (updates as Record<string, unknown>)[key];
if (value !== undefined && value !== null) assertValidProviderInstanceId(value);
}
{
if (updates.dependencies !== undefined) {
await store.assertNoDependencyCycle(
@@ -643,6 +650,11 @@ export async function updateTaskUnlockedImpl(store: TaskStore, id: string, updat
} else if (updates.modelProvider !== undefined) {
task.modelProvider = updates.modelProvider;
}
if (updates.credentialInstanceId === null) {
task.credentialInstanceId = undefined;
} else if (updates.credentialInstanceId !== undefined) {
task.credentialInstanceId = updates.credentialInstanceId;
}
if (updates.modelId === null) {
task.modelId = undefined;
} else if (updates.modelId !== undefined) {
@@ -653,6 +665,11 @@ export async function updateTaskUnlockedImpl(store: TaskStore, id: string, updat
} else if (updates.validatorModelProvider !== undefined) {
task.validatorModelProvider = updates.validatorModelProvider;
}
if (updates.validatorCredentialInstanceId === null) {
task.validatorCredentialInstanceId = undefined;
} else if (updates.validatorCredentialInstanceId !== undefined) {
task.validatorCredentialInstanceId = updates.validatorCredentialInstanceId;
}
if (updates.validatorModelId === null) {
task.validatorModelId = undefined;
} else if (updates.validatorModelId !== undefined) {
@@ -663,6 +680,11 @@ export async function updateTaskUnlockedImpl(store: TaskStore, id: string, updat
} else if (updates.planningModelProvider !== undefined) {
task.planningModelProvider = updates.planningModelProvider;
}
if (updates.planningCredentialInstanceId === null) {
task.planningCredentialInstanceId = undefined;
} else if (updates.planningCredentialInstanceId !== undefined) {
task.planningCredentialInstanceId = updates.planningCredentialInstanceId;
}
if (updates.planningModelId === null) {
task.planningModelId = undefined;
} else if (updates.planningModelId !== undefined) {
@@ -670,6 +692,8 @@ export async function updateTaskUnlockedImpl(store: TaskStore, id: string, updat
}
if (updates.mergerModelProvider === null) task.mergerModelProvider = undefined;
else if (updates.mergerModelProvider !== undefined) task.mergerModelProvider = updates.mergerModelProvider;
if (updates.mergerCredentialInstanceId === null) task.mergerCredentialInstanceId = undefined;
else if (updates.mergerCredentialInstanceId !== undefined) task.mergerCredentialInstanceId = updates.mergerCredentialInstanceId;
if (updates.mergerModelId === null) task.mergerModelId = undefined;
else if (updates.mergerModelId !== undefined) task.mergerModelId = updates.mergerModelId;
if (updates.validatorThinkingLevel === null) {

View File

@@ -104,11 +104,14 @@ export interface ArchivedTaskEntry {
deletedAt?: string;
/** Timestamp when the task was archived to the log */
archivedAt: string;
/** FNXC:CredentialInstanceSelection 2026-08-01-05:43: archive entries preserve persisted-but-inert credential-instance companions for every task model pair. */
/** Optional: model preset and override fields for executor and validator */
modelPresetId?: string;
modelProvider?: string;
credentialInstanceId?: string;
modelId?: string;
validatorModelProvider?: string;
validatorCredentialInstanceId?: string;
validatorModelId?: string;
/**
* Optional provider/model override for the planning session — the same provider/model pair
@@ -121,8 +124,10 @@ export interface ArchivedTaskEntry {
* codebase — this field has never had anything to do with a column.
*/
planningModelProvider?: string;
planningCredentialInstanceId?: string;
planningModelId?: string;
mergerModelProvider?: string;
mergerCredentialInstanceId?: string;
mergerModelId?: string;
mergerThinkingLevel?: ThinkingLevel;
/** Per-task token/cost accounting (input/output/cache) preserved across archival. */

View File

@@ -361,6 +361,8 @@ export interface GlobalSettings {
* Must be set together with `defaultModelId`. When both are undefined,
* the engine uses pi's automatic model resolution. */
defaultProvider?: string;
/** Optional credential instance for `defaultProvider`. Persisted but inert until runtime credential resolution. */
defaultCredentialInstanceId?: string;
/** Default AI model ID within the provider (e.g. `"claude-sonnet-4-5"`).
* Must be set together with `defaultProvider`. When both are undefined,
* the engine uses pi's automatic model resolution. */
@@ -406,6 +408,8 @@ export interface GlobalSettings {
* transient provider-side issues such as rate limits or overloaded capacity.
* Must be set together with `fallbackModelId`. */
fallbackProvider?: string;
/** Optional credential instance for `fallbackProvider`. Persisted but inert until runtime credential resolution. */
fallbackCredentialInstanceId?: string;
/** Fallback AI model ID used with `fallbackProvider` when the primary default
* model fails due to transient provider-side issues such as rate limits or
* overloaded capacity. Must be set together with `fallbackProvider`. */
@@ -683,6 +687,8 @@ export interface GlobalSettings {
* Must be set together with `executionGlobalModelId`. Falls back to
* `defaultProvider`/`defaultModelId` when undefined. */
executionGlobalProvider?: string;
/** Optional credential instance for `executionGlobalProvider`. Persisted but inert until runtime credential resolution. */
executionGlobalCredentialInstanceId?: string;
/** Global baseline AI model ID for task execution.
* Must be set together with `executionGlobalProvider`. */
executionGlobalModelId?: string;
@@ -691,6 +697,8 @@ export interface GlobalSettings {
* Must be set together with `planningGlobalModelId`. Falls back to
* `defaultProvider`/`defaultModelId` when undefined. */
planningGlobalProvider?: string;
/** Optional credential instance for `planningGlobalProvider`. Persisted but inert until runtime credential resolution. */
planningGlobalCredentialInstanceId?: string;
/** Global baseline AI model ID for planning/triage.
* Must be set together with `planningGlobalProvider`. */
planningGlobalModelId?: string;
@@ -699,6 +707,8 @@ export interface GlobalSettings {
* Must be set together with `validatorGlobalModelId`. Falls back to
* `defaultProvider`/`defaultModelId` when undefined. */
validatorGlobalProvider?: string;
/** Optional credential instance for `validatorGlobalProvider`. Persisted but inert until runtime credential resolution. */
validatorGlobalCredentialInstanceId?: string;
/** Global baseline AI model ID for validator/reviewer.
* Must be set together with `validatorGlobalProvider`. */
validatorGlobalModelId?: string;
@@ -707,6 +717,8 @@ export interface GlobalSettings {
* Must be set together with `titleSummarizerGlobalModelId`. Falls back to
* `defaultProvider`/`defaultModelId` when undefined. */
titleSummarizerGlobalProvider?: string;
/** Optional credential instance for `titleSummarizerGlobalProvider`. Persisted but inert until runtime credential resolution. */
titleSummarizerGlobalCredentialInstanceId?: string;
/** Global baseline AI model ID for title summarization.
* Must be set together with `titleSummarizerGlobalProvider`. */
titleSummarizerGlobalModelId?: string;
@@ -718,6 +730,8 @@ export interface GlobalSettings {
* Must be set together with `mergerGlobalModelId`. Falls back to
* `defaultProvider`/`defaultModelId` when undefined. */
mergerGlobalProvider?: string;
/** Optional credential instance for `mergerGlobalProvider`. Persisted but inert until runtime credential resolution. */
mergerGlobalCredentialInstanceId?: string;
/** Global baseline AI model ID for merger agent sessions.
* Must be set together with `mergerGlobalProvider`. */
mergerGlobalModelId?: string;
@@ -729,6 +743,8 @@ export interface GlobalSettings {
* Must be set together with `importTranslateGlobalModelId`. Falls back to the
* summarization lane, then `defaultProvider`/`defaultModelId`. */
importTranslateGlobalProvider?: string;
/** Optional credential instance for `importTranslateGlobalProvider`. Persisted but inert until runtime credential resolution. */
importTranslateGlobalCredentialInstanceId?: string;
/** Global baseline AI model ID for import auto-translation.
* Must be set together with `importTranslateGlobalProvider`. */
importTranslateGlobalModelId?: string;
@@ -1452,6 +1468,8 @@ export interface ProjectSettings {
* Must be set together with `planningModelId`. When both are undefined,
* falls back to `defaultProvider`/`defaultModelId`. */
planningProvider?: string;
/** Optional credential instance for `planningProvider`. Persisted but inert until runtime credential resolution. */
planningCredentialInstanceId?: string;
/** AI model ID for planning/triage (specification) agent.
* Must be set together with `planningProvider`. When both are undefined,
* falls back to `defaultProvider`/`defaultModelId`. */
@@ -1459,6 +1477,8 @@ export interface ProjectSettings {
/** Fallback model provider for planning/triage. When unset, falls back to the
* global fallback model. Must be set together with `planningFallbackModelId`. */
planningFallbackProvider?: string;
/** Optional credential instance for `planningFallbackProvider`. Persisted but inert until runtime credential resolution. */
planningFallbackCredentialInstanceId?: string;
/** Fallback model ID for planning/triage. When unset, falls back to the
* global fallback model. Must be set together with `planningFallbackProvider`. */
planningFallbackModelId?: string;
@@ -1469,6 +1489,8 @@ export interface ProjectSettings {
* for all lanes that don't have their own explicit project override.
* Must be set together with `defaultModelIdOverride`. */
defaultProviderOverride?: string;
/** Optional credential instance for `defaultProviderOverride`; persisted but inert until runtime resolution. */
defaultCredentialInstanceIdOverride?: string;
/** Project-level override for the base default AI model ID.
* Must be set together with `defaultProviderOverride`. */
defaultModelIdOverride?: string;
@@ -1500,6 +1522,8 @@ export interface ProjectSettings {
* `defaultProviderOverride`/`defaultModelIdOverride` or
* `defaultProvider`/`defaultModelId` when undefined. */
executionProvider?: string;
/** Optional credential instance for `executionProvider`. Persisted but inert until runtime credential resolution. */
executionCredentialInstanceId?: string;
/** Project-level AI model ID for task execution.
* Must be set together with `executionProvider`. */
executionModelId?: string;
@@ -1512,6 +1536,8 @@ export interface ProjectSettings {
*/
/** Workflow fallback provider for executor sessions. Must pair with `executionFallbackModelId`; resolves before the shared global fallback pair. */
executionFallbackProvider?: string;
/** Optional credential instance for `executionFallbackProvider`. Persisted but inert until runtime credential resolution. */
executionFallbackCredentialInstanceId?: string;
/** Workflow fallback model ID for executor sessions. Must pair with `executionFallbackProvider`; resolves before the shared global fallback pair. */
executionFallbackModelId?: string;
/** Workflow executor-fallback thinking override. Inherits shared fallback thinking, then executor primary thinking. */
@@ -1522,6 +1548,8 @@ export interface ProjectSettings {
* Must be set together with `validatorModelId`. When both are undefined,
* falls back to `defaultProvider`/`defaultModelId`. */
validatorProvider?: string;
/** Optional credential instance for `validatorProvider`. Persisted but inert until runtime credential resolution. */
validatorCredentialInstanceId?: string;
/** AI model ID for validator/reviewer agent.
* Must be set together with `validatorProvider`. When both are undefined,
* falls back to `defaultProvider`/`defaultModelId`. */
@@ -1530,6 +1558,8 @@ export interface ProjectSettings {
* the global fallback model. Must be set together with
* `validatorFallbackModelId`. */
validatorFallbackProvider?: string;
/** Optional credential instance for `validatorFallbackProvider`. Persisted but inert until runtime credential resolution. */
validatorFallbackCredentialInstanceId?: string;
/** Fallback model ID for validator/reviewer. When unset, falls back to the
* global fallback model. Must be set together with `validatorFallbackProvider`. */
validatorFallbackModelId?: string;
@@ -2068,6 +2098,8 @@ export interface ProjectSettings {
* Must be set together with `titleSummarizerModelId`. Falls back to planningProvider,
* then defaultProvider if not specified. */
titleSummarizerProvider?: string;
/** Optional credential instance for `titleSummarizerProvider`. Persisted but inert until runtime credential resolution. */
titleSummarizerCredentialInstanceId?: string;
/** AI model ID for title summarization (when autoSummarizeTitles is enabled).
* Must be set together with `titleSummarizerProvider`. Falls back to planningModelId,
* then defaultModelId if not specified. */
@@ -2082,6 +2114,8 @@ export interface ProjectSettings {
* Must be set together with `mergerModelId`. Falls back to
* `mergerGlobalProvider`/`mergerGlobalModelId`, then project/global default. */
mergerProvider?: string;
/** Optional credential instance for `mergerProvider`. Persisted but inert until runtime credential resolution. */
mergerCredentialInstanceId?: string;
/** Project AI model ID for merger agent sessions.
* Must be set together with `mergerProvider`. */
mergerModelId?: string;
@@ -2095,6 +2129,8 @@ export interface ProjectSettings {
* Must be set together with `mergerFallbackModelId`. Resolves before the global
* `fallbackProvider`/`fallbackModelId` pair. */
mergerFallbackProvider?: string;
/** Optional credential instance for `mergerFallbackProvider`. Persisted but inert until runtime credential resolution. */
mergerFallbackCredentialInstanceId?: string;
/** Project fallback AI model ID for merger agent sessions.
* Must be set together with `mergerFallbackProvider`. */
mergerFallbackModelId?: string;
@@ -2110,6 +2146,8 @@ export interface ProjectSettings {
* `importTranslateGlobalProvider`/`importTranslateGlobalModelId`, then the
* summarization lane, then project/global default. */
importTranslateProvider?: string;
/** Optional credential instance for `importTranslateProvider`. Persisted but inert until runtime credential resolution. */
importTranslateCredentialInstanceId?: string;
/** Project AI model ID for import auto-translation.
* Must be set together with `importTranslateProvider`. */
importTranslateModelId?: string;
@@ -2130,6 +2168,8 @@ export interface ProjectSettings {
* planning fallback, then global fallback. Must be set together with
* `titleSummarizerFallbackModelId`. */
titleSummarizerFallbackProvider?: string;
/** Optional credential instance for `titleSummarizerFallbackProvider`; persisted but inert until runtime credential resolution. */
titleSummarizerFallbackCredentialInstanceId?: string;
/** Fallback model ID for title summarization. When unset, falls back to
* planning fallback, then global fallback. Must be set together with
* `titleSummarizerFallbackProvider`. */

View File

@@ -227,6 +227,8 @@ export interface CentralClaimStore {
export interface TaskTokenUsagePerModel {
/** Provider of the actually-used model for this bucket. */
modelProvider?: string;
/** Optional credential instance for `modelProvider`; persisted but inert until runtime resolution. */
credentialInstanceId?: string;
/** Id of the actually-used model for this bucket. */
modelId?: string;
/** Cumulative prompt/input tokens consumed by this model for the task. */
@@ -272,6 +274,8 @@ export interface TaskTokenUsage {
* Snapshot the provider of the actually-used model for analytics only. This is intentionally distinct from task.modelProvider, which is an own-model override used by model resolution and must not be written by token bookkeeping.
*/
modelProvider?: string;
/** Optional credential instance for `modelProvider`; persisted but inert until runtime resolution. */
credentialInstanceId?: string;
/**
* FNXC:TokenAnalytics 2026-06-18-16:23:
* Snapshot the id of the actually-used model for analytics only. This is intentionally distinct from task.modelId, which is an own-model override used by model resolution and must not be written by token bookkeeping.
@@ -790,10 +794,17 @@ export interface Task {
reviewLevel?: number;
/** Model preset selected during task creation. Presets resolve to concrete model overrides at creation time. */
modelPresetId?: string;
/*
* FNXC:CredentialInstanceSelection 2026-08-01-05:43:
* Task model overrides persist optional credential-instance ids alongside their provider/model
* pairs. This slice stores and resolves the value only; runtime credential use is unchanged.
*/
/** AI model provider override for the executor agent (e.g., "anthropic").
* Must be set together with `modelId`. When both model fields are undefined,
* the executor uses global settings defaults. */
modelProvider?: string;
/** Optional credential instance for `modelProvider`; persisted but inert until runtime resolution. */
credentialInstanceId?: string;
/** AI model ID override for the executor agent (e.g., "claude-sonnet-4-5").
* Must be set together with `modelProvider`. When both model fields are undefined,
* the executor uses global settings defaults. */
@@ -802,6 +813,8 @@ export interface Task {
* Must be set together with `validatorModelId`. When both validator model fields
* are undefined, the reviewer uses global settings defaults. */
validatorModelProvider?: string;
/** Optional credential instance for `validatorModelProvider`; persisted but inert until runtime resolution. */
validatorCredentialInstanceId?: string;
/** AI model ID override for the validator/reviewer agent.
* Must be set together with `validatorModelProvider`. When both validator model
* fields are undefined, the reviewer uses global settings defaults. */
@@ -810,6 +823,8 @@ export interface Task {
* Must be set together with `planningModelId`. When both planning model fields
* are undefined, the triage agent uses global settings defaults. */
planningModelProvider?: string;
/** Optional credential instance for `planningModelProvider`; persisted but inert until runtime resolution. */
planningCredentialInstanceId?: string;
/** AI model ID override for the planning/triage agent.
* Must be set together with `planningModelProvider`. When both planning model
* fields are undefined, the triage agent uses global settings defaults. */
@@ -819,6 +834,8 @@ export interface Task {
* Per-task merger overrides take precedence over the project/global merger lane only when both fields are set; merger sessions otherwise retain their existing settings-based resolution.
*/
mergerModelProvider?: string;
/** Optional credential instance for `mergerModelProvider`; persisted but inert until runtime resolution. */
mergerCredentialInstanceId?: string;
/** Must be set together with `mergerModelProvider`. */
mergerModelId?: string;
/** IDs of workflow steps enabled for this task, run after implementation completes */
@@ -1354,10 +1371,17 @@ export interface TaskCreateInput {
workflowId?: string | null;
/** Model preset selected during task creation. Presets resolve to concrete model overrides at creation time. */
modelPresetId?: string;
/*
* FNXC:CredentialInstanceSelection 2026-08-01-05:43:
* Task model overrides persist optional credential-instance ids alongside their provider/model
* pairs. This slice stores and resolves the value only; runtime credential use is unchanged.
*/
/** AI model provider override for the executor agent (e.g., "anthropic").
* Must be set together with `modelId`. When both model fields are undefined,
* the executor uses global settings defaults. */
modelProvider?: string;
/** Optional credential instance for `modelProvider`; persisted but inert until runtime resolution. */
credentialInstanceId?: string;
/** AI model ID override for the executor agent (e.g., "claude-sonnet-4-5").
* Must be set together with `modelProvider`. When both model fields are undefined,
* the executor uses global settings defaults. */
@@ -1366,6 +1390,8 @@ export interface TaskCreateInput {
* Must be set together with `validatorModelId`. When both validator model fields
* are undefined, the reviewer uses global settings defaults. */
validatorModelProvider?: string;
/** Optional credential instance for `validatorModelProvider`; persisted but inert until runtime resolution. */
validatorCredentialInstanceId?: string;
/** AI model ID override for the validator/reviewer agent.
* Must be set together with `validatorModelProvider`. When both validator model
* fields are undefined, the reviewer uses global settings defaults. */
@@ -1374,12 +1400,16 @@ export interface TaskCreateInput {
* Must be set together with `planningModelId`. When both planning model fields
* are undefined, the triage agent uses global settings defaults. */
planningModelProvider?: string;
/** Optional credential instance for `planningModelProvider`; persisted but inert until runtime resolution. */
planningCredentialInstanceId?: string;
/** AI model ID override for the planning/triage agent.
* Must be set together with `planningModelProvider`. When both planning model
* fields are undefined, the triage agent uses global settings defaults. */
planningModelId?: string;
/** Per-task merger override; provider and model id must be supplied together. */
mergerModelProvider?: string;
/** Optional credential instance for `mergerModelProvider`; persisted but inert until runtime resolution. */
mergerCredentialInstanceId?: string;
mergerModelId?: string;
/** Thinking level for AI agent sessions — controls reasoning effort (off/minimal/low/medium/high) */
thinkingLevel?: ThinkingLevel;

View File

@@ -7,13 +7,22 @@
import type { ThinkingLevel } from "./board.js";
/*
* FNXC:CredentialInstanceSelection 2026-08-01-05:38:
* Presets retain optional credential instances beside their model pairs. This data slice does
* not consume them at runtime, preserving existing selection behavior.
*/
export interface ModelPreset {
id: string;
name: string;
executorProvider?: string;
executorModelId?: string;
/** Optional credential instance for the executor pair; persisted but inert until runtime resolution. */
executorCredentialInstanceId?: string;
validatorProvider?: string;
validatorModelId?: string;
/** Optional credential instance for the validator pair; persisted but inert until runtime resolution. */
validatorCredentialInstanceId?: string;
}
/** A reusable workflow step definition that can run after task implementation. */

View File

@@ -19,6 +19,7 @@ import { getWorkflowExtensionRegistry } from "./workflow-extension-registry.js";
import type { WorkflowExtensionConfigField } from "./workflow-extension-types.js";
import { THINKING_LEVELS } from "./types.js";
import { resolveColumnFlags } from "./trait-registry.js";
import { isValidProviderInstanceId } from "./provider-instance.js";
// Side-effect import: registers the built-in traits so `resolveColumnFlags`
// resolves the built-in `merge-blocker`/`intake` flags during save-time
// validation (U2). Custom/plugin traits that set the same flags resolve too.
@@ -940,6 +941,22 @@ function validateStepReviewRouting(
}
}
/*
* FNXC:CredentialInstanceSelection 2026-08-01-05:43:
* Workflow model overrides may persist an optional credential-instance id but this data-only slice
* never consumes it at runtime. Validate authoring input recursively before the graph can persist.
*/
function validateCredentialInstanceIdConfig(nodes: WorkflowIrNode[]): void {
for (const node of nodes) {
const value = node.config?.credentialInstanceId;
if (value !== undefined && (typeof value !== "string" || !isValidProviderInstanceId(value))) {
throw new WorkflowIrError(`Workflow node '${node.id}' credentialInstanceId must be a valid string when present`);
}
const templateNodes = (node.config as { template?: { nodes?: unknown } } | undefined)?.template?.nodes;
if (Array.isArray(templateNodes)) validateCredentialInstanceIdConfig(templateNodes as WorkflowIrNode[]);
}
}
function validateThinkingLevelConfig(nodes: WorkflowIrNode[]): void {
for (const node of nodes) {
const value = node.config?.thinkingLevel;
@@ -1701,6 +1718,7 @@ function validateV2(ir: WorkflowIrV2): void {
const topLevelIds = new Set(ir.nodes.map((n) => n.id));
validateStepExecutePlacement(ir.nodes);
validateThinkingLevelConfig(ir.nodes);
validateCredentialInstanceIdConfig(ir.nodes);
for (const node of ir.nodes) {
if (node.kind === "foreach") validateForeach(node, topLevelIds, columnIds);
if (node.kind === "loop") validateLoop(node, topLevelIds, columnIds);

View File

@@ -29,6 +29,7 @@
import type {
WorkflowSettingDefinition,
} from "./workflow-ir-types.js";
import {isValidProviderInstanceId} from "./provider-instance.js";
// ---------------------------------------------------------------------------
// Typed rejection (TransitionRejection-style: flat, JSON-safe, no class)
@@ -252,6 +253,15 @@ export function validateSettingValuePatch(
);
continue;
}
/*
* FNXC:CredentialInstanceSelection 2026-08-01-05:38:
* Workflow-declared credential companions are persisted but inert in this slice; reject malformed
* ids at this write boundary so resolution never needs runtime validation.
*/
if (key.endsWith("CredentialInstanceId") && !isValidProviderInstanceId(value)) {
rejections.push(makeWorkflowSettingRejection("type-mismatch", key, `setting '${key}' must be a valid credential instance id`));
continue;
}
const res = validateValue(setting, value);
if (!res.ok) {
rejections.push(res.rejection);