fix(core): an archived child kept blocking its parent's delete on a renamed board (#3162)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<object>();
|
||||
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<string, unknown>;
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<string>,
|
||||
) {
|
||||
/* 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<string>,
|
||||
): Promise<string[]> {
|
||||
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<string>,
|
||||
): Promise<boolean> {
|
||||
const rows = await db
|
||||
.select({ one: sql<number>`1` })
|
||||
.from(schema.project.tasks)
|
||||
.where(liveLineageChildFilter(parentId, projectId))
|
||||
.where(liveLineageChildFilter(parentId, projectId, archivedColumns))
|
||||
.limit(1);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
@@ -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<string[]> {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user