diff --git a/packages/core/src/__tests__/postgres/mission-store.pg.test.ts b/packages/core/src/__tests__/postgres/mission-store.pg.test.ts index f87209f2c5..b83f8b9328 100644 --- a/packages/core/src/__tests__/postgres/mission-store.pg.test.ts +++ b/packages/core/src/__tests__/postgres/mission-store.pg.test.ts @@ -36,6 +36,7 @@ import { listMissionEvents, listMissions as listMissionRows, } from "../../async-mission-store.js"; +import { BUILTIN_CODING_WORKFLOW_IR } from "../../index.js"; const pgTest = pgDescribe; @@ -393,6 +394,74 @@ pgTest("MissionStore (PostgreSQL backend mode)", () => { expect(await m.getFeature(firstFeature.id)).toMatchObject({ taskId: claimedTask.id, status: "triaged" }); }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-10:20: + THE BOOTSTRAP DUPLICATE WAS PARKED IN A LANE THE BOARD DOES NOT DECLARE. + + This path writes `tasks.column` DIRECTLY rather than going through `moveTask`, so neither the + lifecycle census (which reads comparisons) nor the move-target census (which reads `moveTask` + arguments) could see the literal `archived`. On a board whose archive lane is renamed, the + duplicate landed in a column that workflow does not declare — a card in a lane the board cannot + render, from a path that runs during ordinary feature bootstrap. + + DIFFERENTIAL: `filed` collides with no legacy id, so a surviving `"archived"` cannot pass by luck. + */ + it("archives a bootstrap duplicate into the RENAMED archive lane", async () => { + const m = missions(); + const taskStore = h.store(); + + const ir = JSON.parse(JSON.stringify(BUILTIN_CODING_WORKFLOW_IR)) as { + id: string; nodes?: { column?: string }[]; columns?: { id: string }[]; + }; + ir.id = "custom:renamed-archive-missions"; + for (const node of ir.nodes ?? []) if (node.column === "archived") node.column = "filed"; + for (const column of ir.columns ?? []) if (column.id === "archived") column.id = "filed"; + expect((ir.columns ?? []).map((c) => c.id)).not.toContain("archived"); + const definition = await taskStore.createWorkflowDefinition({ name: "Renamed archive", kind: "workflow", ir } as never); + const workflowId = (definition as unknown as { id: string }).id; + + const mission = await m.createMission({ title: "Renamed archive bootstrap" }); + const milestone = await m.addMilestone(mission.id, { title: "MS" }); + const slice = await m.addSlice(milestone.id, { title: "SL" }); + const feature = await m.addFeature(slice.id, { title: "Feature" }); + + const claimedTask = await taskStore.createTask({ description: "same fingerprint work", missionId: mission.id, sliceId: slice.id }); + await m.linkFeatureToTask(feature.id, claimedTask.id); + const duplicateTask = await taskStore.createTask({ description: "same fingerprint work", missionId: mission.id, sliceId: slice.id }); + await taskStore.writeTaskWorkflowSelection(duplicateTask.id, workflowId, []); + + await m.archiveDefinedFeatureBootstrapDuplicate({ + featureId: feature.id, + taskId: claimedTask.id, + duplicateTaskId: duplicateTask.id, + }); + + expect(await taskStore.getTask(duplicateTask.id)).toMatchObject({ id: duplicateTask.id, column: "filed" }); + }); + + /* Control: with no renamed workflow the duplicate still lands in the legacy archive lane, so an + unconverted board is byte-identical. */ + it("archives a bootstrap duplicate into `archived` on the default lineage", async () => { + const m = missions(); + const taskStore = h.store(); + const mission = await m.createMission({ title: "Default archive bootstrap" }); + const milestone = await m.addMilestone(mission.id, { title: "MS" }); + const slice = await m.addSlice(milestone.id, { title: "SL" }); + const feature = await m.addFeature(slice.id, { title: "Feature" }); + + const claimedTask = await taskStore.createTask({ description: "same fingerprint work", missionId: mission.id, sliceId: slice.id }); + await m.linkFeatureToTask(feature.id, claimedTask.id); + const duplicateTask = await taskStore.createTask({ description: "same fingerprint work", missionId: mission.id, sliceId: slice.id }); + + await m.archiveDefinedFeatureBootstrapDuplicate({ + featureId: feature.id, + taskId: claimedTask.id, + duplicateTaskId: duplicateTask.id, + }); + + expect(await taskStore.getTask(duplicateTask.id)).toMatchObject({ id: duplicateTask.id, column: "archived" }); + }); + /* FNXC:MissionReconciliation 2026-07-20-08:34: Regression coverage exercises every terminal-evidence representation through the real PostgreSQL store. Reconciliation must never route through ordinary triage linking, mutate loop attempts or mission controls, or partially commit when the transaction fails. diff --git a/packages/core/src/async-mission-store.ts b/packages/core/src/async-mission-store.ts index 95e854fdef..7a497ec131 100644 --- a/packages/core/src/async-mission-store.ts +++ b/packages/core/src/async-mission-store.ts @@ -11,7 +11,7 @@ const severityAuditLog = createLogger("core-async-mission-store"); * events; reusable SQL and row mapping live in async-mission-store-queries.ts. */ import { EventEmitter } from "node:events"; -import { and, eq, inArray, sql } from "drizzle-orm"; +import { and, eq, inArray, notInArray, sql } from "drizzle-orm"; import * as schema from "./postgres/schema/index.js"; import type { AsyncDataLayer } from "./postgres/data-layer.js"; import { FEATURE_LOOP_TRANSITIONS, normalizeMissionAssertionType, renderValidationCause } from "./mission-types.js"; @@ -1348,6 +1348,8 @@ export class AsyncMissionStore extends EventEmitter { * generic intake path to archive feature.taskId. */ async archiveDefinedFeatureBootstrapDuplicate(input: { featureId: string; taskId: string; duplicateTaskId: string }): Promise { + /* Resolved once, outside the transaction: both guards below ask the same question. */ + const claimedArchivedLanes = await this.archivedLanesFor(input.taskId); /* FNXC:MissionAdmission 2026-07-23-21:10: Project-agnostic legacy stores remain scoped to their reserved RLS @@ -1372,7 +1374,7 @@ export class AsyncMissionStore extends EventEmitter { eq(schema.project.tasks.projectId, projectId), eq(schema.project.tasks.id, input.taskId), sql`${schema.project.tasks.deletedAt} is null`, - sql`${schema.project.tasks.column} <> 'archived'`, + notInArray(schema.project.tasks.column, [...claimedArchivedLanes]), )); if (!claimed[0]) throw new Error(`Cannot reconcile defined-feature bootstrap duplicate: claimed task ${input.taskId} is not live`); /* @@ -1384,13 +1386,31 @@ export class AsyncMissionStore extends EventEmitter { */ const duplicateFeature = await getConflictingFeatureByTaskId(tx, input.duplicateTaskId, input.featureId); if (duplicateFeature) return; + /* + FNXC:WorkflowResolvedColumns 2026-07-31-10:10: + THE ARCHIVE TARGET IS RESOLVED, not the literal `archived`. + + This writes `tasks.column` DIRECTLY rather than going through `moveTask`, so neither the + lifecycle census (which reads comparisons) nor the move-target census (which reads + `moveTask` call arguments) could see it. On a board whose archive lane is named anything + else, it parked the duplicate in a column that workflow does not declare — a card in a lane + the board cannot render. + + `archivedLanesFor` already exists on this class for the guards above and returns the legacy + id when the task has no resolvable workflow, so an unconverted board is byte-identical. + A board declaring several archive lanes is arbitrated by taking the first; that is the same + choice `resolveLifecycleColumns` makes, and multiple archive lanes are not a shape the + builtin lineages produce. + */ + const duplicateArchivedLanes = await this.archivedLanesFor(input.duplicateTaskId); + const archiveTarget = [...duplicateArchivedLanes][0] ?? "archived"; await tx.update(schema.project.tasks) - .set({ column: "archived", updatedAt: new Date().toISOString() }) + .set({ column: archiveTarget, updatedAt: new Date().toISOString() }) .where(and( eq(schema.project.tasks.projectId, projectId), eq(schema.project.tasks.id, input.duplicateTaskId), sql`${schema.project.tasks.deletedAt} is null`, - sql`${schema.project.tasks.column} <> 'archived'`, + notInArray(schema.project.tasks.column, [...duplicateArchivedLanes]), )); }); } diff --git a/scripts/lib/sql-column-literals-baseline.json b/scripts/lib/sql-column-literals-baseline.json index fda9eed92c..1a046f01f2 100644 --- a/scripts/lib/sql-column-literals-baseline.json +++ b/scripts/lib/sql-column-literals-baseline.json @@ -1,6 +1,5 @@ { "packages/core/src/async-mission-store-queries.ts": 1, - "packages/core/src/async-mission-store.ts": 2, "packages/core/src/github-issue-analytics.ts": 1, "packages/core/src/gitlab-issue-analytics.ts": 1, "packages/core/src/mission-store.ts": 1,