diff --git a/CHANGELOG.md b/CHANGELOG.md
index 14d3f6e..53d4ee5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,12 @@ All notable changes to CorpusKit will be documented here. The project follows
## [Unreleased]
+### Added
+
+- Append-only corpus version creation from manual sentences or bounded UTF-8 TXT, CSV, and JSON
+ imports, with parent lineage, atomic quota accounting, audit evidence, API contracts, and an
+ accessible project-workbench flow.
+
## [0.1.0-alpha.1] - 2026-08-12
### Added
diff --git a/README.md b/README.md
index 607ff41..ef4de38 100644
--- a/README.md
+++ b/README.md
@@ -227,12 +227,11 @@ and session-encryption keys, and an explicit fixed internal API URL. Missing or
auth/session infrastructure fails closed and never enables an in-memory fallback.
The `/projects` workbench now demonstrates tenant-scoped project creation, bounded manual or
-UTF-8 TXT/CSV/JSON corpus import, immutable version inspection, deterministic exports, and
-owner/admin-confirmed project deletion.
+UTF-8 TXT/CSV/JSON corpus import, append-only immutable corpus versions with explicit parent
+lineage, version inspection, deterministic exports, and owner/admin-confirmed project deletion.
The API router is integrated with the durable control plane through one application-owned
database lifecycle; see [`docs/product/project-workspaces.md`](https://github.com/jemsbhai/corpuskit/blob/main/docs/product/project-workspaces.md).
-Project/corpus update, individual corpus deletion, and creation of later corpus versions are not
-presented as available.
+Project/corpus metadata update and individual corpus deletion are not presented as available.
The application also includes bounded G2P, PHOIBLE inventory, evaluation, distribution/text
quality/error-rate/trajectory analysis, six-algorithm selection comparison, repository
diff --git a/apps/web/README.md b/apps/web/README.md
index 149e5d0..373a2ab 100644
--- a/apps/web/README.md
+++ b/apps/web/README.md
@@ -119,6 +119,9 @@ npm run test:e2e --workspace @corpuskit/web
CORPUSKIT_LIVE_BASE_URL=http://127.0.0.1:3000 npm run test:e2e:live --workspace @corpuskit/web
```
+The explicit live runner executes both the end-to-end demo and the project workspace history
+flow, including a real immutable successor append and historical-version export.
+
The scoped workbench configuration enforces at least 90% branch coverage per new state/transport
module. Playwright runs every workbench route through Chromium, Firefox, and WebKit with axe,
console/page/request failure checks, keyboard reachability, and a 320-pixel/200%-text layout
diff --git a/apps/web/e2e/projects-live.spec.ts b/apps/web/e2e/projects-live.spec.ts
index a6a2d79..b94f15b 100644
--- a/apps/web/e2e/projects-live.spec.ts
+++ b/apps/web/e2e/projects-live.spec.ts
@@ -1,11 +1,6 @@
import { expect, test } from "@playwright/test";
-test.skip(
- !process.env.CORPUSKIT_LIVE_STACK,
- "set CORPUSKIT_LIVE_STACK=1 for real API acceptance",
-);
-
-test("real stack creates a tenant project and immutable manual corpus", async ({
+test("real stack creates a corpus, appends a version, and preserves history", async ({
page,
}) => {
const suffix = `${Date.now()}-${test.info().project.name}`;
@@ -24,6 +19,23 @@ test("real stack creates a tenant project and immutable manual corpus", async ({
await expect(
page.getByRole("table", { name: /Normalized sentences/ }),
).toContainText("你好世界");
+ await page.getByLabel("Version eSpeak language").fill("en-gb");
+ await page
+ .getByLabel("Version sentences")
+ .fill("Revised hello world\nA second immutable sentence");
+ await page.getByRole("button", { name: "Create version" }).click();
+ await expect(page.getByRole("button", { name: /Version 2/ })).toHaveAttribute(
+ "aria-pressed",
+ "true",
+ );
+ await expect(
+ page.getByRole("table", { name: /Normalized sentences/ }),
+ ).toContainText("A second immutable sentence");
+
+ await page.getByRole("button", { name: /Version 1/ }).click();
+ await expect(
+ page.getByRole("table", { name: /Normalized sentences/ }),
+ ).toContainText("你好世界");
const downloadPromise = page.waitForEvent("download");
await page.getByRole("link", { name: "JSON" }).click();
const download = await downloadPromise;
diff --git a/apps/web/e2e/projects.spec.ts b/apps/web/e2e/projects.spec.ts
index f2d37a8..0853081 100644
--- a/apps/web/e2e/projects.spec.ts
+++ b/apps/web/e2e/projects.spec.ts
@@ -30,6 +30,14 @@ const version = {
corpusgen_version: "0.1.7",
created_at: "2026-08-11T00:00:00Z",
};
+const versionTwo = {
+ ...version,
+ id: "00000000-0000-4000-8000-000000000104",
+ parent_version_id: version.id,
+ version_number: 2,
+ language: "en-gb",
+ content_sha256: "b".repeat(64),
+};
test("manual corpus workflow is keyboard-accessible and exports are downloadable", async ({
page,
@@ -147,3 +155,79 @@ test("CSV import requires an explicit text column", async ({ page }) => {
expect(uploadBody).toContain("seed.csv");
expect(uploadBody).toContain("text/csv");
});
+
+test("manual corpus version creation refreshes and selects immutable history", async ({
+ page,
+}) => {
+ let submitted: unknown = null;
+ let appended = false;
+ await page.route("**/api/v1/**", async (route) => {
+ const request = route.request();
+ const path = new URL(request.url()).pathname;
+ if (path === "/api/v1/auth/me" && request.method() === "GET") {
+ await route.fulfill({ json: principal });
+ } else if (path === "/api/v1/projects" && request.method() === "GET") {
+ await route.fulfill({ json: [project] });
+ } else if (
+ path.endsWith(`/projects/${project.id}/corpora`) &&
+ request.method() === "GET"
+ ) {
+ await route.fulfill({ json: [corpus] });
+ } else if (
+ path.endsWith(`/corpora/${corpus.id}/versions`) &&
+ request.method() === "POST"
+ ) {
+ submitted = request.postDataJSON();
+ appended = true;
+ await route.fulfill({ status: 201, json: versionTwo });
+ } else if (
+ path.endsWith(`/corpora/${corpus.id}/versions`) &&
+ request.method() === "GET"
+ ) {
+ await route.fulfill({
+ json: appended ? [version, versionTwo] : [version],
+ });
+ } else if (path.endsWith(`/versions/${version.id}/sentences`)) {
+ await route.fulfill({
+ json: [
+ { ordinal: 0, original_text: "First", normalized_text: "First" },
+ ],
+ });
+ } else if (path.endsWith(`/versions/${versionTwo.id}/sentences`)) {
+ await route.fulfill({
+ json: [
+ {
+ ordinal: 0,
+ original_text: " Revised ",
+ normalized_text: "Revised",
+ },
+ ],
+ });
+ } else {
+ await route.fulfill({ status: 404 });
+ }
+ });
+
+ await page.goto("/projects");
+ await page.getByRole("button", { name: /Demo project/ }).click();
+ await page.getByRole("button", { name: /Unicode seed/ }).click();
+ await page.getByLabel("Version eSpeak language").fill("en-gb");
+ await page.getByLabel("Version sentences").fill(" Revised ");
+ await page.getByRole("button", { name: "Create version" }).click();
+
+ await expect
+ .poll(() => submitted)
+ .toEqual({
+ language: "en-gb",
+ sentences: [" Revised "],
+ });
+ await expect(page.getByRole("button", { name: /Version 2/ })).toHaveAttribute(
+ "aria-pressed",
+ "true",
+ );
+ await expect(
+ page.getByRole("table", { name: /Normalized sentences/ }),
+ ).toContainText("Revised");
+ const accessibility = await new AxeBuilder({ page }).analyze();
+ expect(accessibility.violations).toEqual([]);
+});
diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts
index 2b60590..7036e38 100644
--- a/apps/web/playwright.config.ts
+++ b/apps/web/playwright.config.ts
@@ -2,7 +2,7 @@ import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
- testIgnore: ["**/demo-live.spec.ts"],
+ testIgnore: ["**/demo-live.spec.ts", "**/projects-live.spec.ts"],
fullyParallel: true,
forbidOnly: Boolean(process.env.CI),
// Flaky acceptance checks must fail visibly; CI does not conceal them with retries.
diff --git a/apps/web/playwright.live.config.ts b/apps/web/playwright.live.config.ts
index bfa7038..790c778 100644
--- a/apps/web/playwright.live.config.ts
+++ b/apps/web/playwright.live.config.ts
@@ -8,7 +8,7 @@ if (!baseURL)
export default defineConfig({
testDir: "./e2e",
- testMatch: "demo-live.spec.ts",
+ testMatch: ["demo-live.spec.ts", "projects-live.spec.ts"],
fullyParallel: false,
forbidOnly: true,
retries: 0,
diff --git a/apps/web/src/app/projects.css b/apps/web/src/app/projects.css
index eb75f0d..8810df6 100644
--- a/apps/web/src/app/projects.css
+++ b/apps/web/src/app/projects.css
@@ -246,6 +246,32 @@
margin-top: 2rem;
}
+.version-layout {
+ margin-top: 2rem;
+ display: grid;
+ grid-template-columns: minmax(0, 1.35fr) minmax(18rem, 0.65fr);
+ align-items: start;
+ gap: 1rem;
+}
+
+.version-layout .version-browser {
+ min-width: 0;
+ margin-top: 0;
+}
+
+.version-form > .field-help {
+ margin: -0.5rem 0 1rem;
+}
+
+.version-permissions-note {
+ margin: 0;
+ padding: 1rem;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-md);
+ background: var(--paper);
+ color: var(--ink-soft);
+}
+
.version-strip {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -333,7 +359,8 @@
@media (max-width: 900px) {
.projects-intro,
.project-layout,
- .corpus-layout {
+ .corpus-layout,
+ .version-layout {
grid-template-columns: 1fr;
gap: 1.4rem;
}
diff --git a/apps/web/src/app/projects/page.tsx b/apps/web/src/app/projects/page.tsx
index 429c228..70d6d22 100644
--- a/apps/web/src/app/projects/page.tsx
+++ b/apps/web/src/app/projects/page.tsx
@@ -5,7 +5,7 @@ import { ProjectWorkbench } from "@/components/project-workbench";
export const metadata: Metadata = {
title: "Project workspaces",
description:
- "Create projects and import, inspect, and export immutable speech corpora.",
+ "Create projects and build, inspect, and export immutable speech corpus histories.",
};
export default function ProjectsPage() {
@@ -21,14 +21,14 @@ export default function ProjectsPage() {
Build a reproducible corpus from manual sentences or a bounded UTF-8
- file, then inspect its immutable initial version and verified
- exports.
+ file, add immutable successor versions, then inspect any snapshot
+ and its verified exports.
- This release creates and reads projects and version-1 corpora, and
- owners/admins can request retention-governed project deletion with
- exact confirmation. Project updates, individual corpus deletion, and
- later corpus versions are not yet available.
+ Owners, admins, and editors can create corpora and later immutable
+ versions. Owners/admins can request retention-governed project
+ deletion with exact confirmation. Project updates and individual
+ corpus deletion are not yet available.
diff --git a/apps/web/src/components/project-workbench.test.tsx b/apps/web/src/components/project-workbench.test.tsx
index c8b4dfd..beef11c 100644
--- a/apps/web/src/components/project-workbench.test.tsx
+++ b/apps/web/src/components/project-workbench.test.tsx
@@ -12,9 +12,11 @@ import { ProjectWorkbench } from "./project-workbench";
import { ProjectProvider } from "./project-context";
import {
createManualCorpus,
+ createManualVersion,
createProject,
getCurrentPrincipal,
importCorpus,
+ importCorpusVersion,
listCorpora,
listProjects,
listSentences,
@@ -27,9 +29,11 @@ vi.mock("@/lib/projects", async (importOriginal) => {
return {
...original,
createManualCorpus: vi.fn(),
+ createManualVersion: vi.fn(),
createProject: vi.fn(),
getCurrentPrincipal: vi.fn(),
importCorpus: vi.fn(),
+ importCorpusVersion: vi.fn(),
listCorpora: vi.fn(),
listProjects: vi.fn(),
listSentences: vi.fn(),
@@ -192,11 +196,15 @@ describe("ProjectWorkbench", () => {
it("creates a manual corpus then exposes digest, sentences, and all exports", async () => {
const user = userEvent.setup();
+ let corpusCreated = false;
vi.mocked(listProjects).mockResolvedValue([project]);
- vi.mocked(createManualCorpus).mockResolvedValue({ corpus, version });
- vi.mocked(listCorpora)
- .mockResolvedValueOnce([])
- .mockResolvedValueOnce([corpus]);
+ vi.mocked(createManualCorpus).mockImplementation(async () => {
+ corpusCreated = true;
+ return { corpus, version };
+ });
+ vi.mocked(listCorpora).mockImplementation(async () =>
+ corpusCreated ? [corpus] : [],
+ );
vi.mocked(listVersions).mockResolvedValue([version]);
vi.mocked(listSentences).mockResolvedValue([
{ ordinal: 0, original_text: " Héllo ", normalized_text: "Héllo" },
@@ -322,9 +330,10 @@ describe("ProjectWorkbench", () => {
it("browses multiple immutable versions and sanitizes read failures", async () => {
const user = userEvent.setup();
let rejectVersionTwo = false;
+ const latestVersion = { ...versionTwo, language: "fr-fr" };
vi.mocked(listProjects).mockResolvedValue([project]);
vi.mocked(listCorpora).mockResolvedValue([corpus]);
- vi.mocked(listVersions).mockResolvedValue([version, versionTwo]);
+ vi.mocked(listVersions).mockResolvedValue([version, latestVersion]);
vi.mocked(listSentences).mockImplementation(
async (_projectId, _corpusId, versionId) => {
if (versionId === versionTwo.id) {
@@ -360,6 +369,9 @@ describe("ProjectWorkbench", () => {
expect(await screen.findByRole("status")).toHaveTextContent(
"Version 1 loaded",
);
+ expect(screen.getByLabelText("Version eSpeak language")).toHaveValue(
+ "fr-fr",
+ );
expect(screen.getAllByText("First")).toHaveLength(2);
rejectVersionTwo = true;
await user.click(screen.getByRole("button", { name: /Version 2/ }));
@@ -369,6 +381,311 @@ describe("ProjectWorkbench", () => {
expect(screen.getByRole("alert")).not.toHaveTextContent("database");
});
+ it("creates a manual successor then refreshes and selects it", async () => {
+ const user = userEvent.setup();
+ let created = false;
+ const createdVersion = {
+ ...versionTwo,
+ language: "fr-fr",
+ sentence_count: 2,
+ };
+ vi.mocked(listProjects).mockResolvedValue([project]);
+ vi.mocked(listCorpora).mockResolvedValue([corpus]);
+ vi.mocked(listVersions).mockImplementation(async () =>
+ created ? [version, createdVersion] : [version],
+ );
+ vi.mocked(listSentences).mockImplementation(
+ async (_projectId, _corpusId, versionId) =>
+ versionId === createdVersion.id
+ ? [
+ {
+ ordinal: 0,
+ original_text: " Première ",
+ normalized_text: "Première",
+ },
+ {
+ ordinal: 1,
+ original_text: "Deuxième",
+ normalized_text: "Deuxième",
+ },
+ ]
+ : [
+ {
+ ordinal: 0,
+ original_text: "First",
+ normalized_text: "First",
+ },
+ ],
+ );
+ vi.mocked(createManualVersion).mockImplementation(async () => {
+ created = true;
+ return createdVersion;
+ });
+ renderWorkbench();
+
+ await user.click(
+ await screen.findByRole("button", { name: /Demo project/ }),
+ );
+ await user.click(
+ await screen.findByRole("button", { name: /Unicode seed/ }),
+ );
+ const language = await screen.findByLabelText("Version eSpeak language");
+ await waitFor(() =>
+ expect(
+ screen.getByRole("button", { name: "Create version" }),
+ ).toBeEnabled(),
+ );
+ await user.clear(language);
+ await user.type(language, "fr-fr");
+ fireEvent.change(screen.getByLabelText(/Version sentences/), {
+ target: { value: " Première \n\nDeuxième" },
+ });
+ await user.click(screen.getByRole("button", { name: "Create version" }));
+
+ await waitFor(() =>
+ expect(createManualVersion).toHaveBeenCalledWith(project.id, corpus.id, {
+ language: "fr-fr",
+ sentences: [" Première ", "", "Deuxième"],
+ }),
+ );
+ expect(await screen.findByRole("status")).toHaveTextContent(
+ "version 2 created and selected",
+ );
+ const createdButton = screen.getByRole("button", { name: /Version 2/ });
+ expect(createdButton).toHaveAttribute("aria-pressed", "true");
+ expect(createdButton).toHaveFocus();
+ expect(screen.getByText(createdVersion.content_sha256)).toBeVisible();
+ expect(screen.getByLabelText(/Version sentences/)).toHaveValue("");
+ expect(screen.getAllByText("Deuxième")).toHaveLength(2);
+ });
+
+ it("reports a committed version when its follow-up refresh fails", async () => {
+ const user = userEvent.setup();
+ let created = false;
+ vi.mocked(listProjects).mockResolvedValue([project]);
+ vi.mocked(listCorpora).mockResolvedValue([corpus]);
+ vi.mocked(listVersions).mockImplementation(async () => {
+ if (created) throw new Error("replica unavailable");
+ return [version];
+ });
+ vi.mocked(listSentences).mockResolvedValue([
+ { ordinal: 0, original_text: "First", normalized_text: "First" },
+ ]);
+ vi.mocked(createManualVersion).mockImplementation(async () => {
+ created = true;
+ return versionTwo;
+ });
+ renderWorkbench();
+
+ await user.click(
+ await screen.findByRole("button", { name: /Demo project/ }),
+ );
+ await user.click(
+ await screen.findByRole("button", { name: /Unicode seed/ }),
+ );
+ await user.type(
+ await screen.findByLabelText(/Version sentences/),
+ "Second",
+ );
+ await user.click(screen.getByRole("button", { name: "Create version" }));
+
+ expect(await screen.findByRole("status")).toHaveTextContent(
+ "version 2 was created, but its sentences could not be refreshed",
+ );
+ const recovered = screen.getByRole("button", { name: /Version 2/ });
+ expect(recovered).toHaveAttribute("aria-pressed", "true");
+ expect(recovered).toHaveFocus();
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ expect(screen.getByLabelText(/Version sentences/)).toHaveValue("");
+ });
+
+ it("does not apply an append response after the user selects another corpus", async () => {
+ const user = userEvent.setup();
+ const otherCorpus = {
+ ...corpus,
+ id: "corpus-2",
+ name: "Other corpus",
+ };
+ const otherVersion = {
+ ...version,
+ id: "other-version-1",
+ corpus_id: otherCorpus.id,
+ content_sha256: "c".repeat(64),
+ };
+ let appendResolved = false;
+ let staleRefreshAttempted = false;
+ let resolveAppend!: (value: typeof versionTwo) => void;
+ vi.mocked(listProjects).mockResolvedValue([project]);
+ vi.mocked(listCorpora).mockResolvedValue([corpus, otherCorpus]);
+ vi.mocked(listVersions).mockImplementation(async (_projectId, corpusId) => {
+ if (appendResolved && corpusId === corpus.id)
+ staleRefreshAttempted = true;
+ return corpusId === otherCorpus.id ? [otherVersion] : [version];
+ });
+ vi.mocked(listSentences).mockImplementation(
+ async (_projectId, corpusId) => [
+ {
+ ordinal: 0,
+ original_text: corpusId === otherCorpus.id ? "Other" : "First",
+ normalized_text: corpusId === otherCorpus.id ? "Other" : "First",
+ },
+ ],
+ );
+ vi.mocked(createManualVersion).mockReturnValue(
+ new Promise((resolve) => {
+ resolveAppend = resolve;
+ }),
+ );
+ renderWorkbench();
+
+ await user.click(
+ await screen.findByRole("button", { name: /Demo project/ }),
+ );
+ await user.click(
+ await screen.findByRole("button", { name: /Unicode seed/ }),
+ );
+ await user.type(
+ await screen.findByLabelText(/Version sentences/),
+ "Second",
+ );
+ await user.click(screen.getByRole("button", { name: "Create version" }));
+ await waitFor(() => expect(createManualVersion).toHaveBeenCalledTimes(1));
+ await user.click(screen.getByRole("button", { name: /Other corpus/ }));
+ await waitFor(() =>
+ expect(
+ screen.getByRole("button", { name: /Other corpus/ }),
+ ).toHaveAttribute("aria-pressed", "true"),
+ );
+ expect(await screen.findByText(otherVersion.content_sha256)).toBeVisible();
+
+ appendResolved = true;
+ resolveAppend(versionTwo);
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(staleRefreshAttempted).toBe(false);
+ expect(screen.getByText(otherVersion.content_sha256)).toBeVisible();
+ expect(
+ screen.queryByText(versionTwo.content_sha256),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: /Other corpus/ }),
+ ).toHaveAttribute("aria-pressed", "true");
+ });
+
+ it("lets an editor import a version file and clears it after selection", async () => {
+ const user = userEvent.setup();
+ let created = false;
+ vi.mocked(getCurrentPrincipal).mockResolvedValue({
+ subject: "editor-1",
+ organization_id: "00000000-0000-4000-8000-000000000001",
+ role: "editor",
+ display_name: "Editor",
+ });
+ vi.mocked(listProjects).mockResolvedValue([project]);
+ vi.mocked(listCorpora).mockResolvedValue([corpus]);
+ vi.mocked(listVersions).mockImplementation(async () =>
+ created ? [version, versionTwo] : [version],
+ );
+ vi.mocked(listSentences).mockResolvedValue([]);
+ vi.mocked(importCorpusVersion).mockImplementation(async () => {
+ created = true;
+ return versionTwo;
+ });
+ renderWorkbench();
+
+ await user.click(
+ await screen.findByRole("button", { name: /Demo project/ }),
+ );
+ await user.click(
+ await screen.findByRole("button", { name: /Unicode seed/ }),
+ );
+ expect(
+ await screen.findByRole("form", {
+ name: "Create the next immutable version",
+ }),
+ ).toBeVisible();
+ expect(
+ screen.queryByRole("button", { name: "Delete project" }),
+ ).not.toBeInTheDocument();
+ await waitFor(() =>
+ expect(
+ screen.getByRole("button", { name: "Create version" }),
+ ).toBeEnabled(),
+ );
+ await user.click(
+ screen.getByRole("radio", { name: "Version file import" }),
+ );
+ await user.selectOptions(
+ screen.getByLabelText("Version file format"),
+ "csv",
+ );
+ await user.clear(screen.getByLabelText("Version CSV text column"));
+ await user.type(
+ screen.getByLabelText("Version CSV text column"),
+ "utterance",
+ );
+ const file = new File(["utterance\nSecond\n"], "version.csv", {
+ type: "text/csv",
+ });
+ const input = screen.getByLabelText(
+ "UTF-8 CSV version file",
+ ) as HTMLInputElement;
+ await user.upload(input, file);
+ expect(input.files?.[0]).toBe(file);
+ fireEvent.submit(input.closest("form")!);
+
+ await waitFor(() =>
+ expect(importCorpusVersion).toHaveBeenCalledWith(project.id, corpus.id, {
+ language: "en-us",
+ format: "csv",
+ textColumn: "utterance",
+ file,
+ }),
+ );
+ expect(await screen.findByRole("status")).toHaveTextContent(
+ "version 2 created and selected",
+ );
+ expect(input.value).toBe("");
+ expect(input.files).toHaveLength(0);
+ });
+
+ it("keeps version creation read-only for viewers", async () => {
+ const user = userEvent.setup();
+ vi.mocked(getCurrentPrincipal).mockResolvedValue({
+ subject: "viewer-1",
+ organization_id: "00000000-0000-4000-8000-000000000001",
+ role: "viewer",
+ display_name: null,
+ });
+ vi.mocked(listProjects).mockResolvedValue([project]);
+ vi.mocked(listCorpora).mockResolvedValue([corpus]);
+ vi.mocked(listVersions).mockResolvedValue([version]);
+ renderWorkbench();
+
+ await user.click(
+ await screen.findByRole("button", { name: /Demo project/ }),
+ );
+ await user.click(
+ await screen.findByRole("button", { name: /Unicode seed/ }),
+ );
+ expect(
+ await screen.findByText(
+ /Viewers can inspect and export immutable versions/,
+ ),
+ ).toBeVisible();
+ expect(
+ screen.queryByRole("button", { name: "Create project" }),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: "Create corpus" }),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: "Create version" }),
+ ).not.toBeInTheDocument();
+ expect(screen.queryByLabelText("Version eSpeak language")).toBeNull();
+ });
+
it("handles plural project loading, missing versions, and form failures", async () => {
const user = userEvent.setup();
const emptyProject = {
diff --git a/apps/web/src/components/project-workbench.tsx b/apps/web/src/components/project-workbench.tsx
index 63dae1d..4e9fc74 100644
--- a/apps/web/src/components/project-workbench.tsx
+++ b/apps/web/src/components/project-workbench.tsx
@@ -1,7 +1,7 @@
"use client";
import type { FormEvent, ReactNode } from "react";
-import { useEffect, useRef, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import { useProjectContext } from "@/components/project-context";
import {
@@ -10,9 +10,11 @@ import {
MAX_SENTENCE_CHARACTERS,
corpusExportHref,
createManualCorpus,
+ createManualVersion,
createProject,
getCurrentPrincipal,
importCorpus,
+ importCorpusVersion,
listCorpora,
listSentences,
listVersions,
@@ -26,6 +28,8 @@ import {
type ProjectDeletion,
} from "@/lib/projects";
+type WriteAccess = "checking" | "allowed" | "read-only" | "unavailable";
+
export function ProjectWorkbench() {
const projectContext = useProjectContext();
const projects = projectContext?.projects ?? [];
@@ -39,10 +43,24 @@ export function ProjectWorkbench() {
);
const [sentences, setSentences] = useState([]);
const [canDeleteProjects, setCanDeleteProjects] = useState(false);
+ const [writeAccess, setWriteAccess] = useState("checking");
+ const [versionFocusTarget, setVersionFocusTarget] = useState(
+ null,
+ );
const [pending, setPending] = useState(false);
const [notice, setNotice] = useState("");
const [projectActionNotice, setProjectActionNotice] = useState("");
const [error, setError] = useState(null);
+ const selectionRevision = useRef(0);
+ const clearVersionFocusTarget = useCallback(
+ () => setVersionFocusTarget(null),
+ [],
+ );
+ const beginVersionMutation = useCallback(() => selectionRevision.current, []);
+
+ useEffect(() => {
+ selectionRevision.current += 1;
+ }, [selectedCorpus?.id, selectedProject?.id]);
useEffect(() => {
let active = true;
@@ -53,9 +71,12 @@ export function ProjectWorkbench() {
setCanDeleteProjects(
principal.role === "owner" || principal.role === "admin",
);
+ setWriteAccess(principal.role === "viewer" ? "read-only" : "allowed");
})
.catch((caught: unknown) => {
if (!active) return;
+ setCanDeleteProjects(false);
+ setWriteAccess("unavailable");
setError(workspaceError(caught));
setNotice("");
});
@@ -167,11 +188,15 @@ export function ProjectWorkbench() {
function chooseProject(project: Project) {
setProjectActionNotice("");
+ setVersionFocusTarget(null);
+ selectionRevision.current += 1;
projectContext?.selectProject(project.id);
}
async function chooseCorpus(corpus: Corpus) {
if (!selectedProject) return;
+ setVersionFocusTarget(null);
+ selectionRevision.current += 1;
setPending(true);
setError(null);
projectContext?.selectCorpusVersion(null);
@@ -203,6 +228,7 @@ export function ProjectWorkbench() {
async function chooseVersion(version: CorpusVersion) {
if (!selectedProject || !selectedCorpus) return;
+ setVersionFocusTarget(null);
setPending(true);
setError(null);
try {
@@ -232,6 +258,53 @@ export function ProjectWorkbench() {
if (created) await chooseCorpus(created);
}
+ async function refreshVersions(
+ createdVersion: CorpusVersion,
+ expectedSelectionRevision: number,
+ ) {
+ if (!selectedProject || !selectedCorpus) return;
+ const project = selectedProject;
+ const corpus = selectedCorpus;
+ const selectionIsCurrent = () =>
+ selectionRevision.current === expectedSelectionRevision;
+ if (!selectionIsCurrent()) return;
+ setError(null);
+ setNotice("");
+ try {
+ const items = await listVersions(project.id, corpus.id);
+ if (!selectionIsCurrent()) return;
+ const created = items.find((item) => item.id === createdVersion.id);
+ if (!created) throw new Error("missing_created_version");
+ const rows = await listSentences(project.id, corpus.id, created.id);
+ if (!selectionIsCurrent()) return;
+ setVersions(items);
+ setSelectedVersion(created);
+ setSentences(rows);
+ projectContext?.selectCorpusVersion({ corpus, version: created });
+ setVersionFocusTarget(created.id);
+ setNotice(
+ `${corpus.name} version ${created.version_number} created and selected.`,
+ );
+ } catch {
+ if (!selectionIsCurrent()) return;
+ setVersions((current) =>
+ [
+ ...current.filter((item) => item.id !== createdVersion.id),
+ createdVersion,
+ ].sort((left, right) => left.version_number - right.version_number),
+ );
+ setSelectedVersion(createdVersion);
+ setSentences([]);
+ projectContext?.selectCorpusVersion({ corpus, version: createdVersion });
+ setVersionFocusTarget(createdVersion.id);
+ setNotice(
+ `${corpus.name} version ${createdVersion.version_number} was created, but its sentences could not be refreshed. Reopen the corpus to retry.`,
+ );
+ }
+ }
+
+ const latestVersion = versions.at(-1) ?? null;
+
return (
-
{
- await projectContext?.refreshProjects(project.id);
- }}
- onError={setError}
- />
+ {writeAccess === "allowed" ? (
+ {
+ await projectContext?.refreshProjects(project.id);
+ }}
+ onError={setError}
+ />
+ ) : (
+
+ )}
{selectedProject && canDeleteProjects ? (
Corpora
- Manual entry and strict TXT, CSV, or JSON imports create version 1.
- Corpus update and deletion are not available.
+ Manual entry and strict TXT, CSV, or JSON imports create immutable
+ corpora. Corpus update and deletion are not available.
{!selectedProject ? (
@@ -329,14 +409,21 @@ export function ProjectWorkbench() {
selected={selectedCorpus}
onSelect={chooseCorpus}
/>
- {
- await refreshCorpora(name);
- }}
- onError={setError}
- />
+ {writeAccess === "allowed" ? (
+ {
+ await refreshCorpora(name);
+ }}
+ onError={setError}
+ />
+ ) : (
+
+ )}
)}
@@ -350,24 +437,51 @@ export function ProjectWorkbench() {
Versions & sentences
- Every digest identifies normalized text in deterministic sentence
- order.
+ Create immutable successors, then inspect any digest in
+ deterministic sentence order.
{!selectedCorpus || !selectedProject ? (
- Select a corpus to inspect its immutable initial version.
+ Select a corpus to inspect and extend its immutable history.
) : (
-
+
+
+ {writeAccess === "allowed" ? (
+ latestVersion ? (
+
+ ) : (
+
+ Load an existing version before creating its successor.
+
+ )
+ ) : (
+
+ )}
+
)}
@@ -780,6 +894,214 @@ function CorpusForm({
);
}
+function VersionForm({
+ projectId,
+ corpusId,
+ initialLanguage,
+ pending,
+ onMutationStarted,
+ onCreated,
+ onError,
+}: {
+ projectId: string;
+ corpusId: string;
+ initialLanguage: string;
+ pending: boolean;
+ onMutationStarted: () => number;
+ onCreated: (
+ version: CorpusVersion,
+ selectionRevision: number,
+ ) => Promise;
+ onError: (message: string | null) => void;
+}) {
+ const [mode, setMode] = useState<"manual" | "file">("manual");
+ const [languageOverride, setLanguageOverride] = useState(null);
+ const language = languageOverride ?? initialLanguage;
+ const [sentenceText, setSentenceText] = useState("");
+ const [format, setFormat] = useState("txt");
+ const [textColumn, setTextColumn] = useState("text");
+ const [file, setFile] = useState(null);
+ const [submitting, setSubmitting] = useState(false);
+ const submissionLock = useRef(false);
+ const fileInput = useRef(null);
+
+ async function submit(event: FormEvent) {
+ event.preventDefault();
+ if (submissionLock.current || pending) return;
+ submissionLock.current = true;
+ setSubmitting(true);
+ onError(null);
+ const selectionRevision = onMutationStarted();
+ try {
+ let created: CorpusVersion;
+ if (mode === "manual") {
+ const sentences = sentenceText.split(/\r?\n/u);
+ if (
+ sentences.length > MAX_CORPUS_SENTENCES ||
+ sentences.some((item) => item.length > MAX_SENTENCE_CHARACTERS)
+ ) {
+ throw new Error("client_limit");
+ }
+ created = await createManualVersion(projectId, corpusId, {
+ language,
+ sentences,
+ });
+ } else {
+ if (!file || file.size > MAX_CORPUS_FILE_BYTES)
+ throw new Error("client_file_limit");
+ created = await importCorpusVersion(projectId, corpusId, {
+ language,
+ format,
+ textColumn: format === "csv" ? textColumn : null,
+ file,
+ });
+ }
+ await onCreated(created, selectionRevision);
+ setLanguageOverride(null);
+ setSentenceText("");
+ setFile(null);
+ if (fileInput.current) fileInput.current.value = "";
+ } catch (caught) {
+ if (caught instanceof Error && caught.message === "client_limit") {
+ onError(
+ "Manual input is limited to 10,000 lines and 2,000 characters per sentence.",
+ );
+ } else if (
+ caught instanceof Error &&
+ caught.message === "client_file_limit"
+ ) {
+ onError("Choose one UTF-8 file no larger than 10 MiB.");
+ } else {
+ onError(workspaceError(caught));
+ }
+ } finally {
+ submissionLock.current = false;
+ setSubmitting(false);
+ }
+ }
+
+ return (
+
+ );
+}
+
function VersionBrowser({
project,
corpus,
@@ -788,6 +1110,8 @@ function VersionBrowser({
sentences,
pending,
onSelect,
+ focusTarget,
+ onFocusTargetHandled,
}: {
project: Project;
corpus: Corpus;
@@ -796,7 +1120,17 @@ function VersionBrowser({
sentences: CorpusSentence[];
pending: boolean;
onSelect: (version: CorpusVersion) => Promise;
+ focusTarget: string | null;
+ onFocusTargetHandled: () => void;
}) {
+ const focusButton = useRef(null);
+
+ useEffect(() => {
+ if (focusTarget !== selected?.id || !focusButton.current) return;
+ focusButton.current.focus();
+ onFocusTargetHandled();
+ }, [focusTarget, onFocusTargetHandled, selected?.id]);
+
return (
@@ -804,6 +1138,7 @@ function VersionBrowser({
void onSelect(version)}
@@ -889,6 +1224,30 @@ function VersionBrowser({
);
}
+function WritePermissionNote({
+ access,
+ readOnlyMessage,
+}: {
+ access: WriteAccess;
+ readOnlyMessage: string;
+}) {
+ if (access === "allowed") return null;
+ const message =
+ access === "checking"
+ ? "Checking write permissions…"
+ : access === "unavailable"
+ ? "Write controls are unavailable until permissions can be verified."
+ : readOnlyMessage;
+ return (
+
+ {message}
+
+ );
+}
+
function WorkspaceEmpty({ children }: { children: ReactNode }) {
return {children}
;
}
diff --git a/apps/web/src/lib/platform.test.ts b/apps/web/src/lib/platform.test.ts
index ca1b3d6..200a77d 100644
--- a/apps/web/src/lib/platform.test.ts
+++ b/apps/web/src/lib/platform.test.ts
@@ -41,12 +41,12 @@ const event = {
sequence: 7,
actor_kind: "user",
actor_id: "user|demo",
- action: "run.submitted",
- resource_type: "run",
+ action: "corpus.version_created",
+ resource_type: "corpus",
resource_id: "123e4567-e89b-42d3-a456-426614174009",
request_id: "request-7",
occurred_at: "2026-08-11T12:00:00Z",
- metadata: { kind: "evaluate" },
+ metadata: { version_number: 2 },
previous_hash: "a".repeat(64),
event_hash: "b".repeat(64),
};
diff --git a/apps/web/src/lib/platform.ts b/apps/web/src/lib/platform.ts
index afae5ff..826b6be 100644
--- a/apps/web/src/lib/platform.ts
+++ b/apps/web/src/lib/platform.ts
@@ -66,6 +66,7 @@ const auditActions = [
"project.deletion_requested",
"project.purged",
"corpus.created",
+ "corpus.version_created",
"run.submitted",
"run.cancellation_requested",
"run.retry_submitted",
diff --git a/apps/web/src/lib/projects.test.ts b/apps/web/src/lib/projects.test.ts
index 800bfc9..7f671e8 100644
--- a/apps/web/src/lib/projects.test.ts
+++ b/apps/web/src/lib/projects.test.ts
@@ -10,9 +10,11 @@ import {
ProjectContractError,
corpusExportHref,
createManualCorpus,
+ createManualVersion,
createProject,
getCurrentPrincipal,
importCorpus,
+ importCorpusVersion,
listAllSentences,
listCorpora,
listProjects,
@@ -241,6 +243,59 @@ describe("project API client", () => {
expect(form.has("text_column")).toBe(false);
});
+ it("appends manual and file-backed versions to encoded corpus paths", async () => {
+ const second = {
+ ...version,
+ id: "version-2",
+ parent_version_id: version.id,
+ version_number: 2,
+ };
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(Response.json(second, { status: 201 }))
+ .mockResolvedValueOnce(Response.json(second, { status: 201 }));
+ vi.stubGlobal("fetch", fetchMock);
+
+ await expect(
+ createManualVersion("project / 1", "corpus / 1", {
+ language: "en-gb",
+ sentences: ["Second"],
+ }),
+ ).resolves.toEqual(second);
+ const [manualPath, manualInit] = fetchMock.mock.calls[0] as [
+ string,
+ RequestInit,
+ ];
+ expect(manualPath).toContain(
+ "/projects/project%20%2F%201/corpora/corpus%20%2F%201/versions",
+ );
+ expect(manualInit.body).toBe(
+ JSON.stringify({ language: "en-gb", sentences: ["Second"] }),
+ );
+
+ const file = new File(["text\nSecond\n"], "second.csv", {
+ type: "text/csv",
+ });
+ await expect(
+ importCorpusVersion("project / 1", "corpus / 1", {
+ language: "en-us",
+ format: "csv",
+ textColumn: "text",
+ file,
+ }),
+ ).resolves.toEqual(second);
+ const [filePath, fileInit] = fetchMock.mock.calls[1] as [
+ string,
+ RequestInit,
+ ];
+ expect(filePath).toContain("/versions/imports");
+ const form = fileInit.body as FormData;
+ expect(form.get("language")).toBe("en-us");
+ expect(form.get("text_column")).toBe("text");
+ expect(form.get("file")).toBe(file);
+ expect(form.has("name")).toBe(false);
+ });
+
it("normalizes safe API errors without leaking malformed bodies", async () => {
const fetchMock = vi
.fn()
diff --git a/apps/web/src/lib/projects.ts b/apps/web/src/lib/projects.ts
index e6eacc7..ed38a18 100644
--- a/apps/web/src/lib/projects.ts
+++ b/apps/web/src/lib/projects.ts
@@ -312,6 +312,45 @@ export async function importCorpus(
);
}
+export async function createManualVersion(
+ projectId: string,
+ corpusId: string,
+ input: {
+ readonly language: string;
+ readonly sentences: string[];
+ },
+): Promise {
+ const value = await requestJson(
+ `/api/v1/projects/${encodeURIComponent(projectId)}/corpora/${encodeURIComponent(corpusId)}/versions`,
+ jsonRequest(input),
+ );
+ if (!isVersion(value)) throw new ProjectContractError();
+ return value;
+}
+
+export async function importCorpusVersion(
+ projectId: string,
+ corpusId: string,
+ input: {
+ readonly language: string;
+ readonly format: CorpusFileFormat;
+ readonly textColumn: string | null;
+ readonly file: File;
+ },
+): Promise {
+ const form = new FormData();
+ form.set("language", input.language);
+ form.set("format", input.format);
+ if (input.textColumn) form.set("text_column", input.textColumn);
+ form.set("file", input.file);
+ const value = await requestJson(
+ `/api/v1/projects/${encodeURIComponent(projectId)}/corpora/${encodeURIComponent(corpusId)}/versions/imports`,
+ { method: "POST", body: form },
+ );
+ if (!isVersion(value)) throw new ProjectContractError();
+ return value;
+}
+
export async function listVersions(
projectId: string,
corpusId: string,
diff --git a/apps/web/src/playwright-config.test.ts b/apps/web/src/playwright-config.test.ts
index 727cc9c..da0abe9 100644
--- a/apps/web/src/playwright-config.test.ts
+++ b/apps/web/src/playwright-config.test.ts
@@ -9,16 +9,22 @@ function patterns(value: unknown): readonly unknown[] {
describe("Playwright suite boundaries", () => {
afterEach(() => vi.unstubAllEnvs());
- it("keeps the real-stack demo out of the standard mocked browser matrix", () => {
- expect(patterns(standardConfig.testIgnore)).toContain(
- "**/demo-live.spec.ts",
+ it("keeps real-stack acceptance out of the standard mocked browser matrix", () => {
+ expect(patterns(standardConfig.testIgnore)).toEqual(
+ expect.arrayContaining([
+ "**/demo-live.spec.ts",
+ "**/projects-live.spec.ts",
+ ]),
);
});
- it("selects only the real-stack demo in the explicit live configuration", async () => {
+ it("selects the demo and project-history real-stack acceptance", async () => {
vi.stubEnv("CORPUSKIT_LIVE_BASE_URL", "http://127.0.0.1:3000");
const { default: liveConfig } = await import("../playwright.live.config");
- expect(liveConfig.testMatch).toBe("demo-live.spec.ts");
+ expect(patterns(liveConfig.testMatch)).toEqual([
+ "demo-live.spec.ts",
+ "projects-live.spec.ts",
+ ]);
});
});
diff --git a/contracts/openapi.json b/contracts/openapi.json
index f285db0..6c915c2 100644
--- a/contracts/openapi.json
+++ b/contracts/openapi.json
@@ -290,6 +290,7 @@
"project.deletion_requested",
"project.purged",
"corpus.created",
+ "corpus.version_created",
"run.submitted",
"run.cancellation_requested",
"run.retry_submitted",
@@ -484,6 +485,44 @@
"title": "Body_import_corpus_api_v1_projects__project_id__corpora_imports_post",
"type": "object"
},
+ "Body_import_version_api_v1_projects__project_id__corpora__corpus_id__versions_imports_post": {
+ "properties": {
+ "file": {
+ "contentMediaType": "application/octet-stream",
+ "title": "File",
+ "type": "string"
+ },
+ "format": {
+ "$ref": "#/components/schemas/CorpusFileFormat"
+ },
+ "language": {
+ "maxLength": 64,
+ "minLength": 1,
+ "title": "Language",
+ "type": "string"
+ },
+ "text_column": {
+ "anyOf": [
+ {
+ "maxLength": 160,
+ "minLength": 1,
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Text Column"
+ }
+ },
+ "required": [
+ "file",
+ "language",
+ "format"
+ ],
+ "title": "Body_import_version_api_v1_projects__project_id__corpora__corpus_id__versions_imports_post",
+ "type": "object"
+ },
"Body_upload_artifact_api_v1_projects__project_id__artifacts_post": {
"properties": {
"expected_sha256": {
@@ -4998,6 +5037,33 @@
"title": "ManualCorpusInput",
"type": "object"
},
+ "ManualCorpusVersionInput": {
+ "additionalProperties": false,
+ "description": "A new immutable version appended to an existing corpus.",
+ "properties": {
+ "language": {
+ "default": "en-us",
+ "maxLength": 64,
+ "minLength": 1,
+ "title": "Language",
+ "type": "string"
+ },
+ "sentences": {
+ "items": {
+ "type": "string"
+ },
+ "maxItems": 10000,
+ "minItems": 1,
+ "title": "Sentences",
+ "type": "array"
+ }
+ },
+ "required": [
+ "sentences"
+ ],
+ "title": "ManualCorpusVersionInput",
+ "type": "object"
+ },
"ModelDevice": {
"enum": [
"cpu",
@@ -13175,6 +13241,140 @@
"tags": [
"project-workspaces"
]
+ },
+ "post": {
+ "operationId": "create_manual_version_api_v1_projects__project_id__corpora__corpus_id__versions_post",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "project_id",
+ "required": true,
+ "schema": {
+ "format": "uuid",
+ "title": "Project Id",
+ "type": "string"
+ }
+ },
+ {
+ "in": "path",
+ "name": "corpus_id",
+ "required": true,
+ "schema": {
+ "format": "uuid",
+ "title": "Corpus Id",
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ManualCorpusVersionInput"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "201": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/VersionResponse"
+ }
+ }
+ },
+ "description": "Successful Response"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ },
+ "description": "Validation Error"
+ }
+ },
+ "security": [
+ {
+ "HTTPBearer": []
+ }
+ ],
+ "summary": "Create Manual Version",
+ "tags": [
+ "project-workspaces"
+ ]
+ }
+ },
+ "/api/v1/projects/{project_id}/corpora/{corpus_id}/versions/imports": {
+ "post": {
+ "operationId": "import_version_api_v1_projects__project_id__corpora__corpus_id__versions_imports_post",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "project_id",
+ "required": true,
+ "schema": {
+ "format": "uuid",
+ "title": "Project Id",
+ "type": "string"
+ }
+ },
+ {
+ "in": "path",
+ "name": "corpus_id",
+ "required": true,
+ "schema": {
+ "format": "uuid",
+ "title": "Corpus Id",
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "multipart/form-data": {
+ "schema": {
+ "$ref": "#/components/schemas/Body_import_version_api_v1_projects__project_id__corpora__corpus_id__versions_imports_post"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "201": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/VersionResponse"
+ }
+ }
+ },
+ "description": "Successful Response"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ },
+ "description": "Validation Error"
+ }
+ },
+ "security": [
+ {
+ "HTTPBearer": []
+ }
+ ],
+ "summary": "Import Version",
+ "tags": [
+ "project-workspaces"
+ ]
}
},
"/api/v1/projects/{project_id}/corpora/{corpus_id}/versions/{version_id}/export": {
diff --git a/docs/operations/tenant-isolation-quotas-audit.md b/docs/operations/tenant-isolation-quotas-audit.md
index c4a3e5e..386f73d 100644
--- a/docs/operations/tenant-isolation-quotas-audit.md
+++ b/docs/operations/tenant-isolation-quotas-audit.md
@@ -115,7 +115,9 @@ a bounded server-owned `Retry-After`; it does not reveal usage from another orga
Submission locks the tenant usage row and creates one unique reservation in the same
transaction as the run, initial event, outbox message, and audit event. Idempotent replay does
not reserve twice. Artifact and corpus counters are changed in the transaction that persists
-their metadata. A failed transaction changes neither resource nor counter.
+their metadata. Every retained immutable corpus version contributes its sentence count;
+duplicate content/language, lineage, or other failed appends change neither resource nor counter.
+A failed transaction changes neither resource nor counter.
The initial reservation lease is the validated run deadline (or the 300-second default) plus
a five-minute termination grace. Transition to running renews it. Terminal success, failure,
@@ -137,7 +139,7 @@ connection floods, and fair-share scheduling remains a separate release gate.
## Audit evidence
-Project creation, project deletion request/final purge, corpus creation, run
+Project creation, project deletion request/final purge, corpus and corpus-version creation, run
submit/cancel/retry/terminal transitions, artifact
creation/tombstone/purge/adoption, expired reservations, and privileged quota-policy changes
append an audit event in the same database transaction as the mutation. Metadata is
diff --git a/docs/product/15-minute-demo.md b/docs/product/15-minute-demo.md
index 422ee68..f2ea5a4 100644
--- a/docs/product/15-minute-demo.md
+++ b/docs/product/15-minute-demo.md
@@ -41,9 +41,10 @@ $env:CORPUSKIT_LIVE_BASE_URL = "http://127.0.0.1:3000"
npm run test:e2e:live --workspace @corpuskit/web
```
-The automated run is the executable acceptance record. It creates uniquely named demo data and
-leaves it visible for the guided review below. A passing run proves that requests reached the
-external stack; it does not prove live provider, qualified GPU, vendor IdP, TLS Redis, or
+The automated run is the executable acceptance record. It creates uniquely named demo data,
+appends an immutable corpus successor, verifies that version 1 remains readable/exportable, and
+leaves the data visible for the guided review below. A passing run proves that requests reached
+the external stack; it does not prove live provider, qualified GPU, vendor IdP, TLS Redis, or
multi-replica behavior.
## Guided review (minutes 2–15)
diff --git a/docs/product/capability-matrix.md b/docs/product/capability-matrix.md
index 2a4aae0..5fcf352 100644
--- a/docs/product/capability-matrix.md
+++ b/docs/product/capability-matrix.md
@@ -262,15 +262,16 @@ CorpusKit verification evidence for the implemented workflow slice is in:
Project/corpus workspace persistence is a CorpusKit platform capability rather than a
CorpusGen symbol, so it is not added to the 75-row engine parity count above. Create/list,
-bounded version-1 import, inspection, and deterministic export are wired through the API and
-the accessible `/projects` workbench. Unit, service-integration, BFF, and three-browser
-Playwright/axe acceptance tests cover the create/import/export slice. Owner/admin-only project
+bounded version-1 import, append-only manual/file version creation with server-owned parent
+lineage, inspection, and deterministic export are wired through the API and the accessible
+`/projects` workbench. Unit, service-integration, BFF, and three-browser Playwright/axe
+acceptance tests cover the create/import/version/export slice. Owner/admin-only project
deletion now uses exact accessible confirmation, immediate fail-closed logical removal,
transactional artifact tombstones, a configurable minimum 30-day retention, maintenance-only
byte-first physical purge, exact quota release, and durable audit evidence. SQLite race tests and
the opt-in real PostgreSQL RLS suite cover its service boundary. The migrated real-stack
-Playwright deletion flow remains a release gate. Update, individual corpus deletion, deletion
-undo, and later version creation remain unimplemented.
+Playwright deletion flow remains a release gate. Metadata update, individual corpus deletion,
+and deletion undo remain unimplemented.
Tenant isolation, quota accounting, and audit evidence are also CorpusKit platform controls,
not CorpusGen parity rows. Current evidence forces PostgreSQL RLS on every persisted resource
diff --git a/docs/product/project-workspaces.md b/docs/product/project-workspaces.md
index 005fa89..b06ec3a 100644
--- a/docs/product/project-workspaces.md
+++ b/docs/product/project-workspaces.md
@@ -1,4 +1,4 @@
-# Project workspaces and immutable corpus imports
+# Project workspaces and immutable corpus versions
## Delivery status
@@ -11,19 +11,35 @@ This slice implements only:
- create and list tenant projects;
- create a corpus and immutable version 1 from manual sentences;
- import one bounded UTF-8 TXT, CSV, or JSON file;
+- append immutable version 2+ from manual sentences or the same bounded file formats;
- list corpora, versions, and sentences;
- download deterministic TXT, JSON, and spreadsheet-safe CSV exports; and
- schedule an owner/admin-confirmed, retention-safe project deletion lifecycle.
-It does **not** implement project/corpus update, individual corpus deletion, new versions of an
-existing corpus, bulk archive ingestion, asynchronous import, lineage editing, or a user-facing
-deletion undo. Project deletion is logically immediate and physically finalized by maintenance
-only after at least 30 days; see the [project deletion runbook](../operations/project-deletion.md).
+It does **not** implement project/corpus metadata update, individual corpus deletion, bulk archive
+ingestion, asynchronous import, lineage editing, or a user-facing deletion undo. Project deletion
+is logically immediate and physically finalized by maintenance only after at least 30 days; see
+the [project deletion runbook](../operations/project-deletion.md).
The browser sentence table deliberately previews at most the first 500 rows; TXT, JSON, and
-CSV downloads always contain the complete version. Create/import forms lock while a request
+CSV downloads always contain the complete version. Create/import/append forms lock while a request
is in flight because these endpoints do not claim idempotent replay semantics.
+## Version lineage
+
+Writers append through `POST /api/v1/projects/{project_id}/corpora/{corpus_id}/versions` for
+manual text or the `/versions/imports` multipart variant. The server chooses the parent and next
+version number; clients cannot rewrite lineage or choose a historical parent. The active
+project-row lock serializes production PostgreSQL version writers with project deletion, while
+unique corpus/version and corpus/content constraints fail closed on numbering races or duplicate
+normalized content in the same language.
+
+Every successful append stores a complete immutable sentence snapshot. Historical versions and
+their exports remain unchanged. The new version's sentence count is charged atomically to the
+tenant's retained-corpus quota, and rollback of any lineage, uniqueness, or audit failure also
+rolls back that charge. Audit metadata records only the corpus identifier, parent/version
+identity, language, sentence count, and content digest; it never records sentence text.
+
## Import contracts
All imports are limited to 10 MiB, 10,000 input sentences, and 2,000 normalized characters
@@ -57,10 +73,10 @@ different purposes.
## Authorization model
-Owner, admin, and editor roles may create. Viewers may list and export. Every service query
-resolves the authenticated subject through the organization membership table and scopes the
-full project/corpus/version hierarchy by organization. Foreign-tenant identifiers return the
-same not-found contract as absent identifiers.
+Owner, admin, and editor roles may create corpora and append versions. Viewers may list and
+export. Every service query resolves the authenticated subject through the organization
+membership table and scopes the full project/corpus/version hierarchy by organization.
+Foreign-tenant identifiers return the same not-found contract as absent identifiers.
Only owners and admins may request project deletion. The browser presents the explicit danger
control only after reading the server-verified role from `/api/v1/auth/me`; the API remains the
diff --git a/src/corpuskit/api/projects.py b/src/corpuskit/api/projects.py
index b074620..dd58066 100644
--- a/src/corpuskit/api/projects.py
+++ b/src/corpuskit/api/projects.py
@@ -26,7 +26,9 @@
CorpusExportFormat,
CorpusFileFormat,
CorpusUpload,
+ CorpusVersionUpload,
ManualCorpusInput,
+ ManualCorpusVersionInput,
ProjectDeletionInput,
ProjectInput,
ProjectLifecycle,
@@ -67,6 +69,24 @@ async def import_corpus(
self, actor: WorkspaceActor, project_id: UUID, upload: CorpusUpload
) -> CorpusCreation: ...
+ async def create_manual_version(
+ self,
+ actor: WorkspaceActor,
+ project_id: UUID,
+ corpus_id: UUID,
+ request: ManualCorpusVersionInput,
+ ) -> VersionSnapshot:
+ raise NotImplementedError
+
+ async def import_version(
+ self,
+ actor: WorkspaceActor,
+ project_id: UUID,
+ corpus_id: UUID,
+ upload: CorpusVersionUpload,
+ ) -> VersionSnapshot:
+ raise NotImplementedError
+
async def list_corpora(
self, actor: WorkspaceActor, project_id: UUID
) -> tuple[CorpusSnapshot, ...]: ...
@@ -245,6 +265,62 @@ async def import_corpus(
raise InvalidRequestError("corpus.import") from exc
return await service.import_corpus(_actor(principal, http_request), project_id, upload)
+ @router.post(
+ "/projects/{project_id}/corpora/{corpus_id}/versions",
+ response_model=VersionResponse,
+ status_code=status.HTTP_201_CREATED,
+ )
+ async def create_manual_version(
+ project_id: UUID,
+ corpus_id: UUID,
+ payload: Annotated[ManualCorpusVersionInput, Body()],
+ principal: WriterPrincipal,
+ http_request: Request,
+ ) -> VersionSnapshot:
+ return await service.create_manual_version(
+ _actor(principal, http_request),
+ project_id,
+ corpus_id,
+ payload,
+ )
+
+ @router.post(
+ "/projects/{project_id}/corpora/{corpus_id}/versions/imports",
+ response_model=VersionResponse,
+ status_code=status.HTTP_201_CREATED,
+ )
+ async def import_version(
+ project_id: UUID,
+ corpus_id: UUID,
+ principal: WriterPrincipal,
+ file: Annotated[UploadFile, File()],
+ language: Annotated[str, Form(min_length=1, max_length=64)],
+ file_format: Annotated[CorpusFileFormat, Form(alias="format")],
+ http_request: Request,
+ text_column: Annotated[str | None, Form(min_length=1, max_length=160)] = None,
+ ) -> VersionSnapshot:
+ try:
+ content = await file.read(max_upload_bytes + 1)
+ finally:
+ await file.close()
+ try:
+ upload = CorpusVersionUpload(
+ language=language,
+ filename=file.filename or "",
+ content_type=file.content_type or "",
+ file_format=file_format,
+ content=content,
+ text_column=text_column,
+ )
+ except ValidationError as exc:
+ raise InvalidRequestError("corpus.version.import") from exc
+ return await service.import_version(
+ _actor(principal, http_request),
+ project_id,
+ corpus_id,
+ upload,
+ )
+
@router.get(
"/projects/{project_id}/corpora",
response_model=tuple[CorpusResponse, ...],
diff --git a/src/corpuskit/domain/platform.py b/src/corpuskit/domain/platform.py
index eaeb47a..3663ede 100644
--- a/src/corpuskit/domain/platform.py
+++ b/src/corpuskit/domain/platform.py
@@ -50,6 +50,7 @@ class AuditAction(StrEnum):
PROJECT_DELETION_REQUESTED = "project.deletion_requested"
PROJECT_PURGED = "project.purged"
CORPUS_CREATED = "corpus.created"
+ CORPUS_VERSION_CREATED = "corpus.version_created"
RUN_SUBMITTED = "run.submitted"
RUN_CANCELLATION_REQUESTED = "run.cancellation_requested"
RUN_RETRY_SUBMITTED = "run.retry_submitted"
@@ -227,6 +228,15 @@ def safe_audit_actor(value: str) -> str:
),
AuditAction.PROJECT_PURGED: frozenset({"artifact_count", "corpus_sentences"}),
AuditAction.CORPUS_CREATED: frozenset({"content_sha256", "language", "sentence_count"}),
+ AuditAction.CORPUS_VERSION_CREATED: frozenset(
+ {
+ "content_sha256",
+ "language",
+ "parent_version_id",
+ "sentence_count",
+ "version_number",
+ }
+ ),
AuditAction.RUN_SUBMITTED: frozenset({"attempt", "kind", "quota_class"}),
AuditAction.RUN_CANCELLATION_REQUESTED: frozenset({"prior_state"}),
AuditAction.RUN_RETRY_SUBMITTED: frozenset({"attempt", "kind", "quota_class", "source_run_id"}),
diff --git a/src/corpuskit/domain/workspaces.py b/src/corpuskit/domain/workspaces.py
index b991db3..2181a0b 100644
--- a/src/corpuskit/domain/workspaces.py
+++ b/src/corpuskit/domain/workspaces.py
@@ -57,6 +57,13 @@ class ManualCorpusInput(WorkspaceModel):
sentences: tuple[str, ...] = Field(min_length=1, max_length=10_000)
+class ManualCorpusVersionInput(WorkspaceModel):
+ """A new immutable version appended to an existing corpus."""
+
+ language: str = Field(default="en-us", min_length=1, max_length=64)
+ sentences: tuple[str, ...] = Field(min_length=1, max_length=10_000)
+
+
class CorpusUpload(WorkspaceModel):
"""A fully buffered, bounded upload ready for strict format validation."""
@@ -69,11 +76,24 @@ class CorpusUpload(WorkspaceModel):
text_column: str | None = Field(default=None, min_length=1, max_length=160)
+class CorpusVersionUpload(WorkspaceModel):
+ """A bounded upload used to append one immutable corpus version."""
+
+ language: str = Field(default="en-us", min_length=1, max_length=64)
+ filename: str = Field(min_length=1, max_length=255)
+ content_type: str = Field(min_length=1, max_length=160)
+ file_format: CorpusFileFormat
+ content: bytes
+ text_column: str | None = Field(default=None, min_length=1, max_length=160)
+
+
__all__ = [
"CorpusExportFormat",
"CorpusFileFormat",
"CorpusUpload",
+ "CorpusVersionUpload",
"ManualCorpusInput",
+ "ManualCorpusVersionInput",
"ProjectDeletionInput",
"ProjectInput",
"ProjectLifecycle",
diff --git a/src/corpuskit/services/platform.py b/src/corpuskit/services/platform.py
index 5650788..ca8cf85 100644
--- a/src/corpuskit/services/platform.py
+++ b/src/corpuskit/services/platform.py
@@ -508,12 +508,13 @@ async def consume_corpus_sentences(
*,
organization_id: UUID,
sentence_count: int,
+ operation: str = "corpus.create",
) -> None:
if sentence_count <= 0:
raise ValueError("corpus sentence usage must be positive")
policy, usage = await QuotaManager._locked(session, organization_id)
if usage.corpus_sentences + sentence_count > policy.max_corpus_sentences:
- raise QuotaExceededError("corpus.create")
+ raise QuotaExceededError(operation)
usage.corpus_sentences += sentence_count
usage.updated_at = datetime.now(UTC)
await session.flush()
diff --git a/src/corpuskit/services/project_workspaces.py b/src/corpuskit/services/project_workspaces.py
index 91e7fc6..4721c57 100644
--- a/src/corpuskit/services/project_workspaces.py
+++ b/src/corpuskit/services/project_workspaces.py
@@ -31,7 +31,9 @@
CorpusExportFormat,
CorpusFileFormat,
CorpusUpload,
+ CorpusVersionUpload,
ManualCorpusInput,
+ ManualCorpusVersionInput,
ProjectDeletionInput,
ProjectInput,
)
@@ -220,6 +222,33 @@ async def import_corpus(
prepared = self._prepare(upload.language, sentences, operation)
return await self._persist_corpus(actor, project_id, upload.name, prepared)
+ async def create_manual_version(
+ self,
+ actor: WorkspaceActor,
+ project_id: UUID,
+ corpus_id: UUID,
+ request: ManualCorpusVersionInput,
+ ) -> VersionSnapshot:
+ operation = "corpus.version.create"
+ if _utf8_size(request.sentences) > self._max_upload_bytes:
+ raise InvalidRequestError(operation)
+ prepared = self._prepare(request.language, request.sentences, operation)
+ return await self._persist_version(actor, project_id, corpus_id, prepared, operation)
+
+ async def import_version(
+ self,
+ actor: WorkspaceActor,
+ project_id: UUID,
+ corpus_id: UUID,
+ upload: CorpusVersionUpload,
+ ) -> VersionSnapshot:
+ operation = "corpus.version.import"
+ if not upload.content or len(upload.content) > self._max_upload_bytes:
+ raise InvalidRequestError(operation)
+ sentences = parse_corpus_upload(upload, operation=operation)
+ prepared = self._prepare(upload.language, sentences, operation)
+ return await self._persist_version(actor, project_id, corpus_id, prepared, operation)
+
async def list_corpora(
self,
actor: WorkspaceActor,
@@ -359,6 +388,50 @@ async def _persist_corpus(
version=_version_snapshot(version),
)
+ async def _persist_version(
+ self,
+ actor: WorkspaceActor,
+ project_id: UUID,
+ corpus_id: UUID,
+ prepared: PreparedCorpus,
+ operation: str,
+ ) -> VersionSnapshot:
+ async with self.database.session(_context(actor)) as session:
+ user_id, role = await self._actor(session, actor)
+ _require_writer(role, operation)
+ await QuotaManager.consume_corpus_sentences(
+ session,
+ organization_id=actor.organization_id,
+ sentence_count=len(prepared.sentences),
+ operation=operation,
+ )
+ version = await ProjectService.create_version(
+ session,
+ organization_id=actor.organization_id,
+ user_id=user_id,
+ project_id=project_id,
+ corpus_id=corpus_id,
+ prepared=prepared,
+ operation=operation,
+ )
+ await AuditWriter.append(
+ session,
+ organization_id=actor.organization_id,
+ actor=AuditIdentity.user(user_id),
+ action=AuditAction.CORPUS_VERSION_CREATED,
+ resource_type=AuditResourceType.CORPUS,
+ resource_id=corpus_id,
+ request_id=actor.request_id,
+ metadata={
+ "content_sha256": version.content_sha256,
+ "language": version.language,
+ "parent_version_id": str(version.parent_version_id),
+ "sentence_count": version.sentence_count,
+ "version_number": version.version_number,
+ },
+ )
+ return _version_snapshot(version)
+
def _prepare(
self,
language: str,
@@ -392,7 +465,7 @@ async def _actor(
def parse_corpus_upload(
- upload: CorpusUpload, *, operation: str = "corpus.import"
+ upload: CorpusUpload | CorpusVersionUpload, *, operation: str = "corpus.import"
) -> tuple[str, ...]:
"""Validate extension, media type, UTF-8, and a format-specific sentence schema."""
diff --git a/src/corpuskit/services/projects.py b/src/corpuskit/services/projects.py
index dbb62bd..ea6d9b3 100644
--- a/src/corpuskit/services/projects.py
+++ b/src/corpuskit/services/projects.py
@@ -241,6 +241,71 @@ async def create_corpus(
raise ResourceConflictError("create_corpus") from exc
return corpus, version
+ @staticmethod
+ async def create_version(
+ session: AsyncSession,
+ *,
+ organization_id: UUID,
+ user_id: UUID,
+ project_id: UUID,
+ corpus_id: UUID,
+ prepared: PreparedCorpus,
+ operation: str,
+ ) -> CorpusVersion:
+ """Append one immutable version while holding the active parent-project row."""
+
+ await ProjectService._require_project(
+ session,
+ organization_id=organization_id,
+ project_id=project_id,
+ operation=operation,
+ for_update=True,
+ )
+ corpus = await ProjectService._require_corpus(
+ session,
+ organization_id=organization_id,
+ project_id=project_id,
+ corpus_id=corpus_id,
+ operation=operation,
+ )
+ latest = await session.scalar(
+ select(CorpusVersion)
+ .where(
+ CorpusVersion.organization_id == organization_id,
+ CorpusVersion.corpus_id == corpus.id,
+ )
+ .order_by(CorpusVersion.version_number.desc(), CorpusVersion.id.desc())
+ .limit(1)
+ )
+ if latest is None:
+ raise ResourceConflictError(operation)
+
+ version = CorpusVersion(
+ organization_id=organization_id,
+ corpus_id=corpus.id,
+ parent_version_id=latest.id,
+ created_by=user_id,
+ version_number=latest.version_number + 1,
+ language=prepared.language,
+ sentence_count=len(prepared.sentences),
+ content_sha256=prepared.content_sha256,
+ )
+ version.sentences = [
+ Sentence(
+ organization_id=organization_id,
+ ordinal=sentence.ordinal,
+ original_text=sentence.original_text,
+ normalized_text=sentence.normalized_text,
+ )
+ for sentence in prepared.sentences
+ ]
+ session.add(version)
+ try:
+ await session.flush()
+ except IntegrityError as exc:
+ raise ResourceConflictError(operation) from exc
+ return version
+
@staticmethod
async def _require_project(
session: AsyncSession,
@@ -256,7 +321,7 @@ async def _require_project(
Project.lifecycle_state == ProjectLifecycle.ACTIVE,
)
if for_update:
- statement = statement.with_for_update()
+ statement = statement.with_for_update(of=Project)
project = await session.scalar(statement)
if project is None:
raise ResourceNotFoundError(operation)
diff --git a/tests/integration/test_postgres_tenant_controls.py b/tests/integration/test_postgres_tenant_controls.py
index c9a57f8..70add69 100644
--- a/tests/integration/test_postgres_tenant_controls.py
+++ b/tests/integration/test_postgres_tenant_controls.py
@@ -22,7 +22,12 @@
from corpuskit.domain.errors import QuotaExceededError, ResourceNotFoundError
from corpuskit.domain.jobs import RunKind, RunState, normalize_run_spec
from corpuskit.domain.platform import AuditAction, AuditResourceType
-from corpuskit.domain.workspaces import ProjectDeletionInput, ProjectLifecycle
+from corpuskit.domain.workspaces import (
+ ManualCorpusInput,
+ ManualCorpusVersionInput,
+ ProjectDeletionInput,
+ ProjectLifecycle,
+)
from corpuskit.persistence.artifact_store import InMemoryObjectStore
from corpuskit.persistence.database import Database
from corpuskit.persistence.models import (
@@ -115,6 +120,61 @@ class SeededTenant:
run_id: UUID
+@pytest.mark.asyncio
+async def test_concurrent_corpus_versions_serialize_parent_lineage() -> None:
+ assert APP_URL is not None
+ tenant = await _seed_tenant("corpus-version-race", full_graph=False)
+ database = Database(APP_URL)
+ service = ProjectWorkspaceService(
+ database,
+ Settings(environment="test", database_url=APP_URL, _env_file=None),
+ )
+ actor = WorkspaceActor(tenant.subject, tenant.organization_id, "pg-version-race")
+ try:
+ async with database.session(
+ TenantContext.user(tenant.organization_id, tenant.subject)
+ ) as session:
+ assert (
+ await session.scalar(
+ text("SELECT has_table_privilege(current_user, 'projects', 'UPDATE')")
+ )
+ is True
+ )
+ assert (
+ await session.scalar(
+ text("SELECT has_table_privilege(current_user, 'corpora', 'UPDATE')")
+ )
+ is False
+ )
+
+ creation = await service.create_manual_corpus(
+ actor,
+ tenant.project_id,
+ ManualCorpusInput(name="Concurrent corpus", sentences=("Initial",)),
+ )
+ await asyncio.gather(
+ service.create_manual_version(
+ actor,
+ tenant.project_id,
+ creation.corpus.id,
+ ManualCorpusVersionInput(sentences=("Second candidate",)),
+ ),
+ service.create_manual_version(
+ actor,
+ tenant.project_id,
+ creation.corpus.id,
+ ManualCorpusVersionInput(sentences=("Third candidate",)),
+ ),
+ )
+
+ versions = await service.list_versions(actor, tenant.project_id, creation.corpus.id)
+ assert [version.version_number for version in versions] == [1, 2, 3]
+ assert versions[1].parent_version_id == versions[0].id
+ assert versions[2].parent_version_id == versions[1].id
+ finally:
+ await database.dispose()
+
+
@pytest.mark.asyncio
async def test_project_deletion_is_isolated_idempotent_and_maintenance_only() -> None:
assert OWNER_URL is not None
diff --git a/tests/integration/test_project_workspace_flows.py b/tests/integration/test_project_workspace_flows.py
index e0efec5..4cb9556 100644
--- a/tests/integration/test_project_workspace_flows.py
+++ b/tests/integration/test_project_workspace_flows.py
@@ -7,20 +7,38 @@
import pytest
import pytest_asyncio
+from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.pool import StaticPool
from corpuskit.config import Settings
-from corpuskit.domain.errors import InvalidRequestError, ResourceNotFoundError
+from corpuskit.domain.errors import (
+ InvalidRequestError,
+ QuotaExceededError,
+ ResourceConflictError,
+ ResourceNotFoundError,
+)
+from corpuskit.domain.platform import AuditAction
from corpuskit.domain.workspaces import (
CorpusExportFormat,
CorpusFileFormat,
CorpusUpload,
+ CorpusVersionUpload,
ManualCorpusInput,
+ ManualCorpusVersionInput,
ProjectInput,
)
from corpuskit.persistence.database import Database
-from corpuskit.persistence.models import Membership, Organization, Role, User
+from corpuskit.persistence.models import (
+ AuditEvent,
+ Membership,
+ Organization,
+ QuotaPolicy,
+ QuotaUsage,
+ Role,
+ User,
+)
+from corpuskit.services.platform import AuditWriter
from corpuskit.services.project_workspaces import ProjectWorkspaceService, WorkspaceActor
@@ -49,6 +67,21 @@ async def _identity(
return WorkspaceActor(subject=user.oidc_subject, organization_id=organization.id)
+async def _member_identity(
+ session: AsyncSession,
+ slug: str,
+ organization_id: UUID,
+ *,
+ role: Role,
+) -> WorkspaceActor:
+ user = User(oidc_subject=f"oidc|{slug}", display_name=slug.title())
+ session.add(user)
+ await session.flush()
+ session.add(Membership(organization_id=organization_id, user_id=user.id, role=role))
+ await session.flush()
+ return WorkspaceActor(subject=user.oidc_subject, organization_id=organization_id)
+
+
def _service(database: Database, **overrides: int) -> ProjectWorkspaceService:
return ProjectWorkspaceService(
database,
@@ -110,6 +143,168 @@ async def test_manual_corpus_round_trip_is_immutable_ordered_and_exportable(
assert exported.content_disposition.startswith("attachment;")
+@pytest.mark.integration
+@pytest.mark.asyncio
+async def test_manual_and_file_versions_append_lineage_and_preserve_history(
+ database: Database,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ async with database.session() as session:
+ actor = await _identity(session, "versions")
+ actor = WorkspaceActor(
+ subject=actor.subject,
+ organization_id=actor.organization_id,
+ request_id="append-version",
+ )
+ service = _service(database)
+ project = await service.create_project(actor, ProjectInput(name="Version lab"))
+ creation = await service.create_manual_corpus(
+ actor,
+ project.id,
+ ManualCorpusInput(name="Seed", sentences=("Original",)),
+ )
+
+ second = await service.create_manual_version(
+ actor,
+ project.id,
+ creation.corpus.id,
+ ManualCorpusVersionInput(
+ language="en-gb",
+ sentences=(" Revised one ", "Revised two"),
+ ),
+ )
+ third = await service.import_version(
+ actor,
+ project.id,
+ creation.corpus.id,
+ CorpusVersionUpload(
+ language="fr-fr",
+ filename="third.json",
+ content_type="application/json",
+ file_format=CorpusFileFormat.JSON,
+ content=b'{"sentences":["Troisieme"]}',
+ ),
+ )
+
+ versions = await service.list_versions(actor, project.id, creation.corpus.id)
+ assert [version.version_number for version in versions] == [1, 2, 3]
+ assert second.parent_version_id == creation.version.id
+ assert third.parent_version_id == second.id
+ assert (second.language, third.language) == ("en-gb", "fr-fr")
+ assert (second.corpusgen_version, third.corpusgen_version) == ("0.1.7", "0.1.7")
+ assert [
+ sentence.normalized_text
+ for sentence in await service.list_sentences(
+ actor,
+ project.id,
+ creation.corpus.id,
+ second.id,
+ offset=0,
+ limit=100,
+ )
+ ] == ["Revised one", "Revised two"]
+ original = await service.export_version(
+ actor,
+ project.id,
+ creation.corpus.id,
+ creation.version.id,
+ CorpusExportFormat.TXT,
+ )
+ assert original.content == b"Original\n"
+
+ with pytest.raises(ResourceConflictError) as duplicate_error:
+ await service.create_manual_version(
+ actor,
+ project.id,
+ creation.corpus.id,
+ ManualCorpusVersionInput(
+ language="en-gb",
+ sentences=("Revised one", "Revised two"),
+ ),
+ )
+ assert duplicate_error.value.operation == "corpus.version.create"
+ with pytest.raises(ResourceConflictError) as import_duplicate_error:
+ await service.import_version(
+ actor,
+ project.id,
+ creation.corpus.id,
+ CorpusVersionUpload(
+ language="fr-fr",
+ filename="third-again.json",
+ content_type="application/json",
+ file_format=CorpusFileFormat.JSON,
+ content=b'{"sentences":["Troisieme"]}',
+ ),
+ )
+ assert import_duplicate_error.value.operation == "corpus.version.import"
+
+ async with database.session() as session:
+ usage = await session.scalar(
+ select(QuotaUsage).where(QuotaUsage.organization_id == actor.organization_id)
+ )
+ version_events = tuple(
+ (
+ await session.scalars(
+ select(AuditEvent)
+ .where(
+ AuditEvent.organization_id == actor.organization_id,
+ AuditEvent.action == AuditAction.CORPUS_VERSION_CREATED,
+ )
+ .order_by(AuditEvent.sequence)
+ )
+ ).all()
+ )
+ assert usage is not None
+ assert usage.corpus_sentences == 4
+ assert [event.details["version_number"] for event in version_events] == [2, 3]
+ assert all(event.request_id == "append-version" for event in version_events)
+ assert len(await service.list_versions(actor, project.id, creation.corpus.id)) == 3
+
+ async def fail_audit(*_args: object, **_kwargs: object) -> None:
+ raise RuntimeError("synthetic version audit failure")
+
+ monkeypatch.setattr(AuditWriter, "append", fail_audit)
+ with pytest.raises(RuntimeError, match="synthetic version audit failure"):
+ await service.create_manual_version(
+ actor,
+ project.id,
+ creation.corpus.id,
+ ManualCorpusVersionInput(sentences=("Rolled back",)),
+ )
+
+ async with database.session() as session:
+ rolled_back_usage = await session.scalar(
+ select(QuotaUsage).where(QuotaUsage.organization_id == actor.organization_id)
+ )
+ rolled_back_events = tuple(
+ (
+ await session.scalars(
+ select(AuditEvent).where(
+ AuditEvent.organization_id == actor.organization_id,
+ AuditEvent.action == AuditAction.CORPUS_VERSION_CREATED,
+ )
+ )
+ ).all()
+ )
+ assert rolled_back_usage is not None
+ assert rolled_back_usage.corpus_sentences == 4
+ assert len(rolled_back_events) == 2
+ assert len(await service.list_versions(actor, project.id, creation.corpus.id)) == 3
+
+ async with database.session() as session:
+ policy = await session.get(QuotaPolicy, actor.organization_id)
+ assert policy is not None
+ policy.max_corpus_sentences = 4
+ with pytest.raises(QuotaExceededError) as quota_error:
+ await service.create_manual_version(
+ actor,
+ project.id,
+ creation.corpus.id,
+ ManualCorpusVersionInput(sentences=("Over quota",)),
+ )
+ assert quota_error.value.operation == "corpus.version.create"
+
+
@pytest.mark.integration
@pytest.mark.asyncio
@pytest.mark.parametrize(
@@ -179,7 +374,12 @@ async def test_cross_tenant_resources_are_indistinguishable_and_viewer_is_read_o
async with database.session() as session:
owner = await _identity(session, "owner")
intruder = await _identity(session, "intruder")
- viewer = await _identity(session, "viewer", role=Role.VIEWER)
+ viewer = await _member_identity(
+ session,
+ "viewer",
+ owner.organization_id,
+ role=Role.VIEWER,
+ )
service = _service(database)
project = await service.create_project(owner, ProjectInput(name="Private"))
creation = await service.create_manual_corpus(
@@ -211,9 +411,28 @@ async def test_cross_tenant_resources_are_indistinguishable_and_viewer_is_read_o
with pytest.raises(ResourceNotFoundError, match="resource was not found"):
await operation
- assert await service.list_projects(viewer) == ()
+ with pytest.raises(ResourceNotFoundError, match="resource was not found") as missing_error:
+ await service.create_manual_version(
+ intruder,
+ project.id,
+ creation.corpus.id,
+ ManualCorpusVersionInput(sentences=("probe",)),
+ )
+ assert missing_error.value.operation == "corpus.version.create"
+
+ assert [item.id for item in await service.list_projects(viewer)] == [project.id]
+ assert [item.id for item in await service.list_corpora(viewer, project.id)] == [
+ creation.corpus.id
+ ]
with pytest.raises(ResourceNotFoundError):
await service.create_project(viewer, ProjectInput(name="Forbidden"))
+ with pytest.raises(ResourceNotFoundError):
+ await service.create_manual_version(
+ viewer,
+ project.id,
+ creation.corpus.id,
+ ManualCorpusVersionInput(sentences=("forbidden",)),
+ )
@pytest.mark.integration
@@ -262,6 +481,37 @@ async def test_import_limits_are_enforced_before_persistence(database: Database)
)
assert await service.list_corpora(actor, project.id) == ()
+ creation = await service.create_manual_corpus(
+ actor,
+ project.id,
+ ManualCorpusInput(name="Version seed", sentences=("seed",)),
+ )
+ with pytest.raises(InvalidRequestError) as manual_version_error:
+ await service.create_manual_version(
+ actor,
+ project.id,
+ creation.corpus.id,
+ ManualCorpusVersionInput(sentences=("1234", "5678")),
+ )
+ assert manual_version_error.value.operation == "corpus.version.create"
+
+ for filename, content in (("empty.txt", b""), ("large.txt", b"12345678")):
+ with pytest.raises(InvalidRequestError) as imported_version_error:
+ await service.import_version(
+ actor,
+ project.id,
+ creation.corpus.id,
+ CorpusVersionUpload(
+ filename=filename,
+ content_type="text/plain",
+ file_format=CorpusFileFormat.TXT,
+ content=content,
+ ),
+ )
+ assert imported_version_error.value.operation == "corpus.version.import"
+
+ assert await service.list_versions(actor, project.id, creation.corpus.id) == (creation.version,)
+
@pytest.mark.integration
@pytest.mark.asyncio
diff --git a/tests/unit/test_platform_domain.py b/tests/unit/test_platform_domain.py
index 5065817..eb6785c 100644
--- a/tests/unit/test_platform_domain.py
+++ b/tests/unit/test_platform_domain.py
@@ -260,6 +260,19 @@ def test_audit_metadata_is_allowlisted_nonfinite_safe_and_bounded() -> None:
AuditAction.RUN_SUCCEEDED,
{"kind": "x" * 2_100},
)
+ assert (
+ normalize_audit_metadata(
+ AuditAction.CORPUS_VERSION_CREATED,
+ {
+ "content_sha256": "a" * 64,
+ "language": "en-us",
+ "parent_version_id": "00000000-0000-4000-8000-000000000001",
+ "sentence_count": 2,
+ "version_number": 3,
+ },
+ )["version_number"]
+ == 3
+ )
def test_audit_hash_is_canonical_across_sqlite_timezone_round_trip() -> None:
diff --git a/tests/unit/test_project_lifecycle.py b/tests/unit/test_project_lifecycle.py
index c7b138b..7d0f4bd 100644
--- a/tests/unit/test_project_lifecycle.py
+++ b/tests/unit/test_project_lifecycle.py
@@ -8,9 +8,35 @@
import pytest
from sqlalchemy.dialects import postgresql
+from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
+from corpuskit.domain.corpora import PreparedCorpus, PreparedSentence
+from corpuskit.domain.errors import ResourceConflictError
+from corpuskit.persistence.models import Corpus, CorpusVersion, Project
from corpuskit.services.project_lifecycle import lock_project_lifecycle
+from corpuskit.services.projects import ProjectService
+
+ORGANIZATION_ID = UUID("00000000-0000-4000-8000-000000000001")
+USER_ID = UUID("00000000-0000-4000-8000-000000000002")
+PROJECT_ID = UUID("00000000-0000-4000-8000-000000000123")
+CORPUS_ID = UUID("00000000-0000-4000-8000-000000000124")
+VERSION_ID = UUID("00000000-0000-4000-8000-000000000125")
+OPERATION = "corpus.version.create"
+
+
+def _prepared_corpus() -> PreparedCorpus:
+ return PreparedCorpus(
+ language="en-us",
+ sentences=(
+ PreparedSentence(
+ ordinal=0,
+ original_text="New sentence",
+ normalized_text="New sentence",
+ ),
+ ),
+ content_sha256="0" * 64,
+ )
@pytest.mark.asyncio
@@ -18,10 +44,9 @@ async def test_postgres_project_lock_uses_transaction_advisory_lock() -> None:
session = Mock(spec=AsyncSession)
session.get_bind.return_value = SimpleNamespace(dialect=SimpleNamespace(name="postgresql"))
session.scalar = AsyncMock(return_value=None)
- project_id = UUID("00000000-0000-4000-8000-000000000123")
- await lock_project_lifecycle(session, project_id)
- await lock_project_lifecycle(session, project_id)
+ await lock_project_lifecycle(session, PROJECT_ID)
+ await lock_project_lifecycle(session, PROJECT_ID)
first = session.scalar.await_args_list[0].args[0]
second = session.scalar.await_args_list[1].args[0]
@@ -40,7 +65,83 @@ async def test_sqlite_project_lock_is_an_explicit_noop() -> None:
await lock_project_lifecycle(
session,
- UUID("00000000-0000-4000-8000-000000000123"),
+ PROJECT_ID,
)
session.scalar.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_project_row_lock_targets_the_selected_postgres_table() -> None:
+ session = Mock(spec=AsyncSession)
+ project = Mock(spec=Project)
+ session.scalar = AsyncMock(return_value=project)
+
+ resolved = await ProjectService._require_project(
+ session,
+ organization_id=ORGANIZATION_ID,
+ project_id=PROJECT_ID,
+ operation=OPERATION,
+ for_update=True,
+ )
+
+ statement = session.scalar.await_args.args[0]
+ compiled = str(statement.compile(dialect=postgresql.dialect()))
+ assert resolved is project
+ assert "FROM projects" in compiled
+ assert "FOR UPDATE OF projects" in compiled
+ assert "FOR UPDATE OF corpora" not in compiled
+
+
+@pytest.mark.asyncio
+async def test_create_version_rejects_missing_parent_without_locking_corpus() -> None:
+ session = Mock(spec=AsyncSession)
+ corpus = Mock(spec=Corpus)
+ corpus.id = CORPUS_ID
+ session.scalar = AsyncMock(side_effect=(Mock(spec=Project), corpus, None))
+ session.flush = AsyncMock()
+
+ with pytest.raises(ResourceConflictError) as error:
+ await ProjectService.create_version(
+ session,
+ organization_id=ORGANIZATION_ID,
+ user_id=USER_ID,
+ project_id=PROJECT_ID,
+ corpus_id=CORPUS_ID,
+ prepared=_prepared_corpus(),
+ operation=OPERATION,
+ )
+
+ corpus_lookup = session.scalar.await_args_list[1].args[0]
+ compiled = str(corpus_lookup.compile(dialect=postgresql.dialect()))
+ assert "FOR UPDATE" not in compiled
+ assert error.value.operation == OPERATION
+ session.flush.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_create_version_maps_flush_integrity_error_to_conflict() -> None:
+ session = Mock(spec=AsyncSession)
+ corpus = Mock(spec=Corpus)
+ corpus.id = CORPUS_ID
+ latest = Mock(spec=CorpusVersion)
+ latest.id = VERSION_ID
+ latest.version_number = 1
+ failure = IntegrityError("INSERT", {}, RuntimeError("duplicate version"))
+ session.scalar = AsyncMock(side_effect=(Mock(spec=Project), corpus, latest))
+ session.flush = AsyncMock(side_effect=failure)
+
+ with pytest.raises(ResourceConflictError) as error:
+ await ProjectService.create_version(
+ session,
+ organization_id=ORGANIZATION_ID,
+ user_id=USER_ID,
+ project_id=PROJECT_ID,
+ corpus_id=CORPUS_ID,
+ prepared=_prepared_corpus(),
+ operation=OPERATION,
+ )
+
+ assert error.value.operation == OPERATION
+ assert error.value.__cause__ is failure
+ session.add.assert_called_once()
diff --git a/tests/unit/test_project_workspace_api.py b/tests/unit/test_project_workspace_api.py
index a89c2e7..a658e42 100644
--- a/tests/unit/test_project_workspace_api.py
+++ b/tests/unit/test_project_workspace_api.py
@@ -12,10 +12,18 @@
from corpuskit.auth import AuthRole, Principal
from corpuskit.config import Settings
from corpuskit.domain.capabilities import CapabilityReport
+from corpuskit.domain.errors import (
+ ApplicationError,
+ QuotaExceededError,
+ ResourceConflictError,
+ ResourceNotFoundError,
+)
from corpuskit.domain.workspaces import (
CorpusExportFormat,
CorpusUpload,
+ CorpusVersionUpload,
ManualCorpusInput,
+ ManualCorpusVersionInput,
ProjectDeletionInput,
ProjectInput,
ProjectLifecycle,
@@ -55,6 +63,9 @@ def __init__(self) -> None:
NOW,
)
self.deletion_request: ProjectDeletionInput | None = None
+ self.version_request: ManualCorpusVersionInput | None = None
+ self.version_upload: CorpusVersionUpload | None = None
+ self.version_error: ApplicationError | None = None
async def create_project(self, actor: WorkspaceActor, request: ProjectInput) -> ProjectSnapshot:
self.actor = actor
@@ -96,6 +107,52 @@ async def import_corpus(
self.upload = upload
return CorpusCreation(self.corpus, self.version)
+ async def create_manual_version(
+ self,
+ actor: WorkspaceActor,
+ project_id: UUID,
+ corpus_id: UUID,
+ request: ManualCorpusVersionInput,
+ ) -> VersionSnapshot:
+ self.actor = actor
+ assert (project_id, corpus_id) == (PROJECT_ID, CORPUS_ID)
+ if self.version_error is not None:
+ raise self.version_error
+ self.version_request = request
+ return VersionSnapshot(
+ UUID("00000000-0000-4000-8000-000000000024"),
+ CORPUS_ID,
+ VERSION_ID,
+ 2,
+ request.language,
+ len(request.sentences),
+ "c" * 64,
+ "0.1.7",
+ NOW,
+ )
+
+ async def import_version(
+ self,
+ actor: WorkspaceActor,
+ project_id: UUID,
+ corpus_id: UUID,
+ upload: CorpusVersionUpload,
+ ) -> VersionSnapshot:
+ self.actor = actor
+ assert (project_id, corpus_id) == (PROJECT_ID, CORPUS_ID)
+ self.version_upload = upload
+ return VersionSnapshot(
+ UUID("00000000-0000-4000-8000-000000000024"),
+ CORPUS_ID,
+ VERSION_ID,
+ 2,
+ upload.language,
+ 1,
+ "c" * 64,
+ "0.1.7",
+ NOW,
+ )
+
async def list_corpora(
self, actor: WorkspaceActor, project_id: UUID
) -> tuple[CorpusSnapshot, ...]:
@@ -294,6 +351,89 @@ async def test_file_import_is_multipart_typed_and_bounded(ready_report: Capabili
assert bounded_service.upload is None
+@pytest.mark.asyncio
+@pytest.mark.parametrize("role", [AuthRole.OWNER, AuthRole.ADMIN, AuthRole.EDITOR])
+async def test_manual_and_file_version_http_contracts(
+ ready_report: CapabilityReport,
+ role: AuthRole,
+) -> None:
+ service = FakeWorkspaceService()
+ base = f"/api/v1/projects/{PROJECT_ID}/corpora/{CORPUS_ID}/versions"
+ async with _client(ready_report, service, role=role) as client:
+ manual = await client.post(
+ base,
+ json={"language": "en-gb", "sentences": ["Second"]},
+ headers={"X-Request-ID": "manual-version"},
+ )
+ imported = await client.post(
+ f"{base}/imports",
+ data={"language": "fr-fr", "format": "txt"},
+ files={"file": ("second.txt", b"Deuxieme\n", "text/plain")},
+ headers={"X-Request-ID": "file-version"},
+ )
+
+ assert manual.status_code == 201
+ assert manual.json()["version_number"] == 2
+ assert manual.json()["parent_version_id"] == str(VERSION_ID)
+ assert service.version_request == ManualCorpusVersionInput(
+ language="en-gb", sentences=("Second",)
+ )
+ assert imported.status_code == 201
+ assert imported.json()["language"] == "fr-fr"
+ assert service.version_upload is not None
+ assert service.version_upload.filename == "second.txt"
+ assert service.version_upload.content == b"Deuxieme\n"
+ assert service.actor is not None
+ assert service.actor.request_id == "file-version"
+
+
+@pytest.mark.asyncio
+async def test_viewer_cannot_append_a_version(ready_report: CapabilityReport) -> None:
+ service = FakeWorkspaceService()
+ base = f"/api/v1/projects/{PROJECT_ID}/corpora/{CORPUS_ID}/versions"
+ async with _client(ready_report, service, role=AuthRole.VIEWER) as client:
+ manual = await client.post(
+ base,
+ json={"language": "en-us", "sentences": ["Denied"]},
+ )
+ imported = await client.post(
+ f"{base}/imports",
+ data={"language": "en-us", "format": "txt"},
+ files={"file": ("denied.txt", b"Denied\n", "text/plain")},
+ )
+
+ assert manual.status_code == 403
+ assert imported.status_code == 403
+ assert service.version_request is None
+ assert service.version_upload is None
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("error", "expected_status", "expected_code"),
+ [
+ (ResourceNotFoundError("corpus.version.create"), 404, "resource_not_found"),
+ (ResourceConflictError("corpus.version.create"), 409, "resource_conflict"),
+ (QuotaExceededError("corpus.version.create"), 429, "quota_exceeded"),
+ ],
+)
+async def test_version_errors_keep_stable_http_status_and_operation(
+ ready_report: CapabilityReport,
+ error: ApplicationError,
+ expected_status: int,
+ expected_code: str,
+) -> None:
+ service = FakeWorkspaceService()
+ service.version_error = error
+ base = f"/api/v1/projects/{PROJECT_ID}/corpora/{CORPUS_ID}/versions"
+ async with _client(ready_report, service, role=AuthRole.EDITOR) as client:
+ response = await client.post(base, json={"sentences": ["Second"]})
+
+ assert response.status_code == expected_status
+ assert response.json()["code"] == expected_code
+ assert response.json()["operation"] == "corpus.version.create"
+
+
@pytest.mark.asyncio
async def test_multipart_metadata_validation_is_sanitized(ready_report: CapabilityReport) -> None:
service = FakeWorkspaceService()
@@ -315,6 +455,30 @@ async def test_multipart_metadata_validation_is_sanitized(ready_report: Capabili
assert service.upload is None
+@pytest.mark.asyncio
+async def test_version_import_multipart_metadata_validation_is_sanitized(
+ ready_report: CapabilityReport,
+) -> None:
+ service = FakeWorkspaceService()
+ base = f"/api/v1/projects/{PROJECT_ID}/corpora/{CORPUS_ID}/versions"
+ async with _client(ready_report, service) as client:
+ response = await client.post(
+ f"{base}/imports",
+ data={"language": "en-us", "format": "txt"},
+ files={"file": (f"{'x' * 256}.txt", b"Hello", "text/plain")},
+ headers={"X-Request-ID": "invalid-version-upload"},
+ )
+
+ assert response.status_code == 422
+ assert response.json() == {
+ "code": "invalid_request",
+ "message": "The request is not valid for this operation.",
+ "operation": "corpus.version.import",
+ "request_id": "invalid-version-upload",
+ }
+ assert service.version_upload is None
+
+
@pytest.mark.asyncio
async def test_list_and_export_contracts_preserve_integrity_headers(
ready_report: CapabilityReport,