diff --git a/.changeset/polite-penguins-preserve-claims.md b/.changeset/polite-penguins-preserve-claims.md new file mode 100644 index 0000000000..c73f4494cf --- /dev/null +++ b/.changeset/polite-penguins-preserve-claims.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Keep recommendation-created tasks recoverable across custom and legacy archive lanes. +category: fix +dev: Treats undeclared legacy archive IDs as tombstones and re-homes active rows without cold-storage restore. diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx index 3ffe5fb58f..2e14a68200 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx @@ -2256,7 +2256,18 @@ describe("TaskDetailModal", () => { const mockDetail = makeTask({ id: "FN-001", description: "Dep 1" }); const onOpenDetail = vi.fn(); - mockFetch.mockResolvedValue(mockDetail); + /* + FNXC:TaskDetailDependencies 2026-08-08-12:32: + A TaskDetail prop refreshes Definition through fetchTaskPrompt. The full-detail client is + reserved for the clicked dependency, so fail this fixture on any other request. + */ + mockFetch.mockReset(); + mockFetch.mockImplementation(async (id: string, requestedProjectId?: string) => { + if (id !== "FN-001" || requestedProjectId !== projectId) { + throw new Error(`Unexpected dependency detail request: ${id}`); + } + return mockDetail; + }); const { baseElement: container } = render( { const onOpenDetail = vi.fn(); const addToast = vi.fn(); - mockFetch.mockRejectedValueOnce(new Error("Task not found")); + /* + FNXC:TaskDetailDependencies 2026-08-08-12:32: + Definition refresh uses fetchTaskPrompt. Reject the expected dependency request and fail this + fixture distinctly if another full-detail request appears. + */ + mockFetch.mockReset(); + mockFetch.mockImplementation(async (id: string, requestedProjectId?: string) => { + if (id !== "FN-001" || requestedProjectId !== projectId) { + throw new Error(`Unexpected dependency detail request: ${id}`); + } + throw new Error("Task not found"); + }); const { baseElement: container } = render( { const blockingDetail = makeTask({ id: "FN-100", description: "Blocking dependent" }); const onOpenDetail = vi.fn(); - mockFetch.mockImplementation(async (id) => id === "FN-001" ? upstreamDetail : blockingDetail); + mockFetch.mockReset(); + mockFetch.mockImplementation(async (id: string, requestedProjectId?: string) => { + if (requestedProjectId !== projectId) { + throw new Error(`Unexpected dependency project: ${requestedProjectId ?? ""}`); + } + if (id === "FN-001") return upstreamDetail; + if (id === "FN-100") return blockingDetail; + throw new Error(`Unexpected dependency detail request: ${id}`); + }); const { baseElement: container } = render( { - it("returns the renamed complete AND archived lanes, and not the legacy ids", async () => { + it("returns the renamed complete and archived lanes plus the undeclared legacy archive tombstone", async () => { const landed = await landedColumnsForTask(storeWith(RENAMED_IR), "FN-1"); - expect([...landed].sort()).toEqual(["attic", "shipped"]); + expect([...landed].sort()).toEqual(["archived", "attic", "shipped"]); /* The archived half is asserted explicitly: the two roles resolve independently and have failed independently before, so a fixture that only proved `complete` would miss half the guard. @@ -168,6 +168,13 @@ describe("the lane resolvers themselves, not just their callers", () => { ["preWipColumnsForTask", preWipColumnsForTask, "backlog", "todo"], ]; + it("archivedColumnsForTask keeps an undeclared legacy archive tombstone beside traited lanes", async () => { + expect([...(await archivedColumnsForTask(storeFor(RENAMED_IR), "FN-1"))].sort()).toEqual([ + "archived", + "attic", + ]); + }); + for (const [name, resolve, renamed, legacy] of cases) { it(`${name} returns the traited lane and EXCLUDES the untraited legacy name`, async () => { const lanes = await resolve(storeFor(TRAITED_WITH_LEGACY_NAMES), "FN-1"); diff --git a/packages/dashboard/src/routes/__tests__/task-recommendation-routes.test.ts b/packages/dashboard/src/routes/__tests__/task-recommendation-routes.test.ts index 87e91ac817..27d8115bcc 100644 --- a/packages/dashboard/src/routes/__tests__/task-recommendation-routes.test.ts +++ b/packages/dashboard/src/routes/__tests__/task-recommendation-routes.test.ts @@ -135,6 +135,78 @@ function parent(overrides: Partial = {}): Task { }); } +/* +FNXC:TaskRecommendations 2026-08-08-12:49: +Custom-workflow fixtures distinguish the traited `boxed` archived lane from an explicitly declared, +untraited `archived` live lane while retaining undeclared legacy tombstone coverage. +*/ +function installCustomRecommendationWorkflow( + store: Partial, + taskIds: readonly string[], + options: { declareLegacyArchivedAsLive?: boolean } = {}, +): void { + Object.assign(store, { + getTaskWorkflowSelection: vi.fn((id: string) => taskIds.includes(id) ? { workflowId: "recommendation-workflow", stepIds: [] } : undefined), + getTaskWorkflowSelectionAsync: vi.fn(async (id: string) => taskIds.includes(id) ? { workflowId: "recommendation-workflow", stepIds: [] } : undefined), + getWorkflowDefinition: vi.fn(async () => ({ + id: "recommendation-workflow", + name: "Recommendation workflow", + kind: "workflow", + ir: { + version: "v2", + id: "recommendation-workflow", + name: "Recommendation workflow", + nodes: [{ id: "start", kind: "start", column: "backlog" }], + edges: [], + columns: [ + { id: "backlog", name: "Backlog", traits: [{ trait: "intake" }] }, + { id: "queued", name: "Queued", traits: [{ trait: "hold" }] }, + { id: "building", name: "Building", traits: [{ trait: "wip" }] }, + { id: "shipped", name: "Shipped", traits: [{ trait: "complete" }] }, + ...(options.declareLegacyArchivedAsLive + ? [{ id: "archived", name: "Live archived", traits: [] }] + : []), + { id: "boxed", name: "Boxed", traits: [{ trait: "archived" }] }, + ], + }, + })), + }); +} + +/* +FNXC:TaskRecommendations 2026-08-09-06:06: +Real v1 workflow fixtures must pass through the production read-path upgrade so synthesized default +columns keep legacy `archived` tombstone semantics instead of looking like an explicitly live lane. +*/ +function installLegacyV1RecommendationWorkflow( + store: Partial, + taskIds: readonly string[], +): void { + Object.assign(store, { + getTaskWorkflowSelection: vi.fn((id: string) => taskIds.includes(id) ? { workflowId: "legacy-recommendation-workflow", stepIds: [] } : undefined), + getTaskWorkflowSelectionAsync: vi.fn(async (id: string) => taskIds.includes(id) ? { workflowId: "legacy-recommendation-workflow", stepIds: [] } : undefined), + getWorkflowDefinition: vi.fn(async () => ({ + id: "legacy-recommendation-workflow", + name: "Legacy recommendation workflow", + kind: "workflow", + ir: JSON.stringify({ + version: "v1", + name: "legacy-recommendation-workflow", + nodes: [ + { id: "start", kind: "start" }, + { id: "execute", kind: "prompt", config: { seam: "execute", prompt: "Do the work" } }, + { id: "end", kind: "end" }, + ], + edges: [ + { from: "start", to: "execute", condition: "success" }, + { from: "execute", to: "end", condition: "success" }, + { from: "execute", to: "end", condition: "failure" }, + ], + }), + })), + }); +} + describe("recommendation task creation route", () => { beforeEach(() => locks?.clear()); afterEach(() => { locks?.clear(); vi.restoreAllMocks(); }); @@ -318,7 +390,8 @@ describe("recommendation task creation route", () => { expect(recovered.status).toBe(200); expect(store.createTask).toHaveBeenCalledTimes(1); - expect(store.unarchiveTask).toHaveBeenCalledWith("FN-102"); + expect(store.unarchiveTask).not.toHaveBeenCalled(); + expect(store.moveTask).toHaveBeenLastCalledWith("FN-102", "todo", { recoveryRehome: true }); expect(tasks[0]?.recommendations?.[0]?.createdTaskId).toBe("FN-102"); }); @@ -328,29 +401,7 @@ describe("recommendation task creation route", () => { column: "todo", createdAt: "2026-01-01T00:00:00.000Z", }); const { app, store, tasks } = buildApp([parent(), canonical]); - Object.assign(store, { - getTaskWorkflowSelection: vi.fn((id: string) => id === "FN-102" ? { workflowId: "recommendation-workflow", stepIds: [] } : undefined), - getTaskWorkflowSelectionAsync: vi.fn(async (id: string) => id === "FN-102" ? { workflowId: "recommendation-workflow", stepIds: [] } : undefined), - getWorkflowDefinition: vi.fn(async () => ({ - id: "recommendation-workflow", - name: "Recommendation workflow", - kind: "workflow", - ir: { - version: "v2", - id: "recommendation-workflow", - name: "Recommendation workflow", - nodes: [{ id: "start", kind: "start", column: "backlog" }], - edges: [], - columns: [ - { id: "backlog", name: "Backlog", traits: [{ trait: "intake" }] }, - { id: "queued", name: "Queued", traits: [{ trait: "hold" }] }, - { id: "building", name: "Building", traits: [{ trait: "wip" }] }, - { id: "shipped", name: "Shipped", traits: [{ trait: "complete" }] }, - { id: "boxed", name: "Boxed", traits: [{ trait: "archived" }] }, - ], - }, - })), - }); + installCustomRecommendationWorkflow(store, ["FN-102"]); (store.findRecentTasksByContentFingerprint as ReturnType) .mockResolvedValueOnce([]) .mockResolvedValueOnce([canonical]); @@ -363,7 +414,8 @@ describe("recommendation task creation route", () => { const recovered = await performRequest(app, "POST", "/api/tasks/FN-1/recommendations/rec-1/create", undefined); expect(recovered.status).toBe(200); - expect(store.moveTask).toHaveBeenLastCalledWith("FN-102", "backlog"); + expect(store.unarchiveTask).not.toHaveBeenCalled(); + expect(store.moveTask).toHaveBeenLastCalledWith("FN-102", "backlog", { recoveryRehome: true }); expect(tasks.find((item) => item.id === "FN-102")?.column).toBe("backlog"); }); @@ -454,6 +506,232 @@ describe("recommendation task creation route", () => { expect(archived.store.createTask).not.toHaveBeenCalled(); }); + it.each(["boxed", "archived"] as const)( + "rejects a linked child in custom workflow archived state %s", + async (archivedColumn) => { + const archivedChild = task({ id: "FN-9", description: "Archived child", column: archivedColumn as Column }); + const custom = buildApp([ + parent({ recommendations: [{ ...parent().recommendations![0], createdTaskId: "FN-9" }] }), + archivedChild, + ]); + installCustomRecommendationWorkflow(custom.store, ["FN-9"]); + + const response = await performRequest( + custom.app, + "POST", + "/api/tasks/FN-1/recommendations/rec-1/create", + undefined, + ); + + expect(response.status).toBe(409); + expect(custom.store.createTask).not.toHaveBeenCalled(); + }, + ); + + it("keeps a linked child live in an explicitly declared untraited archived column", async () => { + const linkedChild = task({ id: "FN-9", description: "Live child", column: "archived" }); + const custom = buildApp([ + parent({ recommendations: [{ ...parent().recommendations![0], createdTaskId: "FN-9" }] }), + linkedChild, + ]); + installCustomRecommendationWorkflow(custom.store, ["FN-9"], { declareLegacyArchivedAsLive: true }); + + const response = await performRequest( + custom.app, + "POST", + "/api/tasks/FN-1/recommendations/rec-1/create", + undefined, + ); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ task: { id: "FN-9", column: "archived" } }); + expect(custom.store.createTask).not.toHaveBeenCalled(); + }); + + it("treats a linked child in synthesized v1 archived state as unavailable", async () => { + const archivedChild = task({ id: "FN-9", description: "Legacy archived child", column: "archived" }); + const legacy = buildApp([ + parent({ recommendations: [{ ...parent().recommendations![0], createdTaskId: "FN-9" }] }), + archivedChild, + ]); + installLegacyV1RecommendationWorkflow(legacy.store, ["FN-9"]); + + const response = await performRequest( + legacy.app, + "POST", + "/api/tasks/FN-1/recommendations/rec-1/create", + undefined, + ); + + expect(response.status).toBe(409); + expect(legacy.store.createTask).not.toHaveBeenCalled(); + }); + + it("rejects an unavailable proposal claim in a custom workflow archived lane", async () => { + const unavailableClaim = task({ + id: "FN-9", + description: "Archived recommendation claim", + column: "boxed" as Column, + proposalClaimId: "recommendation:FN-1:rec-1", + }); + const custom = buildApp([parent(), unavailableClaim]); + installCustomRecommendationWorkflow(custom.store, ["FN-9"]); + + const response = await performRequest( + custom.app, + "POST", + "/api/tasks/FN-1/recommendations/rec-1/create", + undefined, + ); + + expect(response.status).toBe(409); + expect(custom.store.createTask).not.toHaveBeenCalled(); + expect(custom.store.linkTaskRecommendation).not.toHaveBeenCalled(); + expect(custom.tasks[0]?.recommendations?.[0]?.createdTaskId).toBeUndefined(); + }); + + it("repairs a proposal claim from an explicitly declared untraited archived column", async () => { + const liveClaim = task({ + id: "FN-9", + description: "Live recommendation claim", + column: "archived", + proposalClaimId: "recommendation:FN-1:rec-1", + }); + const custom = buildApp([parent(), liveClaim]); + installCustomRecommendationWorkflow(custom.store, ["FN-9"], { declareLegacyArchivedAsLive: true }); + + const response = await performRequest( + custom.app, + "POST", + "/api/tasks/FN-1/recommendations/rec-1/create", + undefined, + ); + + expect(response.status).toBe(200); + expect(custom.store.createTask).not.toHaveBeenCalled(); + expect(custom.store.linkTaskRecommendation).toHaveBeenCalledWith( + "FN-1", + "rec-1", + "FN-9", + expect.any(Set), + ); + expect(custom.tasks[0]?.recommendations?.[0]?.createdTaskId).toBe("FN-9"); + }); + + it("recovers a deterministic proposal claim from the legacy archived compatibility lane", async () => { + const canonical = task({ + id: "FN-9", + title: "Canonical export task", + description: "Canonical export work", + column: "done", + }); + const recoverableClaim = task({ + id: "FN-10", + description: "Archived recommendation claim", + column: "archived", + proposalClaimId: "recommendation:FN-1:rec-1", + sourceMetadata: { deterministicDuplicateOf: "FN-9" }, + }); + const custom = buildApp([parent(), canonical, recoverableClaim]); + installCustomRecommendationWorkflow(custom.store, ["FN-10"]); + (custom.store.searchTasks as ReturnType).mockResolvedValue([]); + + const response = await performRequest( + custom.app, + "POST", + "/api/tasks/FN-1/recommendations/rec-1/create", + undefined, + ); + + expect(response.status).toBe(200); + expect(custom.store.createTask).not.toHaveBeenCalled(); + expect(custom.store.unarchiveTask).not.toHaveBeenCalled(); + expect(custom.store.moveTask).toHaveBeenCalledWith("FN-10", "backlog", { recoveryRehome: true }); + expect(custom.store.linkTaskRecommendation).toHaveBeenCalledWith( + "FN-1", + "rec-1", + "FN-10", + expect.any(Set), + ); + expect(custom.tasks[0]?.recommendations?.[0]?.createdTaskId).toBe("FN-10"); + }); + + it("recovers a deterministic proposal claim from a custom archived-trait lane", async () => { + const canonical = task({ + id: "FN-9", + title: "Canonical export task", + description: "Canonical export work", + column: "done", + }); + const recoverableClaim = task({ + id: "FN-10", + description: "Archived recommendation claim", + column: "boxed" as Column, + proposalClaimId: "recommendation:FN-1:rec-1", + sourceMetadata: { deterministicDuplicateOf: "FN-9" }, + }); + const custom = buildApp([parent(), canonical, recoverableClaim]); + installCustomRecommendationWorkflow(custom.store, ["FN-10"]); + (custom.store.searchTasks as ReturnType).mockResolvedValue([]); + + const response = await performRequest( + custom.app, + "POST", + "/api/tasks/FN-1/recommendations/rec-1/create", + undefined, + ); + + expect(response.status).toBe(200); + expect(custom.store.createTask).not.toHaveBeenCalled(); + expect(custom.store.unarchiveTask).not.toHaveBeenCalled(); + expect(custom.store.moveTask).toHaveBeenCalledWith("FN-10", "backlog", { recoveryRehome: true }); + expect(custom.store.linkTaskRecommendation).toHaveBeenCalledWith( + "FN-1", + "rec-1", + "FN-10", + expect.any(Set), + ); + expect(custom.tasks[0]?.recommendations?.[0]?.createdTaskId).toBe("FN-10"); + }); + + it("recovers a deterministic proposal claim from synthesized v1 archived state", async () => { + const canonical = task({ + id: "FN-9", + title: "Canonical export task", + description: "Canonical export work", + column: "done", + }); + const recoverableClaim = task({ + id: "FN-10", + description: "Legacy archived recommendation claim", + column: "archived", + proposalClaimId: "recommendation:FN-1:rec-1", + sourceMetadata: { deterministicDuplicateOf: "FN-9" }, + }); + const legacy = buildApp([parent(), canonical, recoverableClaim]); + installLegacyV1RecommendationWorkflow(legacy.store, ["FN-10"]); + (legacy.store.searchTasks as ReturnType).mockResolvedValue([]); + + const response = await performRequest( + legacy.app, + "POST", + "/api/tasks/FN-1/recommendations/rec-1/create", + undefined, + ); + + expect(response.status).toBe(200); + expect(legacy.store.createTask).not.toHaveBeenCalled(); + expect(legacy.store.unarchiveTask).not.toHaveBeenCalled(); + expect(legacy.store.moveTask).toHaveBeenCalledWith("FN-10", "triage", { recoveryRehome: true }); + expect(legacy.store.linkTaskRecommendation).toHaveBeenCalledWith( + "FN-1", + "rec-1", + "FN-10", + expect.any(Set), + ); + expect(legacy.tasks[0]?.recommendations?.[0]?.createdTaskId).toBe("FN-10"); + }); + it.each([ { bypassDuplicateCheck: true }, { acknowledgedDuplicates: ["FN-9"] }, diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index 7ed2bf9f4d..8e84f9e7fd 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -86,6 +86,7 @@ import { } from "@fusion/core"; import { GitHubClient } from "../github.js"; import { resolveArtifactMediaPath } from "../artifact-media.js"; +import { archivedColumnsForTask } from "../task-lifecycle-lanes.js"; import { githubRateLimiter } from "../github-poll.js"; import { createTrackingIssueForTask } from "../github-tracking-hook.js"; import { parseGitHubBadgeUrl } from "./register-git-github.js"; @@ -357,7 +358,6 @@ async function resolveTerminalColumnsForTask(store: TaskStore, taskId: string): } } - function isArtifactType(value: string): value is ArtifactType { return ARTIFACT_TYPES.has(value as ArtifactType); } @@ -2136,18 +2136,23 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork canonical lets the original claimed child return to its workflow-resolved intake lane. */ if (trusted?.recoverArchivedProposalTask) { - const restoredTask = await scopedStore.unarchiveTask(trusted.recoverArchivedProposalTask.id); /* FNXC:TaskRecommendations 2026-08-08-08:44: A deterministic-duplicate archive is a lane move, not an operator archive, so it has no pre-archive history for generic restore to replay. Re-home its recovered child to that child's workflow intake explicitly; otherwise custom workflows can restore it to a legacy fallback or complete lane instead of the normal guarded-intake destination. + + FNXC:TaskRecommendations 2026-08-09-06:06: + Never call the cold-storage unarchive path here. Deterministic reconciliation keeps the row in + active storage and may move it to a custom archived-trait lane; re-home that live row directly + through the explicit recovery bypass because archive-to-intake is intentionally non-adjacent. */ - const intakeColumn = await resolveIntakeColumnForTask(scopedStore, restoredTask.id); - const recoveredTask = restoredTask.column === intakeColumn - ? restoredTask - : await scopedStore.moveTask(restoredTask.id, intakeColumn); + const archivedTask = trusted.recoverArchivedProposalTask; + const intakeColumn = await resolveIntakeColumnForTask(scopedStore, archivedTask.id); + const recoveredTask = archivedTask.column === intakeColumn + ? archivedTask + : await scopedStore.moveTask(archivedTask.id, intakeColumn, { recoveryRehome: true }); const trustedCreateResult = await trusted.onCreated?.(recoveredTask); res.status(200).json(trusted.responseForCreated?.(recoveredTask, trustedCreateResult) ?? recoveredTask); return; @@ -2349,13 +2354,16 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork throw conflict("Recommendation link is malformed"); } const linked = await scopedStore.getTask(recommendation.createdTaskId).catch(() => null); + const linkedArchiveColumns = linked + ? await archivedColumnsForTask(scopedStore, linked.id) + : new Set(); /* FNXC:TaskRecommendations 2026-08-08-06:34: A prior link is reusable only while its child remains in a live task lane. Archived and soft-deleted children are historical records, not an actionable Created result; conflict rather than silently resurrecting or linking a second child. */ - if (!linked || linked.deletedAt || linked.column === "archived") { + if (!linked || linked.deletedAt || linkedArchiveColumns.has(linked.column)) { throw conflict("Recommendation link points to an unavailable task"); } return res.status(200).json({ task: linked, parent }); @@ -2370,16 +2378,9 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork */ const existing = (await scopedStore.listTasks({ slim: false, includeArchived: true, includeDeleted: true })) .find((task) => task.proposalClaimId === proposalClaimId); - const existingArchiveColumns = await (async () => { - if (!existing) return new Set(); - try { - const ir = await resolveWorkflowIrForTask(scopedStore, existing.id); - const columns = columnsWithFlag(ir, "archived"); - return new Set(columns.length > 0 ? columns : ["archived"]); - } catch { - return new Set(["archived"]); - } - })(); + const existingArchiveColumns = existing + ? await archivedColumnsForTask(scopedStore, existing.id) + : new Set(); /* FNXC:TaskRecommendations 2026-08-08-08:44: Deterministic reconciliation moves a child to its workflow's archived trait, which may be diff --git a/packages/dashboard/src/task-lifecycle-lanes.ts b/packages/dashboard/src/task-lifecycle-lanes.ts index c190c7c7f9..e43ec3fd12 100644 --- a/packages/dashboard/src/task-lifecycle-lanes.ts +++ b/packages/dashboard/src/task-lifecycle-lanes.ts @@ -1,4 +1,4 @@ -import { columnsWithFlag, declaresAnyLifecycleTrait, resolveWorkflowIrForTask, type WorkflowIr } from "@fusion/core"; +import { columnsWithFlag, declaresAnyLifecycleTrait, resolveWorkflowIrForTask, workflowHasColumn, type WorkflowIr } from "@fusion/core"; /* FNXC:WorkflowResolvedColumns 2026-07-30-08:45 (#2783 review — coderabbit): @@ -46,6 +46,14 @@ legacy fallback as a workflow that cannot be read at all. */ const LEGACY_LANDED_COLUMNS: readonly string[] = ["done", "archived"]; +function archivedColumnsForIr(ir: WorkflowIr): Set { + if (!declaresAnyLifecycleTrait(ir)) return new Set(["archived"]); + const archived = columnsWithFlag(ir, "archived"); + return workflowHasColumn(ir, "archived") + ? new Set(archived) + : new Set([...archived, "archived"]); +} + export async function landedColumnsForTask( store: LaneResolverStore, taskId: string, @@ -53,8 +61,8 @@ export async function landedColumnsForTask( ): Promise> { try { const ir = await resolveWorkflowIrForTask(store, taskId, irCache); - const landed = [...columnsWithFlag(ir, "complete"), ...columnsWithFlag(ir, "archived")]; - return new Set(landed.length > 0 ? landed : LEGACY_LANDED_COLUMNS); + if (!declaresAnyLifecycleTrait(ir)) return new Set(LEGACY_LANDED_COLUMNS); + return new Set([...columnsWithFlag(ir, "complete"), ...archivedColumnsForIr(ir)]); } catch { return new Set(LEGACY_LANDED_COLUMNS); } @@ -108,6 +116,11 @@ Falling back onto it widens the guard onto a role the board explicitly did not a `declaresAnyLifecycleTrait` separates the two, matching the shape #2821's review established for `resolveNodeOverrideLanes`. A board that traits nothing keeps the legacy id; a board that traits something is taken at its word, including when the answer is "no such lane". + +FNXC:TaskRecommendations 2026-08-09-06:06: +An undeclared legacy `archived` id remains a compatibility tombstone even after a workflow adopts +lifecycle traits. Only an explicitly declared untraited `archived` column proves that id is live; +archived-trait lanes remain additive because persisted pre-migration tasks can still use the old id. */ export async function archivedColumnsForTask( store: LaneResolverStore, @@ -116,8 +129,7 @@ export async function archivedColumnsForTask( ): Promise> { try { const ir = await resolveWorkflowIrForTask(store, taskId, irCache); - if (!declaresAnyLifecycleTrait(ir)) return new Set(["archived"]); - return new Set(columnsWithFlag(ir, "archived")); + return archivedColumnsForIr(ir); } catch { return new Set(["archived"]); }