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:
gsxdsm
2026-07-31 07:18:54 -07:00
committed by GitHub
parent 4365a3b10b
commit 27741d0e2f
4 changed files with 136 additions and 7 deletions

View File

@@ -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");
});
});

View File

@@ -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);
}

View File

@@ -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;
}

View File

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