From 27741d0e2fbd85920e042a781bb0be0adccedb27 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 31 Jul 2026 07:18:54 -0700 Subject: [PATCH] fix(core): an archived child kept blocking its parent's delete on a renamed board (#3162) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second **LANE** site from the archived triage (#3154), same additive shape as #3160. ## The bug `liveLineageChildFilter` is the lineage-integrity gate (VAL-DATA-010) behind `deleteTask` and `archiveTask`: a parent with **live** children is refused with `TaskHasLineageChildrenError`. It excluded children in the `archived` column **by id**. So on a board that renames that lane, an archived child still counted as live and the parent could not be deleted — with an error naming a child the operator had **already filed away**. ## The fix is permissive, and that is the correct direction The gate exists to protect **live** children; an archived child is not one. Resolving makes fewer rows block, which is what the gate always meant. I am flagging this explicitly because *"a conversion makes a delete gate stop firing"* deserves a second look. The second look is that it was firing on rows it was never meant to protect. ## LANE, not STATE The two STATE sites in this inventory are marked at their own sites (#3157) and must never be resolved — one of them deletes directories. This one asks about the board. ## Wired at every caller, not left as an optional seam `findLiveLineageChildrenImpl` (has `store`) and both `archive-lifecycle-2.ts` gates resolve and pass it. `hasLiveLineageChildren` takes the same parameter so the two readers **cannot disagree** about which children are live — the half-conversion shape this program keeps finding, and the reason #3129's earlier attempt was reverted for leaving a seam unsupplied. ## Parity gate satisfied Same reason as #3160: the conversion is **additive** — it keeps the literal as the fallback, so no encoding's literal count moves and a caller supplying no set gets byte-identical SQL. ## Measured - 4 new cases; parity test still **2/2**, inventories unmoved. - **MUTATION**: dropping the resolved branch fails the renamed case and leaves the legacy **control**, the **fail-soft** case, and the parent/project-scope **negative** green. - lineage / archive / soft-delete / archived suites — **6 files / 19 tests pass**. - `tsc --noEmit -p packages/core` clean; census `--strict`, `check-sql-column-literals`, `check-fnxc-future-dates` clean. ## Census **Unchanged** — the literal remains the fallback arm, by design. Co-authored-by: Claude Opus 5 (1M context) --- ...eage-children-renamed-archive-lane.test.ts | 88 +++++++++++++++++++ .../src/task-store/archive-lifecycle-2.ts | 11 ++- .../core/src/task-store/async-lifecycle.ts | 38 +++++++- .../core/src/task-store/task-id-integrity.ts | 6 +- 4 files changed, 136 insertions(+), 7 deletions(-) create mode 100644 packages/core/src/__tests__/lineage-children-renamed-archive-lane.test.ts diff --git a/packages/core/src/__tests__/lineage-children-renamed-archive-lane.test.ts b/packages/core/src/__tests__/lineage-children-renamed-archive-lane.test.ts new file mode 100644 index 0000000000..69f98a50e5 --- /dev/null +++ b/packages/core/src/__tests__/lineage-children-renamed-archive-lane.test.ts @@ -0,0 +1,88 @@ +/* +FNXC:WorkflowResolvedColumns 2026-07-31-23:59: +AN ARCHIVED CHILD KEPT BLOCKING ITS PARENT'S DELETE ON A RENAMED BOARD. + +`liveLineageChildFilter` is the lineage-integrity gate (VAL-DATA-010) behind `deleteTask` and +`archiveTask`: a parent with LIVE children is refused with `TaskHasLineageChildrenError`. It excluded +children in the `archived` column by id, so on a board that renames that lane an archived child still +counted as live and the parent could not be deleted — with an error naming a child the operator had +already filed away. + +THE FIX IS PERMISSIVE, AND THAT IS THE CORRECT DIRECTION. The gate exists to protect live children; +an archived child is not one. Resolving makes fewer rows block, which is what the gate always meant. +Worth stating explicitly because "converting a guard makes a delete gate stop firing" deserves a +second look — the second look is that it was firing on rows it was never meant to protect. + +LANE, not STATE. The triage in `archived-column-gate-parity.test.ts` splits the eight Drizzle +`archived` sites; the two STATE ones are marked at their own sites and must never be resolved (one +deletes directories). This one asks about the board. + +Asserted on the composed predicate rather than through a live delete: the subject is which children +the SQL counts as live. A database fixture would exercise Drizzle and the delete path instead, and +would not distinguish this guard from the three other conditions in the same `and(...)`. +*/ + +import { describe, expect, it } from "vitest"; +import { liveLineageChildFilter } from "../task-store/async-lifecycle.js"; + +/* +Drizzle's SQL graph is CYCLIC (column -> table -> columns), so this needs a visited set — the first +version of the sibling search test blew the stack without one. +*/ +function boundValues(predicate: unknown): string[] { + const seen: string[] = []; + const visited = new WeakSet(); + const walk = (node: unknown): void => { + if (node == null || typeof node !== "object") return; + if (visited.has(node)) return; + visited.add(node); + if (Array.isArray(node)) { node.forEach(walk); return; } + const record = node as Record; + if (typeof record.value === "string") seen.push(record.value); + Object.values(record).forEach(walk); + }; + walk(predicate); + return seen; +} + +describe("the lineage-children gate excludes the board's own archive lanes", () => { + it("excludes a RENAMED archive lane when the resolved set is supplied", () => { + const filter = liveLineageChildFilter("KB-PARENT", "p1", new Set(["archived", "filed"])); + + /* Against the literal, a child in `filed` still counted as live and blocked the parent. */ + expect(boundValues(filter)).toContain("filed"); + }); + + /* + CONTROL. The resolved set is legacy-seeded, so the built-in id must still be excluded — a + conversion that resolved the renamed lane and dropped the legacy one would break every default + board while satisfying the case above. + */ + it("still excludes the legacy `archived` id alongside it", () => { + const filter = liveLineageChildFilter("KB-PARENT", "p1", new Set(["archived", "filed"])); + + expect(boundValues(filter)).toContain("archived"); + }); + + /* + FAIL-SOFT, and this is what keeps the parity gate's encodings in step: an unwired caller, or one + whose workflow list could not be read, must produce exactly the SQL it produced before. + */ + it("falls back to the legacy id when no resolved set is supplied", () => { + const filter = liveLineageChildFilter("KB-PARENT", "p1"); + + expect(boundValues(filter)).toContain("archived"); + expect(boundValues(filter)).not.toContain("filed"); + }); + + /* + The paired negative. The other three conditions in this predicate are what make it a LINEAGE gate + rather than a generic live filter; widening the archive exclusion must not disturb them. + */ + it("still scopes to the parent and its project", () => { + const values = boundValues(liveLineageChildFilter("KB-PARENT", "p1", new Set(["archived"]))); + + expect(values).toContain("KB-PARENT"); + expect(values).toContain("p1"); + }); +}); diff --git a/packages/core/src/task-store/archive-lifecycle-2.ts b/packages/core/src/task-store/archive-lifecycle-2.ts index b32012a520..49d13f4c90 100644 --- a/packages/core/src/task-store/archive-lifecycle-2.ts +++ b/packages/core/src/task-store/archive-lifecycle-2.ts @@ -26,6 +26,7 @@ import {sanitizeFileScopeInPromptContent} from "../task-store/file-scope.js"; import {__setTaskActivityLogLimitsForTesting} from "../task-store/comments.js"; import {softDeleteTaskRowInTransaction, readTaskRow as readTaskRowAsync} from "../task-store/async-persistence.js"; import {findLiveLineageChildren as findLiveLineageChildrenAsync, projectPartition, removeLineageReferences} from "../task-store/async-lifecycle.js"; +import { resolveProjectColumnsForRoles } from "../project-lane-vocabulary.js"; import {archiveParentTaskWithLineageGate, findArchivedTaskEntry, deleteArchivedTaskEntry, restoreTaskFromArchive} from "../task-store/async-archive-lineage.js"; import {getArchivedRowCount, listArchivedTaskEntriesPage} from "../async-archive-db.js"; import {disposeArchivedWorkspaceWorktrees, disposeArchivedWorktree, prepareArchivedWorkspaceWorktrees, releasePreparedWorkspaceArchiveDisposal} from "./archive-lifecycle.js"; @@ -156,7 +157,10 @@ export async function deleteTaskBackendImpl(store: TaskStore, id: string, option } // Lineage-integrity gate (VAL-DATA-010). - const lineageChildIds = await findLiveLineageChildrenAsync(layer.db, id, layer.projectId); + /* FNXC:WorkflowResolvedColumns 2026-07-31-23:59: resolved archive lanes, so an archived child no + longer blocks its parent on a renamed board. Fail-soft to undefined -> the legacy id. */ + const lineageArchivedLanes = await resolveProjectColumnsForRoles(store, ["archived"]).catch(() => undefined); + const lineageChildIds = await findLiveLineageChildrenAsync(layer.db, id, layer.projectId, lineageArchivedLanes); if (lineageChildIds.length > 0 && !options?.removeLineageReferences) { throw new TaskHasLineageChildrenError(id, lineageChildIds); } @@ -250,7 +254,10 @@ export async function deleteTaskIfBackendImpl( // FNXC:TaskDeletion 2026-07-29-19:15: // FN-8361 conditional deletion preserves delete's lineage gate even when // the caller predicate declines the mutation; guards precede the predicate. - const lineageChildIds = await findLiveLineageChildrenAsync(layer.db, id, layer.projectId); + /* FNXC:WorkflowResolvedColumns 2026-07-31-23:59: resolved archive lanes, so an archived child no + longer blocks its parent on a renamed board. Fail-soft to undefined -> the legacy id. */ + const lineageArchivedLanes = await resolveProjectColumnsForRoles(store, ["archived"]).catch(() => undefined); + const lineageChildIds = await findLiveLineageChildrenAsync(layer.db, id, layer.projectId, lineageArchivedLanes); if (lineageChildIds.length > 0 && !options?.removeLineageReferences) { throw new TaskHasLineageChildrenError(id, lineageChildIds); } diff --git a/packages/core/src/task-store/async-lifecycle.ts b/packages/core/src/task-store/async-lifecycle.ts index b53e8566df..b2c59a2fac 100644 --- a/packages/core/src/task-store/async-lifecycle.ts +++ b/packages/core/src/task-store/async-lifecycle.ts @@ -60,12 +60,37 @@ export function projectPartition(projectId?: string): string { return projectId?.trim() || "__legacy_unscoped__"; } -export function liveLineageChildFilter(parentId: string, projectId?: string) { +export function liveLineageChildFilter( + parentId: string, + projectId?: string, + /* + FNXC:WorkflowResolvedColumns 2026-07-31-23:59: + The board's own archive lanes, resolved by the caller. Omitted → the `archived` literal, which is + what every unwired caller keeps, so an unconverted board produces byte-identical SQL. + + A LANE site, per the triage in `archived-column-gate-parity.test.ts`: "which children still count as + LIVE" is a question about the board. The two STATE sites in that inventory + (`cleanupArchivedTasksImpl`, `listSoftDeletedColumnDriftCandidates`) are marked at their own sites + and must NOT be resolved. + + DIRECTION OF THE FIX, stated because this gate BLOCKS deletion: against the literal, an archived + child on a renamed board still counted as live and kept blocking its parent's delete/archive with + `TaskHasLineageChildrenError`. Resolving makes those children correctly stop blocking. The change is + permissive, and permissive is the CORRECT direction here — the gate exists to protect live children, + and an archived child is not one. + */ + archivedColumns?: ReadonlySet, +) { + /* One `ne` per archive lane; the resolved set is legacy-seeded, so the unconverted shape is the + single `ne(column, "archived")` this replaces. */ + const archivedExclusions = archivedColumns && archivedColumns.size > 0 + ? [...archivedColumns].map((lane) => ne(schema.project.tasks.column, lane)) + : [ne(schema.project.tasks.column, "archived")]; return and( eq(schema.project.tasks.projectId, projectPartition(projectId)), eq(schema.project.tasks.sourceParentTaskId, parentId), ne(schema.project.tasks.id, parentId), - ne(schema.project.tasks.column, "archived"), + ...archivedExclusions, ACTIVE_TASK_FILTER, ); } @@ -93,11 +118,13 @@ export async function findLiveLineageChildren( db: AsyncDataLayer["db"] | DbTransaction, parentId: string, projectId?: string, + /** Resolved archive lanes; omitted → the legacy id. See `liveLineageChildFilter`. */ + archivedColumns?: ReadonlySet, ): Promise { const rows = await db .select({ id: schema.project.tasks.id }) .from(schema.project.tasks) - .where(liveLineageChildFilter(parentId, projectId)); + .where(liveLineageChildFilter(parentId, projectId, archivedColumns)); return rows.map((row) => row.id); } @@ -176,11 +203,14 @@ export async function hasLiveLineageChildren( db: AsyncDataLayer["db"] | DbTransaction, parentId: string, projectId?: string, + /** Resolved archive lanes; omitted → the legacy id. Threaded so this and + * `findLiveLineageChildren` cannot disagree about which children are live. */ + archivedColumns?: ReadonlySet, ): Promise { const rows = await db .select({ one: sql`1` }) .from(schema.project.tasks) - .where(liveLineageChildFilter(parentId, projectId)) + .where(liveLineageChildFilter(parentId, projectId, archivedColumns)) .limit(1); return rows.length > 0; } diff --git a/packages/core/src/task-store/task-id-integrity.ts b/packages/core/src/task-store/task-id-integrity.ts index 49b75e90e6..c771958085 100644 --- a/packages/core/src/task-store/task-id-integrity.ts +++ b/packages/core/src/task-store/task-id-integrity.ts @@ -28,6 +28,7 @@ import { getLiveTaskColumn } from "./async-comments-attachments.js"; import { insertTaskRowInTransaction, isTaskIdConflictError, readTaskRow, readTaskRowInTransaction } from "./async-persistence.js"; import { TASK_PERSIST_SQL_COLUMNS, TASK_UPSERT_SQL_ASSIGNMENTS, type TaskRow } from "./persistence.js"; import { purgeTaskWorkflowSelectionRowsAsyncImpl } from "./workflow-definitions.js"; +import { resolveProjectColumnsForRoles } from "../project-lane-vocabulary.js"; import { ConfigRow } from "./row-types.js"; import { ARCHIVE_AGENT_LOG_SNAPSHOT_LIMIT } from "./serialization.js"; import { ActivityLogEntry, ArchiveAgentLogMode, ArchivedTaskEntry, BoardConfig, BranchGroup, BranchGroupCreateInput, GoalCitationInput, GoalCitationSurface, RunAuditEventInput, Settings, Task, TaskCreateInput } from "../types.js"; @@ -494,7 +495,10 @@ export function findLiveDependentsImpl(store: TaskStore, id: string): string[] { export async function findLiveLineageChildrenImpl(store: TaskStore, id: string): Promise { const layer = store.asyncLayer!; - return findLiveLineageChildrenAsync(layer.db, id, layer.projectId); + /* FNXC:WorkflowResolvedColumns 2026-07-31-23:59: the board's archive lanes, so an archived child + stops counting as live. Fail-soft to undefined -> the legacy id. */ + const archivedColumns = await resolveProjectColumnsForRoles(store, ["archived"]).catch(() => undefined); + return findLiveLineageChildrenAsync(layer.db, id, layer.projectId, archivedColumns); } export function recordActivityFromListenerImpl(store: TaskStore,