[F020] kanban: confirm admin-override drags that skip lifecycle preconditions

A drag on the operator kanban routes the status move through the admin
status-override, which bypasses the in-band lifecycle validator entirely.
That override is intentional (it's how an operator recovers a wedged task)
but it also let a careless drag skip material preconditions silently —
completing a task with no open PR, QA-bypassing, finishing docs on a task
whose docs aren't complete.

Leave the override intact but make the bypass explicit: compute the
preconditions the dragged move would skip (open PR, docs complete,
self-verified + commits + progress for submit-qa, visible non-terminal
subtasks for coordination-root targets) and, when any are skipped, hold the
move behind a confirmation dialog that lists exactly what's being skipped.
Precision over recall — only warn on what the panel can verify from the
task and its in-list children; never fabricate a 'satisfied' claim, and
stay silent on benign transitions that gate on nothing we can check.

The admin status-override capability is preserved (Confirm still fires it);
this only surfaces the bypass instead of letting it happen silently. Does
not touch the master-merge invariant — the board's updateTask is the
operator override, not the Main-PM merge path.
This commit is contained in:
Renn F
2026-06-28 18:38:02 +02:00
parent c4eabf90e7
commit b3109feb46
4 changed files with 491 additions and 0 deletions
@@ -0,0 +1,133 @@
import { describe, it, expect } from "vitest";
import { TaskStatus, Team, TaskType, type Task } from "@/types";
import { skippedPreconditions } from "../bypass-preconditions";
function buildTask(overrides: Partial<Task> = {}): Task {
return {
id: "t1",
title: "A task",
description: "desc",
acceptance_criteria: ["a"],
status: TaskStatus.IN_PROGRESS,
priority: 2,
sequence: 0,
team: Team.BACKEND,
created_by: "ceo",
assigned_to: null,
parent_task_id: null,
dependency_ids: [],
blocker_ids: [],
created_at: "2026-01-01T00:00:00Z",
updated_at: null,
claimed_at: null,
started_at: null,
completed_at: null,
target_date: null,
estimated_complexity: "M" as never,
nature: "feature" as never,
task_type: TaskType.CODE,
project_id: "p1",
docs_complete: false,
pr_created: false,
pm_approvals: {},
plan: null,
checkpoints: [],
progress_updates: [],
commits: [],
self_verified: false,
qa_verified: null,
sessions: [],
branch_name: null,
pr_number: null,
pr_url: null,
...overrides,
} as unknown as Task;
}
describe("skippedPreconditions — F020 (what an admin-override drag skips)", () => {
it("flags a PR-less drag to awaiting_qa with the submit-qa gate's missing checks", () => {
const task = buildTask({ pr_number: null, self_verified: false });
const skipped = skippedPreconditions(task, TaskStatus.AWAITING_QA, [task]);
expect(skipped).toEqual([
"no open PR",
"not self-verified",
"no commits linked",
"no progress updates",
]);
});
it("stays silent when a task meets the awaiting_qa preconditions (no false alarm)", () => {
const task = buildTask({
pr_number: 42,
self_verified: true,
commits: [{ sha: "abc", message: "m", timestamp: "t" } as never],
progress_updates: [{ note: "p" } as never],
});
expect(skippedPreconditions(task, TaskStatus.AWAITING_QA, [task])).toEqual(
[],
);
});
it("flags a PR-less, undocumented drag to completed", () => {
const task = buildTask({ pr_number: null, docs_complete: false });
const skipped = skippedPreconditions(task, TaskStatus.COMPLETED, [task]);
expect(skipped).toContain("no open PR");
expect(skipped).toContain("documentation not marked complete");
});
it("flags non-terminal subtasks when completing a coordination root", () => {
const parent = buildTask({ id: "root", pr_number: 7, docs_complete: true });
const children = [
buildTask({
id: "c1",
parent_task_id: "root",
status: TaskStatus.COMPLETED,
}),
buildTask({
id: "c2",
parent_task_id: "root",
status: TaskStatus.IN_PROGRESS,
}),
];
const skipped = skippedPreconditions(parent, TaskStatus.COMPLETED, [
parent,
...children,
]);
expect(skipped).toContain("1 non-terminal subtask(s)");
});
it("does not fabricate a subtask warning when no children are visible", () => {
// The board's task list is team-filtered; a root's children may be in other
// teams and absent here. Precision over recall: don't claim "subtasks
// terminal" we can't verify — and don't false-alarm either.
const parent = buildTask({ id: "root", pr_number: 7, docs_complete: true });
expect(
skippedPreconditions(parent, TaskStatus.COMPLETED, [parent]),
).toEqual([]);
});
it("stays silent for benign transitions that skip no material precondition", () => {
// pending -> claimed, in_progress -> blocked, etc. don't gate on PR/docs.
const task = buildTask({ status: TaskStatus.PENDING });
expect(skippedPreconditions(task, TaskStatus.CLAIMED, [task])).toEqual([]);
const inProgress = buildTask({ status: TaskStatus.IN_PROGRESS });
expect(
skippedPreconditions(inProgress, TaskStatus.BLOCKED, [inProgress]),
).toEqual([]);
});
it("flags a PR-less drag to awaiting_documentation (pass-qa gate needs a PR)", () => {
const task = buildTask({ pr_number: null });
expect(
skippedPreconditions(task, TaskStatus.AWAITING_DOCUMENTATION, [task]),
).toContain("no open PR");
});
it("flags missing docs for a drag to awaiting_pm_review", () => {
const task = buildTask({ pr_number: 9, docs_complete: false });
const skipped = skippedPreconditions(task, TaskStatus.AWAITING_PM_REVIEW, [
task,
]);
expect(skipped).toEqual(["documentation not marked complete"]);
});
});
@@ -0,0 +1,192 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { TaskStatus, Team, TaskType, type Task } from "@/types";
// Capture the board's onDragEnd so the test can synthesize a drop without
// driving the real dnd-kit pointer sensor (painful in jsdom).
const dragRef = vi.hoisted(() => ({
onDragEnd: null as ((e: unknown) => void) | null,
}));
vi.mock("@dnd-kit/core", () => ({
DndContext: ({
onDragEnd,
children,
}: {
onDragEnd: (e: unknown) => void;
children: React.ReactNode;
}) => {
dragRef.onDragEnd = onDragEnd;
return <>{children}</>;
},
DragOverlay: ({ children }: { children: React.ReactNode }) => <>{children}</>,
PointerSensor: () => null,
useSensor: () => null,
useSensors: () => [],
}));
const { mutateAsync, refetch, tasksRef } = vi.hoisted(() => ({
mutateAsync: vi.fn().mockResolvedValue(undefined),
refetch: vi.fn().mockResolvedValue(undefined),
tasksRef: { current: [] as Task[] },
}));
vi.mock("@/hooks/use-tasks", () => ({
useTasks: () => ({ data: tasksRef.current, isLoading: false, refetch }),
useTaskLifecycle: () => ({
claim: { mutateAsync: vi.fn() },
start: { mutateAsync: vi.fn() },
block: { mutateAsync: vi.fn() },
unblock: { mutateAsync: vi.fn() },
pause: { mutateAsync: vi.fn() },
resume: { mutateAsync: vi.fn() },
verify: { mutateAsync: vi.fn() },
submitQa: { mutateAsync: vi.fn() },
passQa: { mutateAsync: vi.fn() },
failQa: { mutateAsync: vi.fn() },
complete: { mutateAsync: vi.fn() },
}),
useUpdateTask: () => ({ mutateAsync, isPending: false }),
}));
// Avoid rendering the real columns/cards — the bypass gate lives in the board's
// drag handler, not in the column children.
vi.mock("../kanban-column", () => ({ KanbanColumn: () => null }));
vi.mock("../kanban-card", () => ({ KanbanCard: () => null }));
vi.mock("@/components/tasks/task-detail/task-action-dialogs", () => ({
RequiredNotesDialog: () => null,
}));
import { KanbanBoard } from "../kanban-board";
function buildTask(overrides: Partial<Task> = {}): Task {
return {
id: "t1",
title: "A task",
description: "desc",
acceptance_criteria: ["a"],
status: TaskStatus.IN_PROGRESS,
priority: 2,
sequence: 0,
team: Team.BACKEND,
created_by: "ceo",
assigned_to: null,
parent_task_id: null,
dependency_ids: [],
blocker_ids: [],
created_at: "2026-01-01T00:00:00Z",
updated_at: null,
claimed_at: null,
started_at: null,
completed_at: null,
target_date: null,
estimated_complexity: "M" as never,
nature: "feature" as never,
task_type: TaskType.CODE,
project_id: "p1",
docs_complete: false,
pr_created: false,
pm_approvals: {},
plan: null,
checkpoints: [],
progress_updates: [],
commits: [],
self_verified: false,
qa_verified: null,
sessions: [],
branch_name: null,
pr_number: null,
pr_url: null,
...overrides,
} as unknown as Task;
}
const COLUMNS = [
{ id: "pending", status: TaskStatus.PENDING, title: "Pending", color: "" },
{
id: "in_progress",
status: TaskStatus.IN_PROGRESS,
title: "In Progress",
color: "",
},
{ id: "completed", status: TaskStatus.COMPLETED, title: "Done", color: "" },
];
function drop(activeId: string, overId: TaskStatus) {
dragRef.onDragEnd?.({ active: { id: activeId }, over: { id: overId } });
}
describe("KanbanBoard — admin-override bypass confirmation (F020)", () => {
beforeEach(() => {
mutateAsync.mockClear();
refetch.mockClear();
tasksRef.current = [];
});
afterEach(() => {
vi.clearAllMocks();
});
it("confirms before a drag that skips lifecycle preconditions, then fires the override on confirm", async () => {
tasksRef.current = [
buildTask({ id: "t1", status: TaskStatus.IN_PROGRESS, pr_number: null }),
];
render(<KanbanBoard title="Board" columns={COLUMNS} />);
// Drag a PR-less task straight to Done — completing with no open PR skips
// the in-band gate. The board must NOT fire the override silently; it must
// surface what's skipped and wait for an explicit confirm.
drop("t1", TaskStatus.COMPLETED);
// The board holds the move and surfaces what's skipped — it must NOT fire
// the admin status-override silently.
await screen.findByText(/no open pr/i);
expect(
screen.getByText(/documentation not marked complete/i),
).toBeInTheDocument();
expect(mutateAsync).not.toHaveBeenCalled();
// Confirm the override — now the admin status-override fires.
fireEvent.click(screen.getByRole("button", { name: /override & move/i }));
await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1));
expect(mutateAsync).toHaveBeenCalledWith({
taskId: "t1",
updates: { status: TaskStatus.COMPLETED },
});
});
it("does not fire the override when the confirmation is cancelled", async () => {
tasksRef.current = [
buildTask({ id: "t1", status: TaskStatus.IN_PROGRESS, pr_number: null }),
];
render(<KanbanBoard title="Board" columns={COLUMNS} />);
drop("t1", TaskStatus.COMPLETED);
await screen.findByText(/no open pr/i);
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
await waitFor(() =>
expect(screen.queryByText(/no open pr/i)).not.toBeInTheDocument(),
);
expect(mutateAsync).not.toHaveBeenCalled();
});
it("fires the move directly when the drag skips no material precondition", async () => {
// pending -> claimed gates on nothing the panel can check — no dialog.
tasksRef.current = [
buildTask({ id: "t1", status: TaskStatus.PENDING, pr_number: null }),
];
render(<KanbanBoard title="Board" columns={COLUMNS} />);
drop("t1", TaskStatus.CLAIMED);
await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1));
expect(mutateAsync).toHaveBeenCalledWith({
taskId: "t1",
updates: { status: TaskStatus.CLAIMED },
});
// No bypass confirmation should ever have been surfaced.
expect(screen.queryByText(/override & move/i)).not.toBeInTheDocument();
});
});
@@ -0,0 +1,82 @@
import { TaskStatus, type Task } from "@/types";
// A drag on the operator kanban routes the status move through the admin
// status-override, which bypasses the in-band lifecycle validator entirely.
// That override is intentional — it's how an operator recovers a task wedged
// in a state with no valid in-band move. But it also lets a careless drag skip
// material preconditions silently (completing a task with no PR, QA-bypassing
// a task with no PR, finishing docs on a task whose docs aren't complete).
//
// This returns the human-readable preconditions the dragged move would skip,
// computed only from what the panel can see reliably on the task (and its
// in-list children). Precision over recall: we never claim a precondition is
// satisfied when we can't verify it, and we never false-alarm on a transition
// that gates on nothing we can check. An empty list means the drag skips no
// material precondition the panel can detect — proceed without a prompt.
// Targets whose in-band entry requires an open PR.
const PR_REQUIRED: ReadonlySet<TaskStatus> = new Set([
TaskStatus.AWAITING_QA,
TaskStatus.AWAITING_DOCUMENTATION,
TaskStatus.NEEDS_REVISION,
TaskStatus.AWAITING_PR_REVIEW,
TaskStatus.AWAITING_CEO_APPROVAL,
TaskStatus.COMPLETED,
]);
// Targets whose in-band entry requires the documentation phase to be complete.
const DOCS_REQUIRED: ReadonlySet<TaskStatus> = new Set([
TaskStatus.AWAITING_PM_REVIEW,
TaskStatus.COMPLETED,
]);
// Coordination-root targets: the in-band entry requires all subtasks terminal.
const SUBTASKS_TERMINAL_REQUIRED: ReadonlySet<TaskStatus> = new Set([
TaskStatus.AWAITING_PM_REVIEW,
TaskStatus.AWAITING_CEO_APPROVAL,
TaskStatus.COMPLETED,
]);
const TERMINAL: ReadonlySet<TaskStatus> = new Set([
TaskStatus.COMPLETED,
TaskStatus.CANCELLED,
]);
export function skippedPreconditions(
task: Task,
newStatus: TaskStatus,
allTasks: Task[],
): string[] {
const skipped: string[] = [];
if (PR_REQUIRED.has(newStatus) && task.pr_number == null) {
skipped.push("no open PR");
}
if (DOCS_REQUIRED.has(newStatus) && !task.docs_complete) {
skipped.push("documentation not marked complete");
}
// The submit-qa gate (verifying -> awaiting_qa) additionally requires
// self-verification, at least one linked commit, and a progress update.
if (newStatus === TaskStatus.AWAITING_QA) {
if (!task.self_verified) skipped.push("not self-verified");
if (task.commits.length === 0) skipped.push("no commits linked");
if (task.progress_updates.length === 0) skipped.push("no progress updates");
}
// Coordination-root targets require every subtask in a terminal state. The
// board's task list is team-filtered, so a root's children may live in other
// teams and be absent here — only warn on children we can actually see that
// are not terminal; never fabricate a "subtasks terminal" claim we can't
// verify.
if (SUBTASKS_TERMINAL_REQUIRED.has(newStatus)) {
const children = allTasks.filter((t) => t.parent_task_id === task.id);
const nonTerminal = children.filter((c) => !TERMINAL.has(c.status));
if (nonTerminal.length > 0) {
skipped.push(`${nonTerminal.length} non-terminal subtask(s)`);
}
}
return skipped;
}
@@ -25,6 +25,17 @@ import {
import { useState } from "react";
import { KanbanCard } from "./kanban-card";
import { RequiredNotesDialog } from "@/components/tasks/task-detail/task-action-dialogs";
import { skippedPreconditions } from "./bypass-preconditions";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
type NotesActionKind = "pass-qa" | "fail-qa" | "complete";
@@ -33,6 +44,16 @@ interface PendingNotesAction {
taskId: string;
}
// A drag that would skip material lifecycle preconditions (completing with no
// PR, QA-bypassing, finishing docs that aren't complete, …) is held for an
// explicit override confirmation. The admin status-override is intentional
// and stays intact — this only makes the bypass visible instead of silent.
interface PendingOverride {
task: Task;
newStatus: TaskStatus;
skipped: string[];
}
interface ColumnConfig {
id: string;
status: TaskStatus;
@@ -67,6 +88,8 @@ export function KanbanBoard({
const [activeTask, setActiveTask] = useState<Task | null>(null);
const [pendingNotesAction, setPendingNotesAction] =
useState<PendingNotesAction | null>(null);
const [pendingOverride, setPendingOverride] =
useState<PendingOverride | null>(null);
const [activeColumnIndex, setActiveColumnIndex] = useState(0);
const sensors = useSensors(
@@ -125,6 +148,19 @@ export function KanbanBoard({
}
}
// A drag on this board routes the status move through the admin
// status-override, which bypasses the in-band lifecycle validator. When
// that override would skip material preconditions the panel can detect
// (no open PR, docs not complete, not self-verified, non-terminal
// subtasks, …), hold the move for an explicit confirmation that surfaces
// exactly what's being skipped. The override stays — this only makes the
// bypass visible instead of silent.
const skipped = skippedPreconditions(task, newStatus, tasks || []);
if (skipped.length > 0) {
setPendingOverride({ task, newStatus, skipped });
return;
}
try {
await updateTask.mutateAsync({
taskId,
@@ -137,6 +173,23 @@ export function KanbanBoard({
}
};
const handleOverrideConfirm = async () => {
if (!pendingOverride) return;
const { task, newStatus } = pendingOverride;
try {
await updateTask.mutateAsync({
taskId: task.id,
updates: { status: newStatus },
});
toast.success(`Task moved to ${newStatus.replace(/_/g, " ")}`);
refetch();
} catch {
toast.error("Failed to move task");
} finally {
setPendingOverride(null);
}
};
const handleAction = async (action: string, taskId: string) => {
try {
switch (action) {
@@ -381,6 +434,37 @@ export function KanbanBoard({
{...notesDialogConfig[pendingNotesAction.kind]}
/>
)}
<AlertDialog
open={pendingOverride !== null}
onOpenChange={(open) => {
if (!open) setPendingOverride(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Override lifecycle preconditions?
</AlertDialogTitle>
<AlertDialogDescription>
This move routes through the admin status-override, which skips
the in-band lifecycle gate. The following preconditions would be
skipped:
</AlertDialogDescription>
</AlertDialogHeader>
<ul className="text-sm text-muted-foreground list-disc pl-6 space-y-1">
{pendingOverride?.skipped.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleOverrideConfirm}>
Override &amp; move
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}