diff --git a/.agents/skills/frontend/information-architecture/SKILL.md b/.agents/skills/frontend/information-architecture/SKILL.md
index 08242ed1..7e2aaeec 100644
--- a/.agents/skills/frontend/information-architecture/SKILL.md
+++ b/.agents/skills/frontend/information-architecture/SKILL.md
@@ -35,6 +35,7 @@ router, so it can only say what a value resolves to, never change the URL.
Rules derived from the 2026-08 audit (§6):
+- **A correction batch is reached from the batch that needs correcting**, never from a "new batch" form: the gallery header and the Batches row both offer it on a `completed` batch, capability-gated on `create_correction`. The annotator's read-only banner and the gallery's bulk bar *link* to it rather than duplicating it — creating a batch is a curation act, curation lives on the batch view, and a second place batches are made is a second place the rules can drift.
- **Dataset is first-class.** It is the product's central object and must be reachable in ≤1 click from any project tab. It is never gated behind, or discoverable only through, onboarding UI. Promotion success links onward to it; the gallery links to it once a batch is `completed`.
- **"Schema history" is not a sibling tab.** Version history lives inside the Schema tab, below the editor and beside the `VersionNavigator` seam. The two overlap on purpose: the navigator is the *reader* (one version, with what it changed), the history is the *ledger* (every version at once). `?tab=versions` remains as a redirect; it does not appear in the tab bar.
- **The 4-step checklist is onboarding, not navigation.** It retires itself twice over: when the journey is finished (`hasReleases` makes `done` derivable) and when somebody dismisses it. Dismissal is **per project** and persisted — finishing one project does not teach you the pipeline for the next. It gates nothing and is never the sole path to a screen.
diff --git a/frontend/app/cycle/cycle.spec.ts b/frontend/app/cycle/cycle.spec.ts
index 4df79598..f9c30d19 100644
--- a/frontend/app/cycle/cycle.spec.ts
+++ b/frontend/app/cycle/cycle.spec.ts
@@ -408,6 +408,42 @@ test("the whole cycle, from opening the app to a downloaded export", async ({ pa
);
});
+ await test.step("correct the completed batch, forward-only", async () => {
+ /*
+ * **The end of the forward-only story** (audit G6), against the real kernel.
+ *
+ * A completed batch has no exit and none is coming, so the product's answer
+ * to "this frame is wrong" is a new batch over the same frames recording
+ * where it came from. Three surfaces had been saying so while nothing could
+ * create one; this is the control they were pointing at.
+ *
+ * Run here rather than against stubs because the two claims worth making are
+ * about the kernel: that the parent is genuinely untouched, and that the
+ * child pins the project's *active* schema at its own approval rather than
+ * inheriting the parent's.
+ */
+ await page.getByTestId("correct-cycle-batch").click();
+ await expect(page.getByTestId("correction-dialog")).toBeVisible();
+ // The suggested name is the parent's, so the ordinary case costs no typing.
+ await expect(page.getByTestId("correction-name")).toHaveValue(/cycle-batch/);
+ await page.getByTestId("correction-submit").click();
+
+ // It navigates to the correction it just made, and that batch says what it
+ // corrects. One hop: the child names its parent, and a reader walks the
+ // chain for the origin.
+ await expect(page.getByTestId("gallery")).toBeVisible();
+ await expect(page.getByTestId("correction-of")).toContainText("Correction of cycle-batch");
+ await expect(page.getByTestId("batch-state")).toHaveText("pending approval");
+
+ // And the parent has not moved — which is the whole point of correcting
+ // forward instead of reopening.
+ await openProject(page, "batches");
+ await expect(page.getByTestId("state-cycle-batch")).toHaveText("completed");
+ await expect(page.getByTestId("promoted-count-cycle-batch")).toHaveText(
+ /3 of 3 in the dataset/,
+ );
+ });
+
await test.step("publish a release", async () => {
// **A tab, reached in one press.** It was behind the header's overflow menu,
// which is where a destination goes when the navigation has no room for it —
diff --git a/frontend/app/e2e/_wire.ts b/frontend/app/e2e/_wire.ts
index 2550ee0a..1bb84453 100644
--- a/frontend/app/e2e/_wire.ts
+++ b/frontend/app/e2e/_wire.ts
@@ -24,7 +24,7 @@ const BATCH_ACTIONS: Record = {
draft: ["approve", "edit_membership", "delete"],
approved: ["start", "repin", "delete"],
in_annotation: ["complete", "repin", "delete"],
- completed: ["promote"],
+ completed: ["promote", "create_correction"],
};
const JOB_ACTIONS: Record = {
diff --git a/frontend/app/src/routes.tsx b/frontend/app/src/routes.tsx
index a08ce9a5..5fe2c812 100644
--- a/frontend/app/src/routes.tsx
+++ b/frontend/app/src/routes.tsx
@@ -243,6 +243,10 @@ function Gallery(): JSX.Element {
// batch is finished, and it had no way to reach the one screen that shows
// what finishing it produced — a tab of the project now, not a route.
onOpenDataset={() => void navigate(PARENT.dataset(projectId))}
+ // A correction just cut, or this batch's own parent (audit G6). Same
+ // route the batch table's rows use — a batch is a batch, whichever screen
+ // named it.
+ onOpenBatch={(next) => void navigate(`/projects/${projectId}/batches/${next}`)}
/>
);
}
diff --git a/frontend/ui-core/src/annotator/AnnotationPage.tsx b/frontend/ui-core/src/annotator/AnnotationPage.tsx
index f1a2943c..7c72ef63 100644
--- a/frontend/ui-core/src/annotator/AnnotationPage.tsx
+++ b/frontend/ui-core/src/annotator/AnnotationPage.tsx
@@ -1032,6 +1032,35 @@ function Workspace({
Viewing only.
{closedBecause ?? settledBecause}
+ {/*
+ The sentence names a correction batch, and now it can reach one — the
+ last link in the forward-only story (audit G6). #306 wrote that
+ sentence deliberately pointing at something that did not exist yet,
+ on the grounds that naming the route onward beats a friendlier lie.
+ This is what it was waiting for.
+
+ It goes to the **gallery** rather than opening a dialog here, and that
+ is a product call rather than a shortcut: creating a batch is a
+ curation act, curation lives on the batch view, and a second place
+ batches are made is a second place the rules can drift. The annotator
+ says which way is forward and hands the person to the screen that
+ owns it, with the batch already in view.
+
+ Only for a batch the wire says can be corrected, so it is absent on a
+ frame that is merely settled inside an open batch — there the remedy
+ is on this toolbar and the banner already names the control.
+ */}
+ {onOpenGallery !== undefined &&
+ declares({ allowed_actions: batchActions }, BATCH_ACTION.createCorrection) && (
+
+ )}
)}
diff --git a/frontend/ui-core/src/data/capabilities.test.ts b/frontend/ui-core/src/data/capabilities.test.ts
index 32038c6f..ff33b42d 100644
--- a/frontend/ui-core/src/data/capabilities.test.ts
+++ b/frontend/ui-core/src/data/capabilities.test.ts
@@ -110,7 +110,16 @@ describe("the action names the client imports", () => {
// action arriving on the wire with no constant here is a rename nobody can
// perform, because the screens would have to spell it as a free string.
expect(Object.values(BATCH_ACTION).sort()).toEqual(
- ["approve", "complete", "delete", "edit_membership", "promote", "repin", "start"].sort(),
+ [
+ "approve",
+ "complete",
+ "create_correction",
+ "delete",
+ "edit_membership",
+ "promote",
+ "repin",
+ "start",
+ ].sort(),
);
expect(Object.values(JOB_ACTION).sort()).toEqual(["complete", "start"].sort());
expect(Object.values(ASSET_ACTION).sort()).toEqual(
diff --git a/frontend/ui-core/src/data/capabilities.ts b/frontend/ui-core/src/data/capabilities.ts
index e00c36e8..97cc5507 100644
--- a/frontend/ui-core/src/data/capabilities.ts
+++ b/frontend/ui-core/src/data/capabilities.ts
@@ -64,6 +64,7 @@ export const BATCH_ACTION = {
complete: "complete",
repin: "repin",
promote: "promote",
+ createCorrection: "create_correction",
editMembership: "edit_membership",
delete: "delete",
} as const satisfies Record;
diff --git a/frontend/ui-core/src/index.ts b/frontend/ui-core/src/index.ts
index 3d814994..c61810cb 100644
--- a/frontend/ui-core/src/index.ts
+++ b/frontend/ui-core/src/index.ts
@@ -197,6 +197,13 @@ export { BatchesScreen, type BatchesScreenProps } from "./screens/BatchesScreen.
export { GalleryScreen, type GalleryScreenProps } from "./screens/GalleryScreen.js";
export { ApproveDialog, BatchProgressBar } from "./screens/BatchLifecycle.js";
export { PromoteButton, promotionSummary, type PromoteButtonProps } from "./screens/PromoteButton.js";
+export {
+ CorrectionButton,
+ CorrectionOf,
+ defaultCorrectionName,
+ type CorrectionButtonProps,
+ type CorrectionScope,
+} from "./screens/CorrectionBatch.js";
export {
batchStateLabel,
segmentCounts,
diff --git a/frontend/ui-core/src/screens/BatchesScreen.tsx b/frontend/ui-core/src/screens/BatchesScreen.tsx
index 7f128896..761b091c 100644
--- a/frontend/ui-core/src/screens/BatchesScreen.tsx
+++ b/frontend/ui-core/src/screens/BatchesScreen.tsx
@@ -43,6 +43,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from ".
import { ApproveDialog, BatchProgressBar, CompleteBatchButton } from "./BatchLifecycle";
import { BATCH_STATE_VARIANT, batchStateLabel } from "./batchState";
import { SchemaForeshadow } from "./SchemaForeshadow";
+import { CorrectionButton, CorrectionOf } from "./CorrectionBatch";
import { PromoteButton } from "./PromoteButton";
import { useBatchTransition, useBatches, type Batch } from "./queries";
@@ -112,14 +113,27 @@ export function BatchesScreen({
{page.items.map((batch) => (
-
+
+
+ {/* Lineage in the listing, where a chain is actually
+ readable: the row says what it corrects, so the order
+ survives a sort by anything else. */}
+ one.id === batch.parent_batch_id)?.name
+ }
+ {...(batch.parent_batch_id == null
+ ? {}
+ : { onOpenParent: () => onOpenBatch(batch.parent_batch_id as string) })}
+ />
+
{/* The label the gallery header already uses (#292) — the
@@ -143,7 +157,11 @@ export function BatchesScreen({
one.parent_batch_id === batch.id).length
+ }
onApprove={() => setApproving(batch)}
+ onOpenBatch={onOpenBatch}
{...(onOpenDataset === undefined ? {} : { onOpenDataset })}
/>
@@ -181,12 +199,17 @@ export function BatchesScreen({
*/
function Lifecycle({
batch,
+ corrections,
onApprove,
onOpenDataset,
+ onOpenBatch,
}: {
readonly batch: Batch & { readonly projectId?: string };
+ /** How many corrections of this batch already exist, for the suggested name. */
+ readonly corrections: number;
readonly onApprove: () => void;
readonly onOpenDataset?: () => void;
+ readonly onOpenBatch?: (batchId: string) => void;
}): JSX.Element | null {
const start = useBatchTransition(batch.id, "start");
@@ -199,11 +222,25 @@ function Lifecycle({
// "safe to press twice" and "you cannot tell whether it worked" were the
// same button until #307's successor. See `PromoteButton`.
return (
-
+
+
+ {/*
+ Beside promote rather than in an overflow menu, and both are offered
+ because a completed batch has exactly two things left to do: put its
+ work in the trunk, and correct it. A menu would hide the second, which
+ is the one somebody is hunting for when a frame turns out wrong.
+ */}
+
+
);
}
if (declares(batch, BATCH_ACTION.approve)) {
diff --git a/frontend/ui-core/src/screens/CorrectionBatch.tsx b/frontend/ui-core/src/screens/CorrectionBatch.tsx
new file mode 100644
index 00000000..55b4cdbf
--- /dev/null
+++ b/frontend/ui-core/src/screens/CorrectionBatch.tsx
@@ -0,0 +1,309 @@
+/**
+ * Correcting a batch that is finished — audit gap G6, and the end of the
+ * forward-only story.
+ *
+ * ## What this replaces
+ *
+ * Nothing, which is the point. A `completed` batch is immutable as a workflow
+ * unit: the kernel gives it no exit and none is coming. The product's answer to
+ * "this frame is wrong" had been a dead end dressed three different ways — an
+ * annotator that opened fully editable and refused every save (#306 made it a
+ * viewer), a bulk bar whose buttons were live and whose every request 409'd
+ * (#305 disabled them with a reason), and a sentence naming a correction batch
+ * that nothing could create.
+ *
+ * Each of those now says the same thing and, from here, *points at the same
+ * control*. That is the difference between a refusal and a next step, and it is
+ * the whole reason those two tasks left the sentence in place rather than
+ * inventing a friendlier lie.
+ *
+ * ## Scope is a choice, and the default is the whole batch
+ *
+ * "Correct this batch" is the ordinary ask, so `all` is the default and sends no
+ * `asset_ids` at all — the server's own default is the parent's whole
+ * membership, and re-listing forty-eight ids to say so would be this screen
+ * telling the API something it already knows.
+ *
+ * `selection` exists because the other ordinary ask is *the three frames
+ * somebody found wrong*, and the gallery already has a selection to hand. It is
+ * offered only when there is one: a scope choice with an empty option is a
+ * choice between doing something and doing nothing.
+ *
+ * There is deliberately no "filtered set" option. The gallery's segments are a
+ * *view*, and a correction cut from whatever happens to be filtered at the
+ * moment of pressing is a batch nobody can describe afterwards — where a
+ * selection is a thing somebody chose.
+ */
+
+import { GitBranch } from "lucide-react";
+import { useState, type JSX } from "react";
+
+import { BATCH_ACTION, declares } from "../data/capabilities";
+import { refusalProse } from "../data/refusals";
+import { Button } from "../primitives/Button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogTitle,
+} from "../primitives/Dialog";
+import { FieldError, FieldHint, Input, Label } from "../primitives/Input";
+import { useCreateCorrection, type Batch } from "./queries";
+
+/** What a correction covers. `all` sends nothing and lets the server default. */
+export type CorrectionScope = "all" | "selection";
+
+/**
+ * What to call a correction of this batch, before anybody types anything.
+ *
+ * Pure and exported because it is the part with a decision in it: a name is
+ * required, and a dialog that opened blank would make the common case — "yes,
+ * correct this, that is all I meant" — cost a sentence of typing. Numbering by
+ * how many corrections already exist keeps a chain readable in a listing, and
+ * counting is the caller's job because only it has the listing.
+ */
+export function defaultCorrectionName(parent: string, existing: number): string {
+ return existing === 0 ? `${parent} — correction` : `${parent} — correction ${existing + 1}`;
+}
+
+export interface CorrectionButtonProps {
+ readonly batch: Batch;
+ readonly projectId: string;
+ /**
+ * How many corrections of this batch already exist, for the suggested name.
+ *
+ * Passed in rather than counted here: the caller is already holding the
+ * project's batch listing, and a second request to name a dialog would be a
+ * request nobody asked for.
+ */
+ readonly existingCorrections?: number;
+ /** The frames currently selected, when the caller has a selection to offer. */
+ readonly selection?: readonly string[];
+ /** Where to go once the correction exists. Absent leaves the caller where it is. */
+ readonly onOpenBatch?: (batchId: string) => void;
+ readonly className?: string;
+ /**
+ * Drive the dialog from outside, for a caller with a second way in.
+ *
+ * The gallery has two — the header button and the bulk bar's "Create one" —
+ * and two independent dialogs would be two states that can both be true. When
+ * this is supplied the component is controlled and its own button reports
+ * through `onOpenChange` rather than to itself.
+ */
+ readonly open?: boolean;
+ readonly onOpenChange?: (open: boolean) => void;
+}
+
+export function CorrectionButton({
+ batch,
+ projectId,
+ existingCorrections = 0,
+ selection,
+ onOpenBatch,
+ className,
+ open,
+ onOpenChange,
+}: CorrectionButtonProps): JSX.Element | null {
+ const [own, setOwn] = useState(false);
+ const showing = open ?? own;
+ const setShowing = onOpenChange ?? setOwn;
+
+ // Capability-gated like every other action in this product: `create_correction`
+ // is declared exactly while the batch is `completed`, and correcting an open
+ // batch is not a correction — it is the work, in the batch already there.
+ if (!declares(batch, BATCH_ACTION.createCorrection)) return null;
+
+ return (
+ <>
+
+ setShowing(false)}
+ {...(selection === undefined ? {} : { selection })}
+ {...(onOpenBatch === undefined ? {} : { onOpenBatch })}
+ />
+ >
+ );
+}
+
+function CorrectionDialog({
+ batch,
+ projectId,
+ existingCorrections,
+ selection,
+ open,
+ onClose,
+ onOpenBatch,
+}: {
+ readonly batch: Batch;
+ readonly projectId: string;
+ readonly existingCorrections: number;
+ readonly selection?: readonly string[];
+ readonly open: boolean;
+ readonly onClose: () => void;
+ readonly onOpenBatch?: (batchId: string) => void;
+}): JSX.Element {
+ const create = useCreateCorrection(projectId);
+ const suggested = defaultCorrectionName(batch.name, existingCorrections);
+ const [name, setName] = useState(suggested);
+ const [touched, setTouched] = useState(false);
+ const [scope, setScope] = useState("all");
+
+ const chosen = selection ?? [];
+ const canScopeToSelection = chosen.length > 0;
+ const effective: CorrectionScope = canScopeToSelection ? scope : "all";
+ const value = touched ? name : suggested;
+
+ const submit = (): void => {
+ create.mutate(
+ {
+ batchId: batch.id,
+ name: value,
+ // Omitted for `all`, so the server's own default answers — see the
+ // module docstring.
+ ...(effective === "selection" ? { assetIds: chosen } : {}),
+ },
+ {
+ onSuccess: (child) => {
+ onClose();
+ onOpenBatch?.(child.id);
+ },
+ },
+ );
+ };
+
+ return (
+
+ );
+}
+
+/**
+ * "Correction of X" — lineage, rendered where somebody is looking at the child.
+ *
+ * A batch's parent is one hop, not a root pointer: each records the one it was
+ * cut from, and a reader walks the chain for the origin. So this says *of what*
+ * and nothing about how deep the chain goes, which is the honest reading of the
+ * one field there is.
+ *
+ * `null` when there is no parent, which is most batches and is not a state worth
+ * drawing: "not a correction of anything" is the ordinary case, and a badge
+ * saying so on every batch would be noise on the many to inform the few.
+ */
+export function CorrectionOf({
+ parentName,
+ onOpenParent,
+}: {
+ readonly parentName: string | undefined;
+ readonly onOpenParent?: () => void;
+}): JSX.Element | null {
+ if (parentName === undefined) return null;
+ return (
+
+
+ Correction of{" "}
+ {onOpenParent === undefined ? (
+ parentName
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/frontend/ui-core/src/screens/GalleryScreen.tsx b/frontend/ui-core/src/screens/GalleryScreen.tsx
index 6951aeb1..7b1dab2d 100644
--- a/frontend/ui-core/src/screens/GalleryScreen.tsx
+++ b/frontend/ui-core/src/screens/GalleryScreen.tsx
@@ -53,6 +53,7 @@ import { AssetThumbnail } from "./AssetThumbnail";
import { BackLink } from "../patterns/BackLink";
import { parentLabel } from "../patterns/parentLabel";
import { ApproveDialog, BatchProgressBar, CompleteBatchButton } from "./BatchLifecycle";
+import { CorrectionButton, CorrectionOf } from "./CorrectionBatch";
import { PromoteButton } from "./PromoteButton";
import {
ASSET_ACTION,
@@ -83,6 +84,7 @@ import {
GALLERY_PAGE_SIZE,
useBatch,
useBatchAssets,
+ useBatches,
useBulkSetProgress,
useProject,
useSource,
@@ -145,6 +147,11 @@ export interface GalleryScreenProps {
readonly onBack?: () => void;
/** The project's schema tab, for the approve dialog's `SCHEMA_NOT_FOUND` remedy (#291). */
readonly onOpenSchema?: () => void;
+ /**
+ * Another batch of the same project — a correction just cut, or this one's
+ * parent. The app turns it into a route change; absent leaves both inert.
+ */
+ readonly onOpenBatch?: (batchId: string) => void;
/**
* The dataset — where a promotion from this screen lands (audit F18).
*
@@ -162,6 +169,7 @@ export function GalleryScreen({
onBack,
onOpenSchema,
onOpenDataset,
+ onOpenBatch,
}: GalleryScreenProps): JSX.Element {
const project = useProject(projectId);
const batch = useBatch(batchId);
@@ -174,6 +182,10 @@ export function GalleryScreen({
const [selected, setSelected] = useState>(new Set());
const [highlighted, setHighlighted] = useState(null);
const [approving, setApproving] = useState(false);
+ // Held here rather than inside `CorrectionButton`, because the gallery has two
+ // ways in — the header control and the bulk bar's "Create one" — and two
+ // independent dialogs would be two states that can both be true.
+ const [correcting, setCorrecting] = useState(false);
const anchor = useRef(null);
const minColumn = DENSITY_STEPS[density] ?? DENSITY_STEPS[DEFAULT_DENSITY];
@@ -300,6 +312,23 @@ export function GalleryScreen({
// batch out of them, and the bar states why its moves are unavailable rather
// than the screen refusing to let anything be picked.
const showsProgress = hasJobs(batch.data?.state);
+
+ /**
+ * This batch's place in a correction chain, both ways.
+ *
+ * Derived from the project's batch listing rather than fetched: it is one
+ * request the screen's siblings already make, and the two facts — how many
+ * corrections point at this one, and what this one points at — are a filter
+ * and a lookup over the same array. A dedicated read would be a second source
+ * for something already on screen.
+ */
+ const siblings = useBatches(projectId);
+ const corrections = (siblings.data?.items ?? []).filter(
+ (one) => one.parent_batch_id === batchId,
+ ).length;
+ const parentName = (siblings.data?.items ?? []).find(
+ (one) => one.id === batch.data?.parent_batch_id,
+ )?.name;
const counts = batch.data === undefined
? { all: total, unannotated: total, review: 0, done: 0 }
: segmentCounts(batch.data.progress);
@@ -311,6 +340,12 @@ export function GalleryScreen({
setSelected(new Set())}
+ onCorrect={() => setCorrecting(true)}
/>
)}
@@ -471,14 +507,28 @@ export function GalleryScreen({
function BatchHeader({
batch,
projectId,
+ corrections,
+ selected,
+ correcting,
+ onCorrectingChange,
+ parentName,
assets,
showsProgress,
onApprove,
onStartAnnotating,
onOpenDataset,
+ onOpenBatch,
}: {
readonly batch: Batch | undefined;
readonly projectId: string;
+ /** How many corrections of this batch exist, for the dialog's suggested name. */
+ readonly corrections: number;
+ readonly selected: ReadonlySet;
+ readonly correcting: boolean;
+ readonly onCorrectingChange: (open: boolean) => void;
+ /** The parent's name, when this batch is itself a correction. */
+ readonly parentName: string | undefined;
+ readonly onOpenBatch?: (batchId: string) => void;
readonly assets: readonly BatchAsset[];
readonly onOpenDataset?: () => void;
/** False for a draft, whose counts are documented zeros rather than data. */
@@ -554,6 +604,15 @@ function BatchHeader({
{facts.join(" · ")}
)}
+ {/* Lineage, on the child. One hop: this says *of what*, and a reader
+ walks the chain for the origin. Absent for the ordinary batch,
+ because "not a correction of anything" is most of them. */}
+ onOpenBatch(batch.parent_batch_id as string) })}
+ />
@@ -602,6 +661,26 @@ function BatchHeader({
evidence lives. Capability-gated and shared with that table rather than
spelled twice: `PromoteButton` owns the sentence and the reason.
*/}
+ {/*
+ The way out of a finished batch (audit G6). The gallery is the screen
+ somebody is on when they find the frame that is wrong, and until now
+ everything here that mentioned a correction batch was a sentence
+ pointing at nothing.
+
+ It takes the current selection, so "the three frames I have picked"
+ is one press rather than a second pass in the new batch.
+ */}
+ {batch !== undefined && (
+
+ )}
{batch !== undefined && (
void;
/** The batch's own state — the reason a move is unavailable, when it is. */
readonly batchState: string | undefined;
readonly selected: ReadonlySet;
@@ -1147,6 +1229,23 @@ function BulkBar({
{skippable.length === 0 && restorable.length === 0 && (
{withheld ?? "Nothing here can be skipped or restored."}
+ {/*
+ The sentence has said "corrections happen in a correction batch"
+ since #305, pointing at something that did not exist. It points at
+ the header's control now (audit G6), and the selection this bar is
+ already holding is what that control offers as a scope — so "these
+ three frames are wrong" is two presses rather than a second pass.
+ */}
+ {withheld !== null && onCorrect !== undefined && (
+
+ )}
)}
diff --git a/frontend/ui-core/src/screens/promote.test.tsx b/frontend/ui-core/src/screens/promote.test.tsx
index 9be37b7e..c25973cd 100644
--- a/frontend/ui-core/src/screens/promote.test.tsx
+++ b/frontend/ui-core/src/screens/promote.test.tsx
@@ -17,13 +17,14 @@
*/
import { QueryClient } from "@tanstack/react-query";
-import { render, screen } from "@testing-library/react";
+import { render, screen, waitFor } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { JSX, ReactNode } from "react";
import { ApiProvider } from "../data/ApiProvider";
import { writeToken } from "../data/session";
+import { CorrectionButton, CorrectionOf, defaultCorrectionName } from "./CorrectionBatch";
import { PromoteButton, promotionSummary } from "./PromoteButton";
import { batchActions } from "../testing/wire.fixtures.js";
import type { Batch } from "./queries";
@@ -66,11 +67,20 @@ const BATCH = "55555555-5555-4555-8555-555555555555";
type Answer = { status: number; body?: unknown };
let handlers: ((request: Request) => Answer | undefined)[] = [];
+/** What each non-GET carried, so a body assertion is about the wire. */
+const posted: { url: string; body: unknown }[] = [];
beforeEach(() => {
handlers = [];
writeToken("a-token");
+ posted.length = 0;
vi.stubGlobal("fetch", async (request: Request) => {
+ if (request.method !== "GET") {
+ // `promote` sends no body at all, and `JSON.parse("")` throws — which
+ // would fail every test in this file for a reason none of them is about.
+ const raw = await request.clone().text();
+ posted.push({ url: request.url, body: raw === "" ? undefined : JSON.parse(raw) });
+ }
for (const handler of handlers) {
const answer = handler(request);
if (answer !== undefined) {
@@ -205,3 +215,157 @@ describe("the control", () => {
expect(said).not.toContain("BATCH_NOT_COMPLETE");
});
});
+
+/**
+ * Correcting a finished batch — audit gap G6, and the end of the forward-only
+ * story.
+ *
+ * Three surfaces had been saying "corrections happen in a correction batch"
+ * while nothing could create one: the annotator's read-only banner (#306), the
+ * gallery's bulk bar (#305), and the settled-work sentence in both. Each named
+ * the route onward deliberately, on the grounds that it beats a friendlier lie.
+ * This is what they were waiting for.
+ */
+describe("naming a correction", () => {
+ it("suggests the parent's name, so the ordinary case costs no typing", () => {
+ expect(defaultCorrectionName("drive-01", 0)).toBe("drive-01 — correction");
+ });
+
+ it("numbers later ones, so a chain stays readable in a listing", () => {
+ expect(defaultCorrectionName("drive-01", 1)).toBe("drive-01 — correction 2");
+ expect(defaultCorrectionName("drive-01", 4)).toBe("drive-01 — correction 5");
+ });
+});
+
+describe("the correction control", () => {
+ function answersCorrection(): void {
+ handlers.push((request) =>
+ request.method === "POST" && request.url.includes("/corrections")
+ ? {
+ status: 201,
+ body: {
+ ...batch({ state: "draft", allowed_actions: batchActions("draft") }),
+ id: "child-batch",
+ name: "drive-01 — correction",
+ parent_batch_id: BATCH,
+ },
+ }
+ : undefined,
+ );
+ }
+
+ it("is drawn only where the batch declares it", () => {
+ // Correcting an open batch is not a correction — it is the work, in the
+ // batch already there.
+ render(
+ mount(
+ ,
+ ),
+ );
+ expect(screen.queryByTestId("correct-drive-01")).toBeNull();
+ });
+
+ it("sends no asset ids when the whole batch is the scope", async () => {
+ // The server's own default is the parent's whole membership, so re-listing
+ // forty-eight ids to say so would be telling the API something it knows.
+ answersCorrection();
+ render(mount());
+
+ await userEvent.click(screen.getByTestId("correct-drive-01"));
+ await userEvent.click(await screen.findByTestId("correction-submit"));
+
+ const sent = await waitFor(() => {
+ const found = posted.find((one) => one.url.includes("/corrections"));
+ expect(found).toBeDefined();
+ return found!;
+ });
+ expect(sent.body).toEqual({ name: "drive-01 — correction" });
+ });
+
+ it("offers the selection as a scope, and sends exactly it", async () => {
+ answersCorrection();
+ render(
+ mount(
+ ,
+ ),
+ );
+
+ await userEvent.click(screen.getByTestId("correct-drive-01"));
+ await userEvent.click(await screen.findByTestId("correction-scope-selection"));
+ await userEvent.click(screen.getByTestId("correction-submit"));
+
+ const sent = await waitFor(() => {
+ const found = posted.find((one) => one.url.includes("/corrections"));
+ expect(found).toBeDefined();
+ return found!;
+ });
+ expect(sent.body).toEqual({ name: "drive-01 — correction", asset_ids: ["a", "b"] });
+ });
+
+ it("offers no selection scope when there is nothing selected", async () => {
+ // A scope choice whose second option covers nothing is a choice between
+ // doing something and doing nothing.
+ answersCorrection();
+ render(mount());
+
+ await userEvent.click(screen.getByTestId("correct-drive-01"));
+
+ await screen.findByTestId("correction-scope-all");
+ expect(screen.queryByTestId("correction-scope-selection")).toBeNull();
+ });
+
+ it("goes to the correction it just made", async () => {
+ answersCorrection();
+ const opened = vi.fn();
+ render(mount());
+
+ await userEvent.click(screen.getByTestId("correct-drive-01"));
+ await userEvent.click(await screen.findByTestId("correction-submit"));
+
+ await waitFor(() => expect(opened).toHaveBeenCalledWith("child-batch"));
+ });
+
+ it("renders a refusal as prose and stays open", async () => {
+ handlers.push((request) =>
+ request.method === "POST"
+ ? { status: 409, body: { code: "INVALID_TRANSITION", message: "not completed" } }
+ : undefined,
+ );
+ render(mount());
+
+ await userEvent.click(screen.getByTestId("correct-drive-01"));
+ await userEvent.click(await screen.findByTestId("correction-submit"));
+
+ const said = (await screen.findByTestId("correction-error")).textContent ?? "";
+ expect(said).toContain("already moved on");
+ expect(said).not.toContain("INVALID_TRANSITION");
+ // Still open: a refusal a dialog closes over is a refusal nobody reads.
+ expect(screen.queryByTestId("correction-dialog")).not.toBeNull();
+ });
+});
+
+describe("lineage", () => {
+ it("says what a batch corrects", () => {
+ render(mount());
+ expect(screen.getByTestId("correction-of").textContent).toContain("Correction of drive-01");
+ });
+
+ it("says nothing for a batch that corrects nothing", () => {
+ // Most batches. A badge saying "not a correction" on every one would be
+ // noise on the many to inform the few.
+ render(mount());
+ expect(screen.queryByTestId("correction-of")).toBeNull();
+ });
+
+ it("links to the parent when the host can open one", async () => {
+ const opened = vi.fn();
+ render(mount());
+
+ await userEvent.click(screen.getByTestId("open-parent-batch"));
+
+ expect(opened).toHaveBeenCalledOnce();
+ });
+});
diff --git a/frontend/ui-core/src/screens/queries.ts b/frontend/ui-core/src/screens/queries.ts
index a4cb88d7..c0620e9a 100644
--- a/frontend/ui-core/src/screens/queries.ts
+++ b/frontend/ui-core/src/screens/queries.ts
@@ -59,6 +59,7 @@ import {
checkListReleases,
checkListSchemaVersions,
checkListSources,
+ checkCreateCorrectionBatch,
checkPromoteBatch,
checkPublishRelease,
checkRegisterImageSource,
@@ -1001,6 +1002,50 @@ export function useFormats() {
});
}
+/**
+ * Cut a draft batch that corrects a completed one.
+ *
+ * **The forward-only model's one write.** A completed batch has no exit — the
+ * kernel gives it none and none is coming — so changing settled work means a new
+ * batch over the same assets, recording `parent_batch_id` back to the one it
+ * corrects. Nothing about the parent moves.
+ *
+ * `assetIds` omitted means the parent's **whole membership**, which is the
+ * server's default and the ordinary ask. A subset is the other one.
+ */
+export function useCreateCorrection(projectId: string) {
+ const client = useApiClient();
+ const queries = useQueryClient();
+ return useMutation({
+ mutationFn: async (input: {
+ readonly batchId: string;
+ readonly name: string;
+ readonly assetIds?: readonly string[];
+ }): Promise =>
+ unwrap(
+ await client.POST("/batches/{batch_id}/corrections", {
+ params: { path: { batch_id: input.batchId } },
+ body: {
+ name: input.name,
+ // Omitted rather than sent empty when the caller wants everything:
+ // `[]` and "all of them" are the same value on this route, and
+ // relying on that coincidence would break the moment it stops being
+ // one. `BatchCreate` already spells the opposite meaning.
+ ...(input.assetIds === undefined ? {} : { asset_ids: [...input.assetIds] }),
+ },
+ }),
+ checkCreateCorrectionBatch,
+ ),
+ onSuccess: () => {
+ // A new batch in the project's listing, and the parent's own read moves
+ // too — nothing on it changed, but a screen deriving "does this have
+ // corrections" from the listing needs the new row.
+ void queries.invalidateQueries({ queryKey: ["batches"] });
+ void queries.invalidateQueries({ queryKey: ["projects", projectId] });
+ },
+ });
+}
+
/**
* Promote a completed batch into the trunk. Idempotent — a union, not an append.
*
diff --git a/frontend/ui-core/src/testing/wire.fixtures.ts b/frontend/ui-core/src/testing/wire.fixtures.ts
index 34ddbf0b..f0e02b98 100644
--- a/frontend/ui-core/src/testing/wire.fixtures.ts
+++ b/frontend/ui-core/src/testing/wire.fixtures.ts
@@ -30,7 +30,7 @@ const BATCH_ACTIONS: Record = {
draft: ["approve", "edit_membership", "delete"],
approved: ["start", "repin", "delete"],
in_annotation: ["complete", "repin", "delete"],
- completed: ["promote"],
+ completed: ["promote", "create_correction"],
};
/** `job_actions`, given an open batch and whether every asset has settled. */