diff --git a/csv-table-hub-main/src/routes/_authenticated/review.$uploadId.tsx b/csv-table-hub-main/src/routes/_authenticated/review.$uploadId.tsx
new file mode 100644
index 0000000..1319c09
--- /dev/null
+++ b/csv-table-hub-main/src/routes/_authenticated/review.$uploadId.tsx
@@ -0,0 +1,33 @@
+// M4 (#108): review screen for one stored upload.
+import { createFileRoute, Link } from "@tanstack/react-router";
+import { Toaster } from "sonner";
+import { ReviewTable } from "@/components/review-table";
+import { SessionMenu } from "@/components/session-menu";
+
+export const Route = createFileRoute("/_authenticated/review/$uploadId")({
+ head: () => ({ meta: [{ title: "Review upload — CSV Migrator" }] }),
+ component: ReviewScreen,
+});
+
+function ReviewScreen() {
+ const { uploadId } = Route.useParams();
+ return (
+
+ );
+}
diff --git a/docs/VCRM.md b/docs/VCRM.md
index f21a400..7fb465d 100644
--- a/docs/VCRM.md
+++ b/docs/VCRM.md
@@ -144,8 +144,8 @@ Status as assessed on 2026-10-26 at `main` 032b522. None of these is "Verified"
|----|--------|--------|----------------------|-----------|
| BR-23 | ✅ | Every upload attempt gets an `uploads` row (M3b, #107): id, original filename, SHA-256, uploader, timestamp and outcome (imported, partly_imported, failed, duplicate, rejected). The tenant is the schema the row lives in (decision 6), not a column. Registry UNIQUE hash and name from M1 (#87). `test_preserved_uploads.py` | Integration + DB constraint tests | M1, M3 |
| BR-24 | ⚠️ Partial | Original bytes (`upload_blobs`) and parsed rows (`source_rows`) kept for every upload, including failed ones and files later overwritten or deleted (M3b, #107). Triggers reject UPDATE, DELETE and TRUNCATE (`test_preserved_uploads.py`, 7 tests), and the API's runtime role has no UPDATE, DELETE or TRUNCATE grant on them (M3d, `test_runtime_role.py`). Still to do: show edits kept separate once they exist (M4) | DB negative tests | M3 |
-| BR-25 | ⚠️ Partial | API: `GET /api/csv/uploads/{id}/rows` pages through every stored row (up to 500 a page), with column types and each row's validation errors, including rows beyond the 200 errors reported at upload (M4a, #108, `test_review.py`). Still to do: the review screen and its e2e test (M4b) | API paging + e2e | M4 |
-| BR-26 | ⚠️ Partial | API: include or exclude a row with a required reason; actor and time recorded; insert-only history, latest wins; audited with before and after (M4a, #108, `test_review.py`). Applied to imports in M6 (decision 1 on #108). Still to do: the review screen and its e2e test (M4b) | API + e2e | M4 |
+| BR-25 | ✅ | `GET /api/csv/uploads/{id}/rows` pages through every stored row and column (up to 500 a page), with column types and each row's validation errors, including rows beyond the 200 errors reported at upload (M4a, `test_review.py`). The review screen pages through them with types and invalid cells marked (M4b, `review-table.test.tsx`, e2e `scripts/e2e/m4-review.js`). #108 | API paging + e2e | M4 |
+| BR-26 | ✅ | Include or exclude a row with a required reason; actor and time recorded; insert-only history, latest wins; audited with before and after (M4a, `test_review.py`). The review screen does it, shows it after reload and lists the history (M4b, e2e `scripts/e2e/m4-review.js`). Applied to imports in M6 (decision 1 on #108) | API + e2e | M4 |
| BR-27 | ✅ | Cell edits are insert-only versions in `cell_edits` with old and new value, actor, time and a required reason; the original in `source_rows` is unchanged; edited values are revalidated; each edit is audited (M4a, #108, `test_review.py`: API and DB tests). The screen to make edits arrives in M4b | API + DB tests | M4 |
| BR-28 | ⚠️ Partial | Race guard enforced by UNIQUE constraints (M1: #87, `tests/test_migrations.py`). For signed-in tenants the registry is per tenant, so the same file in two tenants is not a duplicate (M2: #100, `test_duplicate_detection_is_per_tenant`). Requests without a session still share the default registry; M5 finishes the per-tenant model | Concurrency + cross-tenant tests | M1, M5 |
| BR-29 | ⚠️ Partial | In-file exact match only | Unit + integration | M5 |
@@ -178,12 +178,12 @@ Status as assessed on 2026-10-26 at `main` 032b522. None of these is "Verified"
| Status | Count | Requirements |
|--------|-------|-------------|
-| ✅ Verified | 21 | BR-01, BR-03, BR-04, BR-05, BR-06, BR-07, BR-08, BR-09, BR-10, BR-11, BR-12, BR-13, BR-14, BR-15, BR-16, BR-17, BR-18, BR-19, BR-20, BR-23 (M3b), BR-27 (M4a) |
-| ⚠️ Partial | 12 | BR-24, BR-25, BR-26, BR-28, BR-29, BR-30, BR-31, BR-33, BR-34, BR-35, BR-36, BR-37 (ART, added 2026-10-26) |
+| ✅ Verified | 23 | BR-01, BR-03, BR-04, BR-05, BR-06, BR-07, BR-08, BR-09, BR-10, BR-11, BR-12, BR-13, BR-14, BR-15, BR-16, BR-17, BR-18, BR-19, BR-20, BR-23 (M3b), BR-25, BR-26, BR-27 (M4) |
+| ⚠️ Partial | 10 | BR-24, BR-28, BR-29, BR-30, BR-31, BR-33, BR-34, BR-35, BR-36, BR-37 (ART, added 2026-10-26) |
| ❌ Not started | 2 | BR-32 (ART, added 2026-10-26); BR-38 (added 2026-10-27) |
| ⏸ Deferred by decision | 3 | BR-02, BR-21, BR-22 |
-**Headline (2026-10-28):** with BR-23 verified by M3b (#107) and BR-27 by M4a (#108), **21 of 38 (55 %)** are verified, 3 are deferred by decision, and the other 14 are partial or not started.
+**Headline (2026-10-28):** with BR-23 verified by M3b (#107) and BR-25, BR-26 and BR-27 by M4 (#108), **23 of 38 (61 %)** are verified, 3 are deferred by decision, and the other 12 are partial or not started.
**Headline (2026-10-26):** with the 15 ART requirements added, **19 of 37 (51 %)** are verified, 3 are deferred by decision, and the other 15 are partial or not started.
diff --git a/scripts/e2e/m4-review.js b/scripts/e2e/m4-review.js
new file mode 100644
index 0000000..6ea2982
--- /dev/null
+++ b/scripts/e2e/m4-review.js
@@ -0,0 +1,67 @@
+// M4 end to end (#108): a reviewer reviews an upload in the browser. Excludes a row, edits a cell,
+// checks the history, reloads to show it persisted, and confirms the imported table is unchanged.
+//
+// Needs the API and the frontend running, and a tenant admin or reviewer account:
+// E2E_TENANT=dev E2E_USER=admin E2E_PASSWORD=... node scripts/e2e/m4-review.js
+// Optional: E2E_API (default http://127.0.0.1:8000), E2E_FRONTEND (default http://127.0.0.1:8080).
+// Uses Playwright's Chromium. Prints one PASS or FAIL line per check; exits 1 on any FAIL.
+const { chromium } = require('playwright');
+const API = process.env.E2E_API || 'http://127.0.0.1:8000', FE = process.env.E2E_FRONTEND || 'http://127.0.0.1:8080';
+const tenant = process.env.E2E_TENANT, user = process.env.E2E_USER, pw = process.env.E2E_PASSWORD;
+if (!tenant || !user || !pw) { console.error('Set E2E_TENANT, E2E_USER and E2E_PASSWORD'); process.exit(2); }
+const name = `m4_${Date.now()}.csv`;
+const check = (label, ok) => { console.log(`${ok ? 'PASS' : 'FAIL'} ${label}`); if (!ok) process.exitCode = 1; };
+
+(async () => {
+ const login = await (await fetch(API + '/api/auth/login', { method: 'POST', headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ tenant, username: user, password: pw }) })).json();
+ const auth = { authorization: 'Bearer ' + login.token, 'content-type': 'application/json' };
+ const up = await (await fetch(API + '/api/csv/upload', { method: 'POST', headers: auth,
+ body: JSON.stringify({ fileName: name, content: `n,t\n1,${name}\nx,b\n3,c\n`, types: ['int8', 'text'] }) })).json();
+ console.log('upload', up.status, 'inserted', up.insertedRows, 'failed', up.failedRows, 'uploadId', up.uploadId);
+
+ const browser = await chromium.launch();
+ const page = await (await browser.newContext()).newPage();
+ const errs = [];
+ page.on('pageerror', (e) => errs.push(e.message));
+ await page.goto(FE + '/auth');
+ await page.fill('#tenant', tenant); await page.fill('#username', user); await page.fill('#password', pw);
+ await page.click('button[type=submit]');
+ await page.waitForURL(FE + '/');
+ await page.locator('tr', { hasText: name }).getByRole('link', { name: 'Review' }).click();
+ await page.waitForURL(`${FE}/review/${up.uploadId}`);
+ await page.getByRole('heading', { name }).waitFor();
+ check('3 rows listed', await page.getByText(/3 rows · 0 excluded · 0 edited/).count() === 1);
+ check('row 2 flagged invalid', await page.getByText('Invalid').count() === 1);
+
+ const row3 = page.locator('tr', { has: page.getByRole('button', { name: 'Edit row 3 n' }) });
+ await row3.getByRole('button', { name: 'Exclude' }).click();
+ await page.fill('#review-reason', 'duplicate of row 1');
+ await page.getByRole('button', { name: 'Save' }).click();
+ await page.getByText(/1 excluded/).waitFor();
+ check('row 3 excluded', await row3.getByText('Excluded').count() === 1);
+
+ await page.getByRole('button', { name: 'Edit row 2 n' }).click();
+ await page.fill('#review-value', '2');
+ await page.fill('#review-reason', 'typo in the source file');
+ await page.getByRole('button', { name: 'Save' }).click();
+ await page.getByText(/1 edited/).waitFor();
+ check('row 2 now valid', await page.getByText('Invalid').count() === 0);
+
+ await page.reload();
+ await page.getByText(/3 rows · 1 excluded · 1 edited/).waitFor();
+ const cell = page.getByRole('button', { name: 'Edit row 2 n' }).locator('xpath=..');
+ check('after reload: edit kept, original shown', (await cell.getAttribute('title')) === 'Original: x');
+
+ const row2 = page.locator('tr', { has: page.getByRole('button', { name: 'Edit row 2 n' }) });
+ await row2.getByRole('button', { name: 'History of row 2' }).click();
+ await page.getByText('n: "x" → "2"').waitFor();
+ check('history shows the edit with actor and reason',
+ await page.getByText(new RegExp(`${user} · .* · typo in the source file`)).count() === 1);
+ await page.keyboard.press('Escape');
+
+ const files = await (await fetch(API + '/api/csv/files', { headers: auth })).json();
+ check('imported table unchanged (decision 1)', files.find((f) => f.upload_id === up.uploadId).row_count === 2);
+ check('no page errors', errs.length === 0);
+ await browser.close();
+})().catch((e) => { console.error(e); process.exit(1); });