From 4878bda1975c0748e1b024c90d99aac10eae2190 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 31 Jul 2026 02:22:27 -0700 Subject: [PATCH] fix(core): the mission bootstrap duplicate was archived into a lane the board does not declare (#3046) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Invisible to both censuses `archiveDefinedFeatureBootstrapDuplicate` writes `tasks.column` **directly** rather than through `moveTask`: ```ts .set({ column: "archived", updatedAt: … }) ``` - the **lifecycle census** reads comparisons — an assignment isn't one - the **move-target census** reads `moveTask` call arguments — this never calls it So on a board whose archive lane is renamed, the duplicate landed in a column that workflow doesn't declare: a card in a lane the board can't render, from a path that runs during ordinary feature bootstrap. ## Reuses the helper this class already has `archivedLanesFor(taskId)` was added for the guards further up the same file. It returns the legacy id when the task has no resolvable workflow, so an **unconverted board is byte-identical**. No new resolution machinery — the two `<> 'archived'` guards become `notInArray(column, [...lanes])` and the write targets the resolved lane. A board declaring several archive lanes is arbitrated by taking the first, the same choice `resolveLifecycleColumns` makes. Multiple archive lanes aren't a shape the builtin lineages produce. ## Measured | check | result | |---|---| | mission-store PG suite | **36 → 38**, all green | | new pair | differential — `filed` collides with no legacy id, and the default-lineage control still lands in `archived` | | mutation (hardcode the target back) | fails the renamed case | | SQL literal gate · `tsc` | green | ## How this was found Measuring the literal-column-**write** population for #2839: 51 raw sites, of which 20 are the four builtin workflow IRs declaring their own columns (correct by definition) and several more are archive-*entry record* fields rather than board columns. This is the one I verified is a real board write on a live path. Worth noting the measurement itself was wrong twice first — my glob was `packages/*/src/**/*.ts`, which requires a subdirectory and silently skipped every top-level file in `src/` (including this one), and my script printed only the first 14 findings so the grouping was over a truncated list. Same scope-blindness class as #3000 and #3002, this time in a throwaway scanner. --------- Co-authored-by: Claude Opus 5 (1M context) --- .../postgres/mission-store.pg.test.ts | 69 +++++++++++++++++++ packages/core/src/async-mission-store.ts | 28 ++++++-- scripts/lib/sql-column-literals-baseline.json | 1 - 3 files changed, 93 insertions(+), 5 deletions(-) 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,