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:
@@ -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.
|
||||
|
||||
@@ -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]),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user