fix(core): the mission bootstrap duplicate was archived into a lane the board does not declare (#3046)

## 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) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-31 02:22:27 -07:00
committed by GitHub
parent 53aef245f9
commit 4878bda197
3 changed files with 93 additions and 5 deletions

View File

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

View File

@@ -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<MissionStoreEvents> {
* generic intake path to archive feature.taskId.
*/
async archiveDefinedFeatureBootstrapDuplicate(input: { featureId: string; taskId: string; duplicateTaskId: string }): Promise<void> {
/* 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<MissionStoreEvents> {
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<MissionStoreEvents> {
*/
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]),
));
});
}

View File

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