diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6f918ae..524bf74 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,9 @@ This file records what changes **in the product** – process and session state
architecture map (#115).
### Added
+- Public case study page `/case-study` (no login, static content, no data access): problem and target group,
+ what the AI does and what the person decides, the four steps up to the exactly-once ERP export, screenshots of
+ the prepared samples and the limits; linked from the start page for visitors without an account (#75).
- Evidence page `docs/product/evidence.md`: eval accuracy per key field (reproducible with `pnpm evals`), the
measured state of both sample requests, three key decisions and the limits; handling time marked as not yet
measured (#76).
diff --git a/README.md b/README.md
index 9d09638..91167dc 100644
--- a/README.md
+++ b/README.md
@@ -5,8 +5,9 @@ e-mails and PDF/Excel/Word attachments, review every value **beside its source**
approved request **exactly once** to an ERP.
> **Status:** pilot scope built and released as [v0.1.0](https://github.com/Fluory/RequestFlow/releases/tag/v0.1.0)
-> (2026-09-28); acceptance pending ([roadmap](docs/product/roadmap.md), M2). A synthetic showcase runs on Vercel
-> for invited visitors.
+> (2026-09-28); acceptance pending ([roadmap](docs/product/roadmap.md), M2). A synthetic showcase runs on Vercel:
+> the **[case study](https://requestflow-doc-edit.vercel.app/case-study)** is public (German, no login), the
+> [app](https://requestflow-doc-edit.vercel.app) itself is open to invited visitors.
> **Reference project:** built like a real customer engagement for a mid-sized machine-building
> company; the customer is fictional and **all data in this repository is synthetic**.
@@ -24,6 +25,10 @@ upload (.eml/.msg/PDF/XLSX/DOCX) → parse → extract with evidence → verify
- **Tenant isolation:** repository scoping plus forced PostgreSQL row-level security per company.
- **Measurable AI quality:** an eval set with per-field metrics gates prompt and model changes.
+
+
+*Review beside the source – prepared sample, synthetic data.*
+
## What runs today
Each line is a merged PR; details in the [CHANGELOG](CHANGELOG.md).
@@ -38,6 +43,7 @@ Each line is a merged PR; details in the [CHANGELOG](CHANGELOG.md).
- Request list led by the next action, with diagnosis, retries and reprocessing, paged (#43, #58, #95)
- Eval set of 15 synthetic cases as a CI gate (#41); correlated structured logs and health (#45, #56, #88)
- Showcase: prepared sample requests replayed from recorded AI answers (#83)
+- Public case study `/case-study`: problem, flow, sample screenshots and limits – no login, no data access (#75)
## Architecture
@@ -89,3 +95,4 @@ green CI, and is never merged by its author. The process follows the fluory-syst
KI-gestützte Erfassung von Angebotsanfragen: Daten aus E-Mails und Anhängen extrahieren, jede
Angabe neben ihrer Fundstelle prüfen und freigegebene Anfragen genau einmal ans ERP übergeben.
Referenzprojekt mit fiktivem Kunden – alle Daten sind synthetisch.
+Öffentliche Fallstudie: [requestflow-doc-edit.vercel.app/case-study](https://requestflow-doc-edit.vercel.app/case-study).
diff --git a/docs/technical/architecture.md b/docs/technical/architecture.md
index ca5cc3d..c03e5e0 100644
--- a/docs/technical/architecture.md
+++ b/docs/technical/architecture.md
@@ -38,7 +38,7 @@ Every new file belongs to one of these modules – otherwise add the module here
| `observability` | `src/features/observability/` | logger, health, request-list ops data | `/api/health` | IDs only | no PII in logs | built: pino JSON logs with a fixed key set (IDs + codes only, tested on a full run), health (database, storage; informational AI-service reachability and queue backlog) |
| `db` | `src/db/`, deploy step `src/setup.ts` | Drizzle schema, migrations, DB roles | internal | – | migrations as owner role | built: roles check, schema `app`, default grants for `app_rw` |
| `config` | `src/config/` | typed runtime configuration, validated at start (zod); serverless drain budget | internal | secrets (in memory only) | errors name variables, never values | built |
-| `app` | `src/app/` | Next.js routes and pages; composition root `src/app/_server/` (pool, storage client, serverless drain) | `/`, `/login`, `/signup`, `/invite`, `/requests`, `/requests/:id`, `/api/requests`, `/api/documents/:id`, `/api/auth/*`, `/api/health`, `/api/erp-mock/v1/quote-requests` (flag), `/api/jobs/drain` (`CRON_SECRET`) | – | calls module APIs only (dependency-cruiser); drain route: bearer secret, constant-time | partial: login, sign-up, invite, home, requests, review page, ERP mock route |
+| `app` | `src/app/` (static assets: `public/`) | Next.js routes and pages; composition root `src/app/_server/` (pool, storage client, serverless drain) | `/`, `/case-study` (public, static), `/login`, `/signup`, `/invite`, `/requests`, `/requests/:id`, `/api/requests`, `/api/documents/:id`, `/api/auth/*`, `/api/health`, `/api/erp-mock/v1/quote-requests` (flag), `/api/jobs/drain` (`CRON_SECRET`) | – | calls module APIs only (dependency-cruiser); drain route: bearer secret, constant-time; `/case-study` reads no session and no data | partial: login, sign-up, invite, home, public case study, requests, review page, ERP mock route |
| AI service | `services/ai/` | docling parsing, extraction, grounding, evals | internal HTTP | confidential + personal (transient) | bearer token, stateless, no DB/storage access | built: eval runner + 15 weighted synthetic cases + replay gate in CI (#24); `POST /v1/extract` – EML, MSG (recursive attachments), PDF (text layer; scans via OCR with `AI_PDF_OCR=auto`), XLSX (rows), DOCX (paragraphs, table cells) → segments with stable locators, bounded OOXML/MSG parsing, 6 header fields + line items (schema v2, prompt `extract_v2`), normalisers (German numbers, units, dates, calendar weeks → at most `uncertain`), grounding verifier, bearer auth; Vertex adapter with recorded responses (live call unverified) |
| Contracts | `contracts/` | OpenAPI: AI service, ERP export | – | – | contract tests | built: `ai-service.openapi.yaml` (generated from the service), `erp-export.openapi.yaml` (hand-written); TS types + drift tests |
diff --git a/public/case-study/exported.png b/public/case-study/exported.png
new file mode 100644
index 0000000..273dfa4
Binary files /dev/null and b/public/case-study/exported.png differ
diff --git a/public/case-study/requests.png b/public/case-study/requests.png
new file mode 100644
index 0000000..7faf11c
Binary files /dev/null and b/public/case-study/requests.png differ
diff --git a/public/case-study/review.png b/public/case-study/review.png
new file mode 100644
index 0000000..bb555be
Binary files /dev/null and b/public/case-study/review.png differ
diff --git a/src/app/case-study.css b/src/app/case-study.css
new file mode 100644
index 0000000..c3b66b6
--- /dev/null
+++ b/src/app/case-study.css
@@ -0,0 +1,142 @@
+/*
+ * Public case study (#75): a reading page on the pilot design tokens (globals.css). Reading width ~70
+ * characters; screenshots framed like the app's cards; numbered steps because the flow is a real sequence.
+ */
+.case-study {
+ max-width: 960px;
+ gap: 40px;
+}
+.cs-hero {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ padding-top: 8px;
+}
+.cs-brand {
+ align-self: flex-start;
+}
+.cs-eyebrow {
+ font-family: var(--mono);
+ font-size: 12px;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--muted);
+}
+.case-study h1 {
+ font-size: clamp(26px, 4vw, 34px);
+ line-height: 1.2;
+ max-width: 28ch;
+ text-wrap: balance;
+}
+.cs-lede {
+ font-size: 17px;
+ line-height: 1.6;
+ color: var(--text-2);
+ max-width: 68ch;
+}
+.cs-actions {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 12px 20px;
+}
+.cs-note,
+.cs-synthetic {
+ font-size: 14px;
+ color: var(--muted);
+ max-width: 68ch;
+}
+.cs-section {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ border-top: 1px solid var(--rule);
+ padding-top: 24px;
+}
+.cs-section h2 {
+ font-size: 19px;
+}
+.cs-section p,
+.cs-section li {
+ line-height: 1.6;
+ max-width: 70ch;
+}
+.cs-section ul {
+ margin: 0;
+ padding-left: 20px;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+.cs-split {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr));
+ gap: 24px 40px;
+}
+.cs-split > div {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ min-width: 0;
+}
+.cs-steps {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ counter-reset: step;
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 200px), 1fr));
+ gap: 16px;
+}
+.cs-steps li {
+ counter-increment: step;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 14px 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ font-size: 14px;
+ line-height: 1.5;
+}
+.cs-steps li::before {
+ content: counter(step);
+ font-family: var(--mono);
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--brand);
+}
+.cs-shots {
+ display: flex;
+ flex-direction: column;
+ gap: 28px;
+}
+.cs-shots figure {
+ margin: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+.cs-shots img {
+ width: 100%;
+ height: auto;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ background: var(--surface);
+}
+.cs-shots figcaption {
+ font-size: 14px;
+ color: var(--text-2);
+ max-width: 70ch;
+}
+.cs-section .cs-links {
+ list-style: none;
+ padding-left: 0;
+}
+.cs-foot {
+ font-size: 13px;
+ color: var(--muted);
+ border-top: 1px solid var(--rule);
+ padding-top: 16px;
+}
diff --git a/src/app/case-study/page.tsx b/src/app/case-study/page.tsx
new file mode 100644
index 0000000..84e4c0e
--- /dev/null
+++ b/src/app/case-study/page.tsx
@@ -0,0 +1,179 @@
+import type { Metadata } from "next";
+import Image from "next/image";
+import Link from "next/link";
+import { Brand } from "@/app/_components/app-header";
+
+// #75: public case study – static content only. No session lookup and no data access in this page; the
+// auth checks of every other route stay where they are. Screenshots show the prepared samples (#71),
+// synthetic data only.
+export const metadata: Metadata = {
+ title: "Fallstudie – RequestFlow",
+ description: "Angebotsanfragen erfassen, jeden Wert neben seiner Quelle prüfen und genau einmal ans ERP übergeben – Referenzprojekt mit synthetischen Daten.",
+};
+
+const REPO = "https://github.com/Fluory/RequestFlow";
+const EVIDENCE = `${REPO}/blob/main/docs/product/evidence.md`;
+const ADR = `${REPO}/blob/main/docs/decisions/ADR-0001-pilot-architecture.md`;
+const PROPOSAL = `${REPO}/blob/main/docs/product/pilot-vorschlag.md`;
+
+const STEPS = [
+ {
+ title: "Hochladen",
+ text: "Die E-Mail mit ihren Anhängen (PDF, Excel, Word) wird hochgeladen. Eine doppelt eingereichte Anfrage wird erkannt, markiert und mit dem Original verknüpft – nie stillschweigend verworfen.",
+ },
+ {
+ title: "Erkennen mit Quelle",
+ text: "Ein KI-Dienst ohne Zugriff auf Datenbank und Ablage schlägt die Werte vor, jeden mit einem wörtlichen Zitat. Ein Prüfprogramm zählt einen Wert nur als „gefunden“, wenn es das Zitat an der genannten Stelle wiederfindet.",
+ },
+ {
+ title: "Prüfen und freigeben",
+ text: "Eine Person sieht jeden Wert neben seiner Quelle, korrigiert, was unsicher ist oder fehlt, und gibt die Anfrage frei.",
+ },
+ {
+ title: "Genau einmal ins ERP",
+ text: "Idempotenzschlüssel, eindeutige Export-Zeile und Zeilensperre verhindern, dass eine Anfrage zweimal ankommt – auch wenn eine Übergabe nach einem Fehler wiederholt wird.",
+ },
+] as const;
+
+const SHOTS = [
+ {
+ src: "/case-study/requests.png",
+ alt: "Anfrageliste mit zwei vorbereiteten Musterfällen: einer wartet auf Prüfung, einer ist exportiert",
+ caption: "Die Anfrageliste nennt je Anfrage den Kunden, was zu prüfen ist und den nächsten Schritt. Musterfälle sind als Beispiel gekennzeichnet.",
+ },
+ {
+ src: "/case-study/review.png",
+ alt: "Prüfansicht des Musterfalls „Werk Ost“: erkannte Angaben mit Status, daneben die Quelle mit markiertem Zitat",
+ caption: "Prüfansicht „Werk Ost“: Der Liefertermin steht nur als Kalenderwoche in der Mail und ist deshalb „unsicher“ – die Person sieht die markierte Stelle und entscheidet.",
+ },
+ {
+ src: "/case-study/exported.png",
+ alt: "Exportierter Musterfall „Pumpe P204“ mit ERP-Referenz",
+ caption: "Musterfall „Pumpe P204“: freigegeben und genau einmal an das ERP übergeben, mit der Referenz, die das ERP zurückgemeldet hat.",
+ },
+] as const;
+
+export default function CaseStudyPage() {
+ return (
+
+
+
+
Fallstudie · Referenzprojekt
+
Angebotsanfragen erfassen, jeden Wert neben seiner Quelle prüfen – und genau einmal ans ERP übergeben
+
+ RequestFlow liest Anfragen aus E-Mails und ihren PDF-, Excel- und Word-Anhängen, legt jeden erkannten Wert
+ neben die Stelle im Dokument, aus der er stammt, und übergibt eine freigegebene Anfrage genau einmal an das
+ ERP. Gebaut wie ein echtes Kundenprojekt für einen mittelständischen Maschinenbauer: Der Kunde ist fiktiv,
+ alle Daten sind synthetisch.
+
+ Die Live-App ist auf Einladung zugänglich. Nach der Anmeldung führt der Hinweis „Nächster Schritt“ auf der
+ Startseite direkt zum vorbereiteten Musterfall.
+
+
+
+
+
Das Problem
+
+ Im Vertriebsinnendienst eines Maschinenbauers kommen Angebotsanfragen als E-Mail mit Anhängen: Zeichnungen
+ und Stücklisten als PDF, Positionslisten in Excel, Anschreiben in Word. Jemand überträgt Firma,
+ Ansprechpartner, gewünschten Liefertermin und jede Position mit Menge, Werkstoff und Maßen von Hand ins
+ ERP. Das kostet Zeit, Tippfehler fallen spät auf, und eine doppelt erfasste Anfrage erzeugt doppelte
+ Vorgänge.
+
+
+ Für wen: die Sachbearbeitung, die Anfragen erfasst und prüft, und die Teamleitung, die sehen
+ will, was auf Prüfung wartet.
+
+
+
+
+
+
Was die KI macht
+
+
Sie schlägt Kopfdaten (Firma, Ansprechpartner, E-Mail, Telefon, Liefertermin, Zusatzanforderungen) und Positionen (Beschreibung, Menge, Einheit, Werkstoff, Maße) vor.
+
Zu jedem Wert liefert sie ein wörtliches Zitat und die Stelle im Dokument.
+
Nur wenn das Prüfprogramm das Zitat an dieser Stelle wiederfindet, gilt der Wert als „gefunden“. Alles andere ist „unsicher“ oder „fehlt“ und geht an eine Person.
+
+
+
+
Was die Person entscheidet
+
+
Sie sieht jeden Wert neben seiner Quelle; die zitierte Stelle ist markiert.
+
Sie korrigiert unsichere oder fehlende Angaben; die ursprüngliche Erkennung bleibt sichtbar.
+
Sie gibt die Anfrage frei. Ohne Freigabe geht nichts ans ERP.
+
+
+
+
+
+
Der Ablauf
+
+ {STEPS.map((step) => (
+
+ {step.title}
+ {step.text}
+
+ ))}
+
+
+
+
+
Der Musterfall im Bild
+
Synthetische Daten: vorbereitete Musterfälle mit einer einmal aufgezeichneten KI-Antwort.
+ {/* #75: visitors without an account find the public case study here. */}
+
+ So funktioniert RequestFlow – Fallstudie lesen
+
Pilot – alle Daten sind synthetisch.
diff --git a/tests/e2e/case-study.spec.ts b/tests/e2e/case-study.spec.ts
new file mode 100644
index 0000000..309cf84
--- /dev/null
+++ b/tests/e2e/case-study.spec.ts
@@ -0,0 +1,33 @@
+import { expect, test } from "@playwright/test";
+
+// #75: the public case study is reachable without a session, shows static content only and leads through
+// the login to the prepared sample (#71) from the global setup.
+test("a visitor reads the case study without signing in and reaches the sample through the login", async ({ page }) => {
+ const response = await page.goto("/case-study");
+ expect(response?.status()).toBe(200);
+ await expect(page.getByRole("heading", { level: 1 })).toContainText("genau einmal ans ERP");
+
+ // No data access: the seeded sample's customer exists in the database but never appears here, and a
+ // visitor without a session gets no app navigation.
+ await expect(page.getByText("Beispiel Anlagenbau GmbH")).toHaveCount(0);
+ await expect(page.getByRole("navigation", { name: "Hauptnavigation" })).toHaveCount(0);
+ await expect(page.getByText("Synthetische Daten", { exact: false }).first()).toBeVisible();
+
+ await expect(page.getByRole("link", { name: "Gemessene Nachweise" }).first()).toHaveAttribute("href", /\/docs\/product\/evidence\.md$/);
+ await expect(page.getByRole("link", { name: "Quellcode auf GitHub" })).toHaveAttribute("href", "https://github.com/Fluory/RequestFlow");
+ for (const shot of ["requests", "review", "exported"]) {
+ const image = await page.request.get(`/case-study/${shot}.png`);
+ expect(image.status(), `${shot}.png`).toBe(200);
+ }
+
+ await page.getByRole("link", { name: "Live-App öffnen" }).first().click();
+ await expect(page.getByRole("heading", { name: "Anmelden" })).toBeVisible();
+ await page.getByLabel("E-Mail").fill("sachbearbeitung@musterbau.example.com");
+ await page.getByLabel("Passwort").fill(process.env.SEED_PASSWORD!);
+ await page.getByRole("button", { name: "Anmelden" }).click();
+
+ // The start page's next step leads to the open review, where the sample waits.
+ await page.getByTestId("next-step").getByRole("link").click();
+ await page.getByRole("link", { name: "Anfrage Rohrbogen und Flansche fuer Werk Ost", exact: true }).first().click();
+ await expect(page.getByTestId("sample-notice")).toContainText("Vorbereitetes Beispiel");
+});