diff --git a/packages/core/src/__tests__/postgres/live-lineage-children-lanes.pg.test.ts b/packages/core/src/__tests__/postgres/live-lineage-children-lanes.pg.test.ts new file mode 100644 index 0000000000..d74eb12490 --- /dev/null +++ b/packages/core/src/__tests__/postgres/live-lineage-children-lanes.pg.test.ts @@ -0,0 +1,108 @@ +/* +FNXC:WorkflowResolvedColumns 2026-07-31-23:45: +THE LINEAGE-INTEGRITY GATE'S ARCHIVE READ, on a RENAMED board. + +`findLiveLineageChildren` answers "does this parent still have LIVE children?" — the gate that +refuses to delete a parent while lineage descendants remain. An ARCHIVED child is filed away, not +live, so it must not hold the gate shut. + +WHY THIS FILE EXISTS. The archive read was converted to +`resolveProjectColumnsForRoles(store, ["archived"])`, and blinding it back to the legacy id left the +whole 16-file lane-detector set green. `store.findLiveLineageChildren` is public and nothing in +`packages/core` exercises it against a renamed board. + +WHAT BREAKS WITHOUT THE CONVERSION. On a board whose archive lane is `vaulted`, an archived child is +not recognised as archived, so it still counts as live and `TaskHasLineageChildrenError` blocks the +parent's delete forever. The operator archived the child precisely to clear the way, and the gate +cannot see that they did. This is the renamed-board twin of the defect #3162 fixed. + +DIFFERENTIAL. Same seeded rows under two vocabularies with identical traits; only the ids differ, and +`vaulted` collides with no legacy id. The default-vocabulary case is the control. +*/ + +import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from "vitest"; +import { sql } from "drizzle-orm"; +import { + createSharedPgTaskStoreTestHarness, + pgDescribe, + type SharedPgTaskStoreHarness, +} from "../../__test-utils__/pg-test-harness.js"; +import { BUILTIN_CODING_WORKFLOW_IR } from "../../builtin-coding-workflow-ir.js"; + +pgDescribe("findLiveLineageChildren under a renamed board vocabulary", () => { + const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({ + prefix: "fusion_lineage_children_lanes", + }); + + beforeAll(h.beforeAll); + beforeEach(h.beforeEach); + afterEach(h.afterEach); + afterAll(h.afterAll); + + async function seedRenamedWorkflow(): Promise { + const RENAME: Record = { + todo: "drafting", + "in-progress": "building", + "in-review": "checking", + done: "shipped", + archived: "vaulted", + }; + const rename = (id: string | undefined) => (id && RENAME[id]) ?? id; + const ir = JSON.parse(JSON.stringify(BUILTIN_CODING_WORKFLOW_IR)) as { + id: string; + nodes?: { column?: string }[]; + columns?: { id: string }[]; + }; + ir.id = "custom:renamed-lineage"; + for (const node of ir.nodes ?? []) node.column = rename(node.column); + for (const column of ir.columns ?? []) column.id = rename(column.id) as string; + + const ids = (ir.columns ?? []).map((column) => column.id); + expect(ids).toContain("vaulted"); + expect(ids).not.toContain("archived"); + + await h.store().createWorkflowDefinition({ name: "Renamed", kind: "workflow", ir } as never); + } + + /** A parent plus one child linked by `source_parent_task_id`, the child parked in `childLane`. */ + async function seedLineagePair(childLane: string): Promise { + const store = h.store(); + for (const id of ["KB-PARENT", "KB-CHILD"]) { + await store.createTaskWithReservedId( + { description: id, column: "todo" }, + { taskId: id, applyDefaultWorkflowSteps: false }, + ); + } + /* Seeded directly: moveTask would reject a target the default workflow does not declare. */ + await h.adminDb().execute(sql` + UPDATE project.tasks + SET "column" = ${childLane}, source_parent_task_id = 'KB-PARENT' + WHERE id = 'KB-CHILD'`); + store.taskCache.delete("KB-CHILD"); + } + + it("default vocabulary: an ARCHIVED child does not count as live", async () => { + await seedLineagePair("archived"); + + expect(await h.store().findLiveLineageChildren("KB-PARENT")).toEqual([]); + }); + + it("renamed vocabulary: a child in the RENAMED archive lane does not count as live", async () => { + await seedRenamedWorkflow(); + await seedLineagePair("vaulted"); + + expect(await h.store().findLiveLineageChildren("KB-PARENT")).toEqual([]); + }); + + /* + The paired positive. Recognising the renamed archive lane must not degrade into "no child is ever + live" — that would silently disable the lineage gate and let a parent be deleted out from under + real descendants, which is worse than the bug being fixed. + */ + it("renamed vocabulary: a WORKING child still counts as live", async () => { + await seedRenamedWorkflow(); + await seedLineagePair("building"); + + expect(await h.store().findLiveLineageChildren("KB-PARENT")).toEqual(["KB-CHILD"]); + }); +}); 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 b83f8b9328..7beeb850c0 100644 --- a/packages/core/src/__tests__/postgres/mission-store.pg.test.ts +++ b/packages/core/src/__tests__/postgres/mission-store.pg.test.ts @@ -514,6 +514,60 @@ pgTest("MissionStore (PostgreSQL backend mode)", () => { expect(reconciled).toMatchObject({ taskId: task.id, status: "done" }); }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-23:40: + THE ARCHIVED HALF OF THE SAME PAIR. The case above pins the `complete` resolver; the `archived` + one is declared on the very next line and nothing reached it — blinding it back to `["archived"]` + left the whole 16-file lane-detector set green while blinding its neighbour failed immediately. + + Terminal evidence is "done OR supported archived state", so an archived card is equally valid + repair evidence. On a board whose archive lane is `vaulted`, the archived half could not see it and + the method threw `TASK_NOT_TERMINAL` for a card that was genuinely filed away — the same refusal + the case above fixed, reached through the other door. + + Being adjacent to a covered resolver is not coverage; this is the third such split found in core. + */ + it("accepts an ARCHIVED card whose board calls the archive lane something else", async () => { + const m = missions(); + const store = h.store(); + await store.createWorkflowDefinition({ + name: "Renamed archive", + ir: { + version: "v2", + name: "Renamed archive", + columns: [ + { id: "todo", name: "Todo", traits: [{ trait: "intake" }, { trait: "hold" }] }, + { id: "done", name: "Done", traits: [{ trait: "complete" }] }, + { id: "vaulted", name: "Vaulted", traits: [{ trait: "archived" }] }, + ], + nodes: [ + { id: "start", kind: "start", column: "todo" }, + { id: "end", kind: "end", column: "done" }, + ], + edges: [{ from: "start", to: "end", condition: "success" }], + } as never, + }); + const mission = await m.createMission({ title: "Renamed-archive repair" }); + 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: "Delivered" }); + const task = await store.createTask({ description: "filed away", column: "done" }); + /* + A REAL archive, then the lane rename. The `archived` verdict requires all three of + `deletedAt !== null`, an archive-snapshot row, and `isArchived(column)` — a live card merely + sitting in an archive-trait column is `invalid-deleted`, not `archived`, so seeding one would + fail for a reason that has nothing to do with the lane read under test. Archiving first and + then renaming the recorded lane isolates exactly the third condition. + */ + await store.archiveTask(task.id, { cleanup: false }); + await h.adminDb().execute(sql`UPDATE project.tasks SET "column" = 'vaulted' WHERE id = ${task.id}`); + store.taskCache.delete(task.id); + + const reconciled = await m.reconcileFeatureDoneWithTerminalTask(feature.id, task.id); + + expect(reconciled).toMatchObject({ taskId: task.id, status: "done" }); + }); + it("atomically reconciles live done evidence and remains idempotent", async () => { const m = missions(); const mission = await m.createMission({ title: "Parked repair" });