diff --git a/doc/architecture.md b/doc/architecture.md
new file mode 100644
index 0000000..de77452
--- /dev/null
+++ b/doc/architecture.md
@@ -0,0 +1,594 @@
+# go-documenter — Architekturdokumentation (arc42)
+
+> Detaildokument zur Architektur. Das [README](../README.md) bleibt der fachliche Überblick;
+> dieses Dokument konkretisiert Bausteine, Abläufe, Technologien und Entscheidungen (DEC-*).
+> Anforderungen als testbare Gherkin-Szenarien: [doc/requirements/](requirements/).
+> API-Vertrag: [doc/openapi.yaml](openapi.yaml).
+
+## 1. Einführung und Ziele
+
+Schlankes, serverseitiges Dokumentenmanagement-System (Single-User) in Go:
+
+- Import von **PDF- und Bilddateien** (JPG, PNG, HEIC) über API oder überwachten Ordner
+- Verarbeitung: Textextraktion, OCR, optionale LLM-Anreicherung, Volltextindexierung
+- Finale Ablage in Jahresordnern; **Originaldateien werden niemals inhaltlich verändert**
+- Nachvollziehbarkeit aller Zustandsänderungen durch Event Sourcing
+- API-first: kein Frontend in der ersten Ausbaustufe
+
+Qualitätsziele (Rangfolge): Einfachheit, Testbarkeit, Nachvollziehbarkeit, Zuverlässigkeit, Erweiterbarkeit.
+
+## 2. Randbedingungen
+
+| Randbedingung | Ausprägung |
+|---|---|
+| Sprache | Go (~1.26) |
+| Betrieb | Eigener VServer (Strato, 8 GB RAM) hinter Caddy-Reverse-Proxy, Docker Compose |
+| CI/CD | Gitea + Actions (git2.adelante-it.de, `go-ci`-Runner-Image, gespiegelte Actions) |
+| Vorgehen | **Dokumentation vor Implementierung** (dieses Dokument ist das Gate), TDD über Gherkin-Szenarien |
+| Dokumentation | arc42, deutsch; Diagramme in Mermaid (DEC-DOC-01) |
+| Referenzprojekte | pamietnik (Go-Konventionen), infranas (Betriebsumgebung) |
+
+## 3. Kontextabgrenzung
+
+```mermaid
+flowchart LR
+ client["API-Client
(curl, Skripte, späteres Frontend)"]
+ user["Nutzer
(legt Dateien ab)"]
+ llm["LLM-Dienst
OpenAI-kompatibel
(LocalAI via VPN oder Cloud)"]
+
+ subgraph host["Server (Docker)"]
+ sys["go-documenter"]
+ import["import/ (überwacht)"]
+ archive["archive/{Jahr}/"]
+ data["data/ (SQLite, Staging, Logs)"]
+ end
+
+ client -- "HTTPS + Bearer-Token" --> sys
+ user -- "Datei speichern" --> import
+ sys -- "liest / verschiebt" --> import
+ sys -- "legt ab (unverändert)" --> archive
+ sys -- "Events, Projektion, FTS-Index" --> data
+ sys -- "Chat-Completions (optional)" --> llm
+```
+
+Externe Schnittstellen:
+
+- **API-Clients**: REST/JSON, Bearer-Token (`API_TOKEN`), Vertrag in `doc/openapi.yaml`
+- **Dateisystem**: `import/` (Eingang), `data/staging/` (Verarbeitung), `archive/` (Ablage)
+- **LLM**: OpenAI-kompatible Chat-Completions-API; erreichbar oder nicht — das System funktioniert ohne
+- **OCR**: keine externe Schnittstelle — `ocrmypdf`/`tesseract` sind im Container gebündelt
+
+## 4. Lösungsstrategie
+
+| Bereich | Wahl | Begründung |
+|---|---|---|
+| HTTP | `github.com/go-chi/chi/v5` | minimal, stdlib-kompatibel, pamietnik-Standard |
+| Persistenz | `modernc.org/sqlite` (pure Go) | eine Datei, WAL, kein CGo, kein DB-Server |
+| Volltextsuche | SQLite FTS5 (external content, BM25) | transaktional konsistent mit den Daten, kein Zweitsystem |
+| OCR | `ocrmypdf --sidecar --output-type none` (PDF), `tesseract` (Bilder) | nur Text wird gewonnen, Original bleibt byte-identisch |
+| PDF-Text | `pdftotext` (poppler) via `exec.Command` | beste Extraktionsqualität, Interface-gekapselt |
+| LLM | eigener Minimal-Client (Stdlib-HTTP) | LocalAI ↔ Cloud nur per Config umschaltbar, kein SDK |
+| Event Sourcing | handgerollt, nur Dokument-Aggregat | erfüllt Nachvollziehbarkeit ohne Framework-Komplexität |
+| Watcher | `fsnotify` + periodischer Rescan | Rescan fängt verpasste Events und Startbestand |
+| Config | YAML + Env-Override (Secrets nur Env) | pamietnik-Muster |
+| Logging | `log/slog` JSON + lumberjack-Rotation | strukturiert, pamietnik-Muster |
+| Tests | nur Stdlib `testing`, handgeschriebene Fakes | keine Mock-Frameworks, Interfaces als Test-Seams |
+
+Vollständige Dependency-Liste: `chi/v5`, `modernc.org/sqlite`, `fsnotify`, `google/uuid`, `yaml.v3`, `lumberjack.v2`. Sonst nichts.
+
+## 5. Bausteinsicht
+
+### 5.1 Überblick
+
+```mermaid
+flowchart TB
+ subgraph api_layer["API-Schicht"]
+ api["internal/api
Router, Handler, Auth-Middleware"]
+ end
+
+ subgraph app["Anwendungslogik"]
+ ingest["internal/ingest
Aufnahme: UUID, Hash, Dedup, Staging"]
+ pipeline["internal/pipeline
Runner, Queue, Stages"]
+ importer["internal/importer
fsnotify + Rescan + Stabilität"]
+ end
+
+ subgraph domain_layer["Domäne"]
+ domain["internal/domain
Document, Status, MediaType"]
+ events["internal/events
Event-Store, Replay, Projektion"]
+ end
+
+ subgraph infra["Infrastruktur"]
+ db["internal/db
SQLite, Schema, Stores"]
+ search["internal/search
FTS5-Indexer/Searcher"]
+ storage["internal/storage
Datei-Moves, Jahresordner, Hash"]
+ pdf["internal/pdf
pdftotext"]
+ ocr["internal/ocr
ocrmypdf / tesseract"]
+ llm["internal/llm
OpenAI-kompatibler Client"]
+ config["internal/config"]
+ end
+
+ api --> ingest
+ api --> db
+ api --> search
+ api --> events
+ importer --> ingest
+ ingest --> events
+ ingest --> storage
+ ingest --> pipeline
+ pipeline --> events
+ pipeline --> pdf
+ pipeline --> ocr
+ pipeline --> llm
+ pipeline --> search
+ pipeline --> storage
+ events --> db
+ search --> db
+```
+
+Abhängigkeitsregel: `domain` und `events` importieren keine Infrastruktur. Stages und Handler
+hängen nur von schmalen Interfaces ab; die konkreten Implementierungen (exec, SQLite, HTTP)
+werden in `cmd/server/main.go` verdrahtet.
+
+### 5.2 Pakete
+
+| Paket | Verantwortung |
+|---|---|
+| `cmd/server` | Wiring: Config → Logger → DB → Stores → Worker + Importer → HTTP-Server; graceful shutdown |
+| `internal/domain` | Reine Typen: `Document`, Status-Konstanten, `MediaType` — keine Abhängigkeiten |
+| `internal/events` | Event-Envelope + Payload-Structs, `Store`-Interface, SQLite-Store (Append + synchrone Projektion in einer Transaktion), `Replay` |
+| `internal/pipeline` | `Stage`-Interface, kanonische Reihenfolge, Worker-Loop, DB-backed Work-Queue mit Lease |
+| `internal/ingest` | Gemeinsame Aufnahme für API und Ordner: UUID, SHA-256, Dedup, Staging, `DocumentReceived`, Enqueue |
+| `internal/pdf` | `TextExtractor`-Interface + pdftotext-Implementierung, `needs_ocr`-Heuristik |
+| `internal/ocr` | `Processor`-Interface; ocrmypdf-Sidecar (PDF), tesseract (Bilder), HEIC→temp-PNG |
+| `internal/llm` | `Client`-Interface, OpenAI-kompatible Implementierung, `Enricher` (Prompt + JSON-Parsing) |
+| `internal/search` | `Indexer`/`Searcher`-Interfaces, FTS5-Implementierung inkl. Rebuild |
+| `internal/importer` | Ordnerüberwachung: fsnotify, Stabilitäts-Detektor, periodischer Rescan |
+| `internal/storage` | Datei-Lebenszyklus: Staging-Pfade, Jahresordner, Umbenennung, Hash, atomare Moves — **nie Inhaltsänderung** |
+| `internal/api` | `NewRouter(RouterDeps)`, Handler je Ressource, Bearer-Token-Middleware, `writeError`/`writeJSON` |
+| `internal/db` | `Open` (WAL, busy_timeout, foreign_keys), idempotentes Schema (go:embed), Store-Interfaces + Sentinel-Errors |
+| `internal/config` | YAML + Env-Override; Secrets ausschließlich per Env |
+
+### 5.3 Zentrale Typen (Klassendiagramm)
+
+```mermaid
+classDiagram
+ class Document {
+ +string ID «UUIDv4»
+ +Status Status
+ +string OriginalName
+ +MediaType MediaType
+ +string Origin «api | import_folder»
+ +string ContentHash «SHA-256»
+ +int64 SizeBytes
+ +int PageCount
+ +string StoragePath
+ +string ArchivePath
+ +string Text
+ +bool NeedsOCR
+ +string Title
+ +string Summary
+ +List~string~ Tags
+ +string DocDate
+ +string Language
+ +bool Enriched
+ +string FailedStage
+ +string LastError
+ +int Attempts
+ +int Version «Seq des letzten Events»
+ }
+
+ class Event {
+ +string AggregateID
+ +int Seq
+ +string Type
+ +json.RawMessage Payload
+ +time.Time CreatedAt
+ }
+
+ class Store {
+ <>
+ +Append(ctx, aggregateID, expectedVersion, payloads) error
+ +Load(ctx, aggregateID) List~Event~
+ }
+
+ class Stage {
+ <>
+ +Name() string
+ +Skip(doc Document) bool
+ +Run(ctx, doc Document) List~Payload~
+ }
+
+ class Queue {
+ <>
+ +Enqueue(ctx, documentID, stage) error
+ +Claim(ctx, workerID) *Work
+ +Complete(ctx, documentID) error
+ +Defer(ctx, documentID, notBefore) error
+ }
+
+ class TextExtractor {
+ <>
+ +Extract(ctx, path) ExtractResult
+ }
+ class OCRProcessor {
+ <>
+ +Recognize(ctx, path, mediaType) OCRResult
+ }
+ class Enricher {
+ <>
+ +Enrich(ctx, text) Enrichment
+ }
+ class Indexer {
+ <>
+ +Index(ctx, doc Document) error
+ +Rebuild(ctx) error
+ }
+ class Filer {
+ <>
+ +File(ctx, doc Document) string «archive_path»
+ }
+
+ Store ..> Event
+ Store ..> Document : Replay(events) → Document
+ Stage ..> Document
+ Stage <|.. ExtractStage
+ Stage <|.. OCRStage
+ Stage <|.. EnrichStage
+ Stage <|.. IndexStage
+ Stage <|.. FileStage
+ ExtractStage --> TextExtractor
+ OCRStage --> OCRProcessor
+ EnrichStage --> Enricher
+ IndexStage --> Indexer
+ FileStage --> Filer
+```
+
+### 5.4 Event-Modell
+
+| Event | Payload (snake_case) | Wirkung im Replay |
+|---|---|---|
+| `DocumentReceived` | original_name, media_type, origin, content_hash, size_bytes, staging_path, imported_at | Aggregat entsteht, Status `received` |
+| `ProcessingStarted` | attempt | Status `processing` |
+| `TextExtracted` | text, page_count, char_count, method, needs_ocr | Text/Seiten gesetzt |
+| `OCRCompleted` | text, engine, languages, duration_ms | Text gesetzt, Status `ocr_done` |
+| `DocumentEnriched` | title, summary, tags[], doc_date, language, model, prompt_version | Metadaten gesetzt, Status `enriched` |
+| `EnrichmentSkipped` | reason, attempts | enriched=false, Pipeline läuft weiter |
+| `DocumentIndexed` | char_count, indexed_at | Status `indexed` |
+| `DocumentFiled` | archive_path, filed_at, timestamp_applied | Status `filed` (Endzustand) |
+| `ProcessingFailed` | stage, attempt, error, retryable | Status `failed`, FailedStage/LastError gesetzt |
+| `RetryRequested` | requested_by, from_stage | Status `processing`, Wiederaufnahme |
+
+`Replay(events) → Document` ist eine pure Fold-Funktion — der Zustand ist vollständig aus
+Events rekonstruierbar. Der **Volltext liegt im Event-Payload** (DEC-ES-02), damit auch die
+Projektion und der Suchindex jederzeit aus dem Event-Store neu aufgebaut werden können.
+
+### 5.5 Statusmodell
+
+```mermaid
+stateDiagram-v2
+ [*] --> received : DocumentReceived
+ received --> processing : ProcessingStarted
+ processing --> ocr_done : OCRCompleted
+ processing --> enriched : DocumentEnriched
+ ocr_done --> enriched : DocumentEnriched
+ processing --> indexed : DocumentIndexed (Enrich übersprungen)
+ ocr_done --> indexed : DocumentIndexed (Enrich übersprungen)
+ enriched --> indexed : DocumentIndexed
+ indexed --> filed : DocumentFiled
+ processing --> failed : ProcessingFailed
+ ocr_done --> failed : ProcessingFailed
+ enriched --> failed : ProcessingFailed
+ indexed --> failed : ProcessingFailed
+ failed --> processing : RetryRequested
+ filed --> [*]
+```
+
+## 6. Laufzeitsicht
+
+### 6.1 Import über überwachten Ordner
+
+```mermaid
+sequenceDiagram
+ actor N as Nutzer
+ participant W as importer (Watcher)
+ participant I as ingest
+ participant S as storage
+ participant E as events (Store)
+ participant Q as work_queue
+
+ N->>W: Datei in import/ speichern
+ W->>W: fsnotify-Event → Datei "pending"
+ loop bis Größe+mtime stable_seconds unverändert
+ W->>W: Stabilität prüfen
+ end
+ W->>I: Ingest(pfad, origin=import_folder)
+ I->>S: SHA-256 berechnen (streaming)
+ I->>E: Dedup-Check (content_hash)
+ alt Duplikat
+ I->>S: Datei nach import/rejected/ verschieben
+ I-->>W: Warnung geloggt, kein Aggregat
+ else neu
+ I->>I: UUID vergeben
+ I->>S: Move nach data/staging/{uuid}.{ext}
+ I->>E: Append(DocumentReceived)
+ Note over E: Event + documents-Projektion
in EINER Transaktion
+ I->>Q: Enqueue(uuid, next_stage=extract)
+ end
+```
+
+### 6.2 Verarbeitung durch die Pipeline (Worker)
+
+```mermaid
+sequenceDiagram
+ participant Q as work_queue
+ participant R as pipeline (Runner)
+ participant E as events (Store)
+ participant X as ExtractStage (pdftotext)
+ participant O as OCRStage (ocrmypdf/tesseract)
+ participant L as EnrichStage (LLM)
+ participant F as IndexStage / FileStage
+
+ loop Poll alle 2 s
+ R->>Q: Claim (Lease 15 min)
+ end
+ Q-->>R: document_id, next_stage
+ R->>E: Load + Replay → Document
+ alt Stage = extract (nur lesend!)
+ R->>X: Run(doc)
+ X-->>R: TextExtracted{needs_ocr}
+ else Stage = ocr (Original bleibt byte-identisch)
+ R->>O: Run(doc)
+ Note over O: PDF: ocrmypdf --sidecar --output-type none
Bild: tesseract direkt (HEIC → temp-PNG)
+ O-->>R: OCRCompleted{text}
+ else Stage = enrich (optional)
+ R->>L: Run(doc)
+ alt LLM erreichbar
+ L-->>R: DocumentEnriched{title, tags, …}
+ else LLM offline
+ L-->>R: Fehler (retryable)
+ R->>E: Append(ProcessingFailed{retryable})
+ R->>Q: Defer(not_before = now + Backoff)
+ Note over R,Q: nach max. Versuchen:
EnrichmentSkipped → weiter zu index
+ end
+ else Stage = index / file
+ R->>F: Run(doc)
+ F-->>R: DocumentIndexed / DocumentFiled{archive_path}
+ end
+ R->>E: Append(Events, expectedVersion)
+ R->>Q: next_stage weiterschalten bzw. Complete bei filed
+```
+
+### 6.3 Import über API
+
+```mermaid
+sequenceDiagram
+ actor C as API-Client
+ participant A as api (Handler)
+ participant I as ingest
+ participant Q as work_queue
+
+ C->>A: POST /v1/documents (multipart, Bearer-Token)
+ A->>A: Auth-Middleware (constant-time Vergleich)
+ A->>I: Ingest(stream, origin=api)
+ Note over I: Stream → staging/{uuid}.{ext}.part
TeeReader → SHA-256, dann Rename
+ alt Duplikat
+ I-->>A: ErrDuplicate + existing_id
+ A-->>C: 409 {error, existing_document_id}
+ else neu
+ I->>Q: DocumentReceived + Enqueue
+ A-->>C: 202 {id, status: received}
+ end
+ Note over C: weiterer Ablauf identisch zu 6.2
+```
+
+### 6.4 Suche
+
+```mermaid
+sequenceDiagram
+ actor C as API-Client
+ participant A as api
+ participant S as search (FTS5)
+
+ C->>A: GET /v1/search?q=rechnung+stadtwerke
+ A->>S: Search(q, limit, offset)
+ S->>S: MATCH + bm25() + snippet()
+ S-->>A: [{id, title, snippet, rank}, …]
+ A-->>C: 200 {results, total}
+```
+
+### 6.5 Fehlerfall und Wiederaufnahme (Retry/Resume)
+
+```mermaid
+sequenceDiagram
+ actor C as API-Client
+ participant A as api
+ participant E as events
+ participant Q as work_queue
+ participant R as Runner
+
+ Note over E: Dokument steht auf failed
(ProcessingFailed{stage=ocr, retryable=false})
+ C->>A: POST /v1/documents/{id}/reprocess
+ A->>Q: geclaimt? → 409, sonst weiter
+ A->>E: Append(RetryRequested{from_stage})
+ A->>Q: Enqueue(id)
+ A-->>C: 202
+ R->>E: Load + Replay
+ Note over R: Resume-Stage ableitbar aus Zustand:
erste Stage der kanonischen Reihenfolge,
deren Ergebnis fehlt — fertige Stages laufen nie erneut
+ R->>R: Pipeline ab OCR fortsetzen
+```
+
+## 7. Verteilungssicht
+
+```mermaid
+flowchart TB
+ subgraph vserver["Strato VServer (Ubuntu 24.04)"]
+ caddy["Caddy
:80/:443, TLS/ACME"]
+ subgraph compose["/opt/documenter (Docker Compose)"]
+ app["documenter-api-1
:9060 intern
debian-slim + ocrmypdf/tesseract/poppler/libheif"]
+ vol1[("config/")]
+ vol2[("data/ — SQLite, Staging, Logs")]
+ vol3[("import/")]
+ vol4[("archive/")]
+ end
+ caddy -- "proxy-Netzwerk
(Host-Header-Routing)" --> app
+ app --- vol1 & vol2 & vol3 & vol4
+ end
+ subgraph xps["XPS15 (NetBird-VPN, ggf. offline)"]
+ localai["LocalAI :8080
OpenAI-kompatibel"]
+ end
+ subgraph git2["git2.adelante-it.de"]
+ gitea["Gitea: Repo + Registry + Actions
act_runner (go-ci)"]
+ end
+ app -. "Chat-Completions
(optional)" .-> localai
+ gitea -- "Release: Image push,
Deploy: compose pull && up -d" --> compose
+```
+
+- Keine veröffentlichten Container-Ports; Caddy erreicht den Container über das externe `proxy`-Netzwerk
+- Secrets (`API_TOKEN`, `LLM_API_KEY`) via `/opt/documenter/.env`, nie im Repo
+- Dockerfile: Builder `golang:1.26-alpine` (CGO_ENABLED=0) → Runtime `debian:trixie-slim` mit `ocrmypdf tesseract-ocr-deu tesseract-ocr-eng poppler-utils libheif-examples`, non-root (DEC-RUNTIME-01)
+- Offener Punkt: Erreichbarkeit von LocalAI (VPN-Adresse) aus dem Container früh verifizieren
+
+## 8. Querschnittliche Konzepte
+
+### 8.1 Speicherkonzept (DEC-STORAGE-01)
+
+Genau **zwei Speicherorte**, keine weiteren Systeme:
+
+1. **Eine SQLite-Datei** (`data/documenter.db`): Event-Store (Quelle der Wahrheit),
+ `documents`-Projektion (Read Model), FTS5-Index, Work-Queue. Eine Datei = eine
+ Backup-Einheit; transaktionale Konsistenz gratis; WAL für parallele API-/Worker-Zugriffe.
+2. **Plain-Dateisystem**: `import/` (+ `rejected/`), `data/staging/`, `archive/{JJJJ}/`.
+ Keine Blobs in der DB — Dateien bleiben normale, menschenlesbare Dateien.
+
+Robustheit:
+
+- Originale byte-identisch; nur `os.Rename` (cross-device: copy+fsync+rename), `.part`-Suffix
+ während Schreibvorgängen — nie halbe Dateien sichtbar
+- Verknüpfung Datei↔Kontext doppelt: **UUID im Dateinamen** (Datei→DB) + **SHA-256 in der DB** (DB→Datei)
+- Gestaffelte Wiederherstellung: FTS kaputt → Rebuild-API; Projektion kaputt → Replay aus Events;
+ DB weg → Dateien bleiben lesbar und über die UUID identifizierbar
+- Backup: genau zwei Verzeichnisse (`data/` + `archive/`) per rsync/Snapshot
+
+### 8.2 Unveränderlichkeit der Originale (DEC-IMMUTABLE-01)
+
+Dateien werden ab Aufnahme nur gelesen, verschoben und umbenannt — nie inhaltlich verändert.
+OCR erzeugt ausschließlich Sidecar-Text (`ocrmypdf --output-type none` bzw. tesseract-Ausgabe);
+es wird kein Textlayer eingebettet, keine Bild→PDF-Konvertierung für die Ablage vorgenommen.
+Kontext (Text, Tags, Titel, Events) lebt vollständig extern in der DB.
+Verifikation: SHA-256 vor Import == SHA-256 der archivierten Datei (fester Testfall).
+
+### 8.3 Datei-Lebenszyklus und Benennung (DEC-NAME-01, DEC-YEAR-01, DEC-DUP-01)
+
+- Ablagepfad: `archive/{JJJJ}/{JJJJMMTT-HHMMSS_}bereinigter-name_{uuid}.{ext}`
+- Jahr = **Importzeitpunkt** (deterministisch, LLM-unabhängig); Zeitstempel-Präfix per Config
+- Dedup per SHA-256 (UNIQUE-Index): API → `409` + `existing_document_id`; Watcher → `import/rejected/`
+- Grenze: erneuter Scan desselben Papierdokuments erzeugt andere Bytes und wird nicht erkannt
+
+### 8.4 Ereignisorientierung und Konsistenz (DEC-ES-01, DEC-ES-02)
+
+Event Sourcing nur für das Dokument-Aggregat, handgerollt (~200 Zeilen):
+append-only `events`-Tabelle, `PRIMARY KEY(aggregate_id, seq)` als optimistische
+Nebenläufigkeitskontrolle, **synchrone Projektion** in derselben Transaktion — das Read
+Model ist nie stale. Retries und Übersprünge sind selbst Events (`RetryRequested`,
+`EnrichmentSkipped`), die Wiederaufnahme-Stage ist rein aus dem Replay-Zustand ableitbar.
+
+### 8.5 Fehlerbehandlung und Wiederanlauf (DEC-QUEUE-01)
+
+- DB-backed Work-Queue mit Lease (15 min): Neustart- und Crash-sicher, verwaiste Claims
+ werden übernommen; 1 Worker (konfigurierbar), Poll-Intervall 2 s
+- Retryable Fehler (LLM offline, OCR-Timeout): exponentieller Backoff 30s·2ⁿ (Cap 1 h),
+ max. Versuche konfigurierbar (Default 10)
+- Enrichment ist **optional**: nach erschöpften Versuchen `EnrichmentSkipped` und die
+ Pipeline läuft weiter — ein offlines LLM blockiert nie die Ablage
+- Nicht-retryable Fehler (korrupte Datei, passwortgeschütztes PDF): Status `failed`,
+ manuell per `POST /v1/documents/{id}/reprocess` wiederaufnehmbar
+
+### 8.6 Logging
+
+- `log/slog`, JSON-Handler; stdout + optional Datei mit lumberjack-Rotation
+ (`max_size_mb`/`max_backups`/`max_age_days`); Level per Config/Env
+- HTTP-Zugriffe über chi-Middleware; jede Pipeline-Stage loggt strukturiert:
+ `slog.Info("stage done", "document_id", id, "stage", name, "duration_ms", ms)`
+- Fehler immer mit `"err"`, `"stage"`, `"attempt"` — Korrelation über `document_id`
+
+### 8.7 Testbarkeit und TDD (DEC-REQ-01)
+
+- Anforderungen als **Gherkin-Szenarien** in [doc/requirements/](requirements/), je Szenario
+ ein gleichnamiger Stdlib-Test; Traceability-Tabelle je Datei. Workflow: Szenario → roter Test → Implementierung
+- Nur Stdlib `testing`, table-driven, handgeschriebene In-Memory-Fakes; Interfaces als Seams
+- exec-basierte Komponenten (ocrmypdf, pdftotext, tesseract) über injizierbaren
+ `runner func(ctx, name, args...)`-Seam testbar; LLM über `httptest.Server`
+- Echte SQLite (in-memory/tmpfile) inkl. FTS5 in DB- und Integrationstests
+- Zwei Integrationstests im normalen `go test ./...`: API-Upload → `filed` + suchbar;
+ Ordner-Import → gleiches Ergebnis
+
+### 8.8 Sicherheit (DEC-AUTH-01)
+
+- Statischer Bearer-Token (`API_TOKEN`, nur Env), Vergleich mit `crypto/subtle.ConstantTimeCompare`
+- `/healthz` öffentlich, alles andere tokenpflichtig; TLS terminiert Caddy
+- Upload-Limit per Config (Default 100 MB); nur Whitelist-Endungen (`pdf|jpg|jpeg|png|heic`)
+
+## 9. Architekturentscheidungen (DEC-*)
+
+| ID | Entscheidung | Begründung / Trade-off |
+|---|---|---|
+| DEC-STACK-01 | Go + chi + SQLite + slog, pamietnik-Konventionen | bewährt, minimal, ein Binary; bewusst kein Framework |
+| DEC-DB-01 | `modernc.org/sqlite`, kein DB-Server | pure Go, eine Datei; Grenze: eine Schreib-Verbindung (für Single-User irrelevant) |
+| DEC-FTS-01 | SQLite FTS5 external-content, Sync explizit in Go, Rebuild per API | kein Zweitsystem; offen: `trigram`-Tokenizer, falls Recall bei deutschen Komposita schlecht |
+| DEC-ES-01 | Event Sourcing nur fürs Dokument-Aggregat, synchrone Projektion | volle Nachvollziehbarkeit ohne ES-Framework; keine Eventual-Consistency-Probleme |
+| DEC-ES-02 | Volltext im Event-Payload | vollständige Rekonstruierbarkeit; DB-Wachstum durch Upload-Limit + Text-Cap begrenzt |
+| DEC-QUEUE-01 | DB-backed Work-Claim-Queue mit Lease statt Go-Channels | Neustart-/Crash-sicher, kein Verlust; Trade-off: Poll-Latenz ≤ 2 s (akzeptiert) |
+| DEC-IMMUTABLE-01 | **Originale nie verändern**; OCR nur als Sidecar-Text; Kontext extern | Hashes/Signaturen bleiben gültig, keine Render-Regressionen; Trade-off: archivierte PDFs haben keinen eingebetteten Textlayer — Suche läuft immer über das System |
+| DEC-MEDIA-01 | PDF und Bilder (JPG/PNG/HEIC) in identischer Pipeline | ein Ablauf, eine Ablage; HEIC nur temporär für OCR konvertiert |
+| DEC-NAME-01 | UUID im Archiv-Dateinamen + SHA-256 in DB | bidirektionaler, DB-unabhängiger Link; Trade-off: längere Dateinamen |
+| DEC-YEAR-01 | Jahresordner aus Importdatum, nicht LLM-`doc_date` | deterministisch, funktioniert ohne LLM |
+| DEC-DUP-01 | Dedup via SHA-256, Duplikate abgelehnt | einfach, exakt; erkennt keine Re-Scans desselben Papiers |
+| DEC-OCR-01 | ocrmypdf (PDF) / tesseract (Bilder) via exec hinter Interface | beste Qualität, ein Container; Trade-off: ~600-MB-Image |
+| DEC-EXTRACT-01 | `pdftotext` (poppler) via exec statt pure-Go-Lib | bessere Extraktion; Fat-Image ohnehin nötig |
+| DEC-LLM-01 | OpenAI-kompatibler Minimal-Client; Anreicherung optional/retryable/nachholbar | LocalAI ↔ Cloud per Config; offlines LLM blockiert nie |
+| DEC-RUNTIME-01 | Runtime-Image debian-slim statt distroless (Abweichung von pamietnik) | ocrmypdf/tesseract/poppler/libheif erforderlich |
+| DEC-AUTH-01 | Statischer Bearer-Token, Single-User | ausreichend für v1; Revision bei Frontend/Multi-User |
+| DEC-STORAGE-01 | Nur SQLite + Plain-Dateisystem | s. Kap. 8.1 |
+| DEC-DOC-01 | Mermaid für alle Diagramme | nativ in Gitea gerendert, diffbar; Trade-off: kein Voll-UML (akzeptiert) |
+| DEC-REQ-01 | Gherkin als Doku-Format + Stdlib-Tests (kein godog) | TDD-tauglich ohne Framework; Drift-Kontrolle über Namenskonvention + Traceability-Tabellen |
+
+## 10. Qualitätsanforderungen
+
+| Qualitätsziel | Konkretisierung / Prüfung |
+|---|---|
+| Einfachheit | 6 Dependencies, ein Binary, ein Container, eine DB-Datei |
+| Testbarkeit | jedes Gherkin-Szenario hat einen benannten Test; `go test ./...` ohne externe Dienste |
+| Nachvollziehbarkeit | `GET /v1/documents/{id}/events` zeigt lückenlosen fachlichen Verlauf |
+| Konsistenz | Events + Projektion + FTS in einer Transaktion; Replay == Projektion (Testfall) |
+| Zuverlässigkeit | Crash-Recovery über Queue-Lease; Stabilitäts-Detektor gegen halbe Dateien |
+| Unversehrtheit | SHA-256 vor Import == SHA-256 im Archiv (Testfall) |
+| Suchbarkeit | jedes verarbeitete Dokument per Inhalt findbar; Rebuild jederzeit möglich |
+| Erweiterbarkeit | Frontend später gegen `doc/openapi.yaml`; neue Stages als weitere `Stage`-Implementierung |
+
+## 11. Risiken und offene Punkte
+
+| Risiko / offener Punkt | Einordnung / Maßnahme |
+|---|---|
+| OCR-Qualität schwankt | Original bleibt erhalten → jederzeit Re-OCR per `reprocess` möglich |
+| LocalAI-Erreichbarkeit aus dem Container (VPN) | früh in M8 verifizieren; ggf. `extra_hosts`/Host-Forwarding |
+| FTS5-Recall bei deutschen Komposita | beobachten; ggf. `trigram`-Tokenizer (DEC-FTS-01 offen) |
+| Passwortgeschützte/korrupte PDFs | non-retryable `failed`; Szenario definiert |
+| RAM bei parallelem OCR (VServer 8 GB) | v1: 1 Worker; OCR-Parallelität erst bei Bedarf |
+| Spec-Drift Gherkin ↔ Tests | Namenskonvention + Traceability-Tabelle; optional später Lint-Skript |
+| Backup-Automatisierung | offen; Muster: rsync wie pamietnik (infranas) |
+| Watcher-Unterordner | v1: nein (nur Top-Level von `import/`) |
+| Domain/Caddyfile-Eintrag | vor M9 festlegen |
+
+## 12. Glossar
+
+| Begriff | Bedeutung |
+|---|---|
+| Aggregat | Das Dokument als Einheit der Event-Sourcing-Konsistenz (eine UUID = ein Event-Stream) |
+| Projektion / Read Model | `documents`-Tabelle, abgeleitet aus Events, für Abfragen und Suche |
+| Sidecar-Text | OCR-Ausgabe als reine Textdatei, ohne das Quelldokument zu verändern |
+| Staging | `data/staging/` — Zwischenbereich während der Verarbeitung |
+| Stage | Ein Pipeline-Schritt (extract, ocr, enrich, index, file) hinter dem `Stage`-Interface |
+| Work-Queue | DB-Tabelle, aus der der Worker Arbeit per Lease claimt |
+| Lease | Zeitlich begrenzter Claim; läuft er ab, übernimmt ein Worker die Arbeit erneut |
+| FTS5 | SQLite-Volltextsuchmodul (BM25-Ranking, Snippets) |
+| external content | FTS5-Modus, bei dem der Index auf die `documents`-Tabelle zeigt statt Text zu duplizieren |
diff --git a/doc/openapi.yaml b/doc/openapi.yaml
new file mode 100644
index 0000000..480d62e
--- /dev/null
+++ b/doc/openapi.yaml
@@ -0,0 +1,288 @@
+openapi: "3.1.0"
+info:
+ title: go-documenter API
+ description: |
+ API-first Dokumentenmanagement-System. Dieses Dokument ist die Quelle der Wahrheit
+ für den API-Vertrag; Endpunkte werden hier VOR der Implementierung spezifiziert.
+ Architektur: doc/architecture.md, Anforderungen: doc/requirements/.
+ version: "0.1.0"
+
+servers:
+ - url: /
+ description: Hinter Caddy-Reverse-Proxy
+
+security:
+ - bearerAuth: []
+
+paths:
+ /healthz:
+ get:
+ summary: Liveness-Check mit Versionsinformation
+ security: []
+ responses:
+ "200":
+ description: Dienst läuft
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ status: { type: string, example: ok }
+ commit: { type: string }
+ tag: { type: string }
+
+ /v1/documents:
+ post:
+ summary: Dokument hochladen (PDF, JPG, PNG, HEIC)
+ requestBody:
+ required: true
+ content:
+ multipart/form-data:
+ schema:
+ type: object
+ required: [file]
+ properties:
+ file:
+ type: string
+ format: binary
+ responses:
+ "202":
+ description: Angenommen, Verarbeitung gestartet
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ id: { $ref: "#/components/schemas/UUID" }
+ status: { type: string, example: received }
+ "401": { $ref: "#/components/responses/Unauthorized" }
+ "409":
+ description: Byte-identisches Dokument existiert bereits
+ content:
+ application/json:
+ schema:
+ allOf:
+ - $ref: "#/components/schemas/Error"
+ - type: object
+ properties:
+ existing_document_id: { $ref: "#/components/schemas/UUID" }
+ "413": { description: Upload überschreitet das konfigurierte Größenlimit }
+ "422": { description: Nicht unterstützter Dateityp }
+ get:
+ summary: Dokumente auflisten (Projektion)
+ parameters:
+ - { name: status, in: query, schema: { $ref: "#/components/schemas/Status" } }
+ - { name: tag, in: query, schema: { type: string } }
+ - { name: limit, in: query, schema: { type: integer, default: 50, maximum: 200 } }
+ - { name: offset, in: query, schema: { type: integer, default: 0 } }
+ responses:
+ "200":
+ description: Liste
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ documents:
+ type: array
+ items: { $ref: "#/components/schemas/Document" }
+ total: { type: integer }
+ "401": { $ref: "#/components/responses/Unauthorized" }
+
+ /v1/documents/{id}:
+ get:
+ summary: Einzelnes Dokument abrufen
+ parameters:
+ - { $ref: "#/components/parameters/DocumentID" }
+ - name: include_text
+ in: query
+ description: Volltext im Response mitliefern
+ schema: { type: boolean, default: false }
+ responses:
+ "200":
+ description: Dokument
+ content:
+ application/json:
+ schema: { $ref: "#/components/schemas/Document" }
+ "401": { $ref: "#/components/responses/Unauthorized" }
+ "404": { $ref: "#/components/responses/NotFound" }
+
+ /v1/documents/{id}/file:
+ get:
+ summary: Originaldatei streamen (byte-identisch zum Import)
+ parameters:
+ - { $ref: "#/components/parameters/DocumentID" }
+ responses:
+ "200":
+ description: Dateiinhalt
+ content:
+ application/pdf: { schema: { type: string, format: binary } }
+ image/jpeg: { schema: { type: string, format: binary } }
+ image/png: { schema: { type: string, format: binary } }
+ image/heic: { schema: { type: string, format: binary } }
+ "401": { $ref: "#/components/responses/Unauthorized" }
+ "404": { $ref: "#/components/responses/NotFound" }
+
+ /v1/documents/{id}/events:
+ get:
+ summary: Audit-Trail — alle Events des Dokument-Aggregats
+ parameters:
+ - { $ref: "#/components/parameters/DocumentID" }
+ responses:
+ "200":
+ description: Eventfolge in seq-Reihenfolge
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ events:
+ type: array
+ items: { $ref: "#/components/schemas/Event" }
+ "401": { $ref: "#/components/responses/Unauthorized" }
+ "404": { $ref: "#/components/responses/NotFound" }
+
+ /v1/documents/{id}/reprocess:
+ post:
+ summary: Verarbeitung wiederaufnehmen (nach Fehler oder für Nachanreicherung)
+ parameters:
+ - { $ref: "#/components/parameters/DocumentID" }
+ requestBody:
+ required: false
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ from_stage:
+ type: string
+ enum: [extract, ocr, enrich, index, file]
+ responses:
+ "202": { description: Wiederaufnahme eingeplant }
+ "401": { $ref: "#/components/responses/Unauthorized" }
+ "404": { $ref: "#/components/responses/NotFound" }
+ "409": { description: Dokument ist aktuell in Verarbeitung (geclaimt) }
+
+ /v1/search:
+ get:
+ summary: Volltextsuche (FTS5, BM25-Ranking)
+ parameters:
+ - { name: q, in: query, required: true, schema: { type: string } }
+ - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 100 } }
+ - { name: offset, in: query, schema: { type: integer, default: 0 } }
+ responses:
+ "200":
+ description: Treffer nach Relevanz sortiert
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ results:
+ type: array
+ items:
+ type: object
+ properties:
+ id: { $ref: "#/components/schemas/UUID" }
+ title: { type: string }
+ original_name: { type: string }
+ archive_path: { type: string }
+ snippet: { type: string, description: Treffer-Ausschnitt mit Hervorhebung }
+ rank: { type: number }
+ total: { type: integer }
+ "400": { description: Fehlender oder ungültiger Suchbegriff }
+ "401": { $ref: "#/components/responses/Unauthorized" }
+
+ /v1/admin/search/rebuild:
+ post:
+ summary: FTS-Index vollständig aus der documents-Projektion neu aufbauen
+ responses:
+ "200":
+ description: Rebuild abgeschlossen
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ indexed_documents: { type: integer }
+ duration_ms: { type: integer }
+ "401": { $ref: "#/components/responses/Unauthorized" }
+
+components:
+ securitySchemes:
+ bearerAuth:
+ type: http
+ scheme: bearer
+ description: Statischer API-Token (Env `API_TOKEN`)
+
+ parameters:
+ DocumentID:
+ name: id
+ in: path
+ required: true
+ schema: { $ref: "#/components/schemas/UUID" }
+
+ responses:
+ Unauthorized:
+ description: Fehlender oder ungültiger Token
+ content:
+ application/json:
+ schema: { $ref: "#/components/schemas/Error" }
+ NotFound:
+ description: Dokument nicht gefunden
+ content:
+ application/json:
+ schema: { $ref: "#/components/schemas/Error" }
+
+ schemas:
+ UUID:
+ type: string
+ format: uuid
+ example: a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d
+
+ Status:
+ type: string
+ enum: [received, processing, ocr_done, enriched, indexed, filed, failed]
+
+ Error:
+ type: object
+ properties:
+ error:
+ type: object
+ properties:
+ code: { type: string, example: duplicate_document }
+ message: { type: string }
+
+ Event:
+ type: object
+ properties:
+ seq: { type: integer }
+ event_type: { type: string, example: DocumentReceived }
+ payload: { type: object, description: Event-spezifischer Payload (snake_case) }
+ created_at: { type: string, format: date-time }
+
+ Document:
+ type: object
+ properties:
+ id: { $ref: "#/components/schemas/UUID" }
+ status: { $ref: "#/components/schemas/Status" }
+ original_name: { type: string }
+ media_type: { type: string, enum: [pdf, jpg, png, heic] }
+ origin: { type: string, enum: [api, import_folder] }
+ content_hash: { type: string, description: SHA-256 (hex) }
+ size_bytes: { type: integer }
+ page_count: { type: integer }
+ title: { type: string }
+ summary: { type: string }
+ tags:
+ type: array
+ items: { type: string }
+ doc_date: { type: string, description: Vom LLM erkanntes Dokumentdatum (Metadatum) }
+ language: { type: string }
+ enriched: { type: boolean }
+ archive_path: { type: string, description: Relativ zum Archiv-Wurzelverzeichnis }
+ failed_stage: { type: string }
+ last_error: { type: string }
+ text: { type: string, description: Nur bei include_text=true }
+ imported_at: { type: string, format: date-time }
+ updated_at: { type: string, format: date-time }
diff --git a/doc/requirements/01-import-api.md b/doc/requirements/01-import-api.md
new file mode 100644
index 0000000..f8aa7c6
--- /dev/null
+++ b/doc/requirements/01-import-api.md
@@ -0,0 +1,56 @@
+# Feature: Dokumenten-Upload über die API
+
+Als API-Client möchte ich Dokumente (PDF, JPG, PNG, HEIC) hochladen,
+damit sie automatisch verarbeitet, durchsuchbar gemacht und abgelegt werden.
+
+```gherkin
+Funktionalität: Dokumenten-Upload über die API
+
+ Szenario: Erfolgreicher PDF-Upload
+ Angenommen ein gültiger API-Token
+ Wenn ein PDF per POST /v1/documents (multipart, Feld "file") hochgeladen wird
+ Dann antwortet das System mit 202 und einer neuen Dokument-UUID
+ Und das Dokument hat den Status "received"
+ Und die Datei liegt unter data/staging/{uuid}.pdf
+ Und ein Event "DocumentReceived" mit origin "api" wurde aufgezeichnet
+
+ Szenario: Upload eines Bildes
+ Angenommen ein gültiger API-Token
+ Wenn eine JPG-, PNG- oder HEIC-Datei hochgeladen wird
+ Dann antwortet das System mit 202
+ Und der media_type des Dokuments entspricht dem Dateityp
+
+ Szenario: Duplikat-Upload wird abgelehnt
+ Angenommen ein bereits importiertes Dokument mit bekanntem SHA-256-Hash
+ Wenn eine byte-identische Datei erneut hochgeladen wird
+ Dann antwortet das System mit 409
+ Und die Antwort enthält error.code "duplicate_document" und die existing_document_id
+ Und es wurde kein neues Aggregat angelegt
+
+ Szenario: Upload ohne Token wird abgelehnt
+ Angenommen kein oder ein falscher API-Token
+ Wenn ein Dokument hochgeladen wird
+ Dann antwortet das System mit 401
+
+ Szenario: Nicht unterstützter Dateityp wird abgelehnt
+ Angenommen ein gültiger API-Token
+ Wenn eine Datei mit nicht unterstützter Endung (z.B. .docx) hochgeladen wird
+ Dann antwortet das System mit 422 und error.code "unsupported_media_type"
+
+ Szenario: Upload über dem Größenlimit wird abgelehnt
+ Angenommen ein konfiguriertes Upload-Limit von 100 MB
+ Wenn eine größere Datei hochgeladen wird
+ Dann antwortet das System mit 413
+ Und es verbleibt keine .part-Datei im Staging
+```
+
+## Traceability
+
+| Szenario | Go-Test (Paket `internal/api`, `internal/ingest`) |
+|---|---|
+| Erfolgreicher PDF-Upload | `TestUpload_PDFReturns202AndReceived` |
+| Upload eines Bildes | `TestUpload_ImageSetsMediaType` |
+| Duplikat-Upload wird abgelehnt | `TestUpload_DuplicateReturns409` |
+| Upload ohne Token wird abgelehnt | `TestUpload_MissingTokenReturns401` |
+| Nicht unterstützter Dateityp wird abgelehnt | `TestUpload_UnsupportedTypeReturns422` |
+| Upload über dem Größenlimit wird abgelehnt | `TestUpload_TooLargeReturns413AndCleansUp` |
diff --git a/doc/requirements/02-import-ordner.md b/doc/requirements/02-import-ordner.md
new file mode 100644
index 0000000..2fbf8d2
--- /dev/null
+++ b/doc/requirements/02-import-ordner.md
@@ -0,0 +1,54 @@
+# Feature: Automatischer Import aus dem überwachten Ordner
+
+Als Nutzer möchte ich Dateien einfach im Import-Ordner ablegen,
+damit sie ohne manuellen Eingriff verarbeitet werden.
+
+```gherkin
+Funktionalität: Automatischer Import aus dem überwachten Ordner
+
+ Szenario: Neue Datei wird automatisch erkannt und verarbeitet
+ Angenommen der Watcher überwacht den Import-Ordner
+ Wenn eine PDF-Datei im Import-Ordner gespeichert wird
+ Dann wird die Datei nach Abschluss des Schreibvorgangs in das Staging übernommen
+ Und ein Event "DocumentReceived" mit origin "import_folder" wurde aufgezeichnet
+ Und die Datei liegt nicht mehr im Import-Ordner
+
+ Szenario: Unvollständig geschriebene Datei wird nicht vorzeitig übernommen
+ Angenommen eine Datei wird langsam in den Import-Ordner geschrieben
+ Wenn sich Größe oder Änderungszeit innerhalb der Stabilitätsfrist noch ändern
+ Dann wird die Datei noch nicht übernommen
+ Und erst nach unveränderter Stabilitätsfrist beginnt die Verarbeitung
+
+ Szenario: Beim Start vorhandene Dateien werden übernommen
+ Angenommen im Import-Ordner liegen Dateien, während das System nicht läuft
+ Wenn das System startet
+ Dann werden diese Dateien durch den Start-Scan erkannt und verarbeitet
+
+ Szenario: Periodischer Rescan fängt verpasste Ereignisse
+ Angenommen eine Datei wurde vom Dateisystem-Watcher nicht gemeldet
+ Wenn der periodische Rescan läuft
+ Dann wird die Datei erkannt und verarbeitet
+
+ Szenario: Duplikat aus dem Import-Ordner wird aussortiert
+ Angenommen ein bereits importiertes Dokument mit bekanntem SHA-256-Hash
+ Wenn eine byte-identische Datei im Import-Ordner gespeichert wird
+ Dann wird die Datei nach import/rejected/ verschoben
+ Und eine Warnung mit der existing_document_id wird geloggt
+ Und es wurde kein neues Aggregat angelegt
+
+ Szenario: Nicht unterstützte und versteckte Dateien werden ignoriert
+ Angenommen der Watcher überwacht den Import-Ordner
+ Wenn eine .docx-, .part- oder versteckte Datei dort gespeichert wird
+ Dann wird sie ignoriert und verbleibt unverändert
+```
+
+## Traceability
+
+| Szenario | Go-Test (Paket `internal/importer`) |
+|---|---|
+| Neue Datei wird automatisch erkannt und verarbeitet | `TestImporter_NewFileIsIngested` |
+| Unvollständig geschriebene Datei wird nicht vorzeitig übernommen | `TestImporter_WaitsForStableFile` |
+| Beim Start vorhandene Dateien werden übernommen | `TestImporter_StartupScanPicksUpExistingFiles` |
+| Periodischer Rescan fängt verpasste Ereignisse | `TestImporter_RescanPicksUpMissedFile` |
+| Duplikat aus dem Import-Ordner wird aussortiert | `TestImporter_DuplicateMovedToRejected` |
+| Nicht unterstützte und versteckte Dateien werden ignoriert | `TestImporter_IgnoresUnsupportedAndHiddenFiles` |
diff --git a/doc/requirements/03-textextraktion-ocr.md b/doc/requirements/03-textextraktion-ocr.md
new file mode 100644
index 0000000..26fdfed
--- /dev/null
+++ b/doc/requirements/03-textextraktion-ocr.md
@@ -0,0 +1,58 @@
+# Feature: Textextraktion und OCR
+
+Als System möchte ich den Textinhalt jedes Dokuments gewinnen,
+ohne die Originaldatei jemals zu verändern (DEC-IMMUTABLE-01),
+damit Suche und Anreicherung auf externem Kontext arbeiten können.
+
+```gherkin
+Funktionalität: Textextraktion und OCR
+
+ Szenario: Born-digital-PDF braucht kein OCR
+ Angenommen ein PDF mit maschinenlesbarem Textlayer
+ Wenn die Extract-Stage läuft
+ Dann wird der Text per pdftotext gewonnen
+ Und ein Event "TextExtracted" mit needs_ocr=false wird aufgezeichnet
+ Und die OCR-Stage wird übersprungen
+
+ Szenario: Gescanntes PDF wird per OCR erschlossen
+ Angenommen ein PDF ohne bzw. mit unzureichendem Textlayer
+ Wenn die OCR-Stage läuft
+ Dann wird der Text per ocrmypdf im Sidecar-Modus gewonnen
+ Und ein Event "OCRCompleted" mit engine "ocrmypdf" wird aufgezeichnet
+ Und es wird kein Ausgabe-PDF erzeugt
+
+ Szenario: Bilddateien werden immer per OCR erschlossen
+ Angenommen ein importiertes JPG-, PNG- oder HEIC-Bild
+ Wenn die Verarbeitung läuft
+ Dann liefert die Extract-Stage needs_ocr=true
+ Und der Text wird per tesseract direkt aus dem Bild gewonnen
+
+ Szenario: HEIC wird nur temporär konvertiert
+ Angenommen ein importiertes HEIC-Bild
+ Wenn die OCR-Stage läuft
+ Dann wird für die Erkennung ein temporäres PNG erzeugt
+ Und das temporäre PNG wird nach der Erkennung gelöscht
+ Und die archivierte Datei ist das unveränderte HEIC-Original
+
+ Szenario: Originaldatei bleibt byte-identisch
+ Angenommen ein Dokument mit bekanntem SHA-256-Hash bei der Aufnahme
+ Wenn das Dokument vollständig verarbeitet und abgelegt wurde
+ Dann ist der SHA-256-Hash der archivierten Datei identisch mit dem Hash bei der Aufnahme
+
+ Szenario: Korrupte oder passwortgeschützte Datei schlägt endgültig fehl
+ Angenommen eine nicht lesbare oder passwortgeschützte PDF-Datei
+ Wenn Extraktion und OCR fehlschlagen
+ Dann wird ein Event "ProcessingFailed" mit retryable=false aufgezeichnet
+ Und das Dokument hat den Status "failed" mit gesetzter failed_stage
+```
+
+## Traceability
+
+| Szenario | Go-Test (Pakete `internal/pdf`, `internal/ocr`, `internal/pipeline`) |
+|---|---|
+| Born-digital-PDF braucht kein OCR | `TestExtractStage_TextPDFSkipsOCR` |
+| Gescanntes PDF wird per OCR erschlossen | `TestOCRStage_ScannedPDFUsesSidecarOnly` |
+| Bilddateien werden immer per OCR erschlossen | `TestExtractStage_ImageAlwaysNeedsOCR` |
+| HEIC wird nur temporär konvertiert | `TestOCR_HEICTempConversionIsCleanedUp` |
+| Originaldatei bleibt byte-identisch | `TestPipeline_OriginalHashUnchangedAfterProcessing` |
+| Korrupte oder passwortgeschützte Datei schlägt endgültig fehl | `TestPipeline_CorruptPDFFailsNonRetryable` |
diff --git a/doc/requirements/04-llm-anreicherung.md b/doc/requirements/04-llm-anreicherung.md
new file mode 100644
index 0000000..331f924
--- /dev/null
+++ b/doc/requirements/04-llm-anreicherung.md
@@ -0,0 +1,47 @@
+# Feature: Optionale LLM-Anreicherung
+
+Als Nutzer möchte ich, dass Dokumente semantisch angereichert werden (Titel, Zusammenfassung,
+Tags, Dokumentdatum, Sprache), ohne dass ein nicht erreichbares LLM die Verarbeitung blockiert.
+
+```gherkin
+Funktionalität: Optionale LLM-Anreicherung
+
+ Szenario: Erfolgreiche Anreicherung
+ Angenommen ein Dokument mit extrahiertem Text und ein erreichbares LLM
+ Wenn die Enrich-Stage läuft
+ Dann wird ein Event "DocumentEnriched" mit title, summary, tags, doc_date und language aufgezeichnet
+ Und das verwendete Modell und die Prompt-Version sind im Event dokumentiert
+
+ Szenario: LLM nicht erreichbar führt zu Wiederholung mit Backoff
+ Angenommen das LLM ist nicht erreichbar
+ Wenn die Enrich-Stage fehlschlägt
+ Dann wird ein Event "ProcessingFailed" mit retryable=true aufgezeichnet
+ Und der nächste Versuch wird mit exponentiellem Backoff eingeplant
+
+ Szenario: Nach erschöpften Versuchen läuft die Pipeline weiter
+ Angenommen das LLM bleibt über die maximale Versuchszahl hinaus nicht erreichbar
+ Wenn der letzte Versuch fehlschlägt
+ Dann wird ein Event "EnrichmentSkipped" aufgezeichnet
+ Und das Dokument wird trotzdem indexiert und abgelegt
+
+ Szenario: Unbrauchbare LLM-Antwort ist wiederholbar
+ Angenommen das LLM liefert kein valides JSON
+ Wenn die Enrich-Stage die Antwort nicht parsen kann
+ Dann wird der Fehler als retryable behandelt
+
+ Szenario: Übersprungene Anreicherung ist nachholbar
+ Angenommen ein abgelegtes Dokument mit "EnrichmentSkipped"
+ Wenn POST /v1/documents/{id}/reprocess mit from_stage "enrich" aufgerufen wird
+ Dann wird nur die Anreicherung erneut ausgeführt
+ Und bereits abgeschlossene Schritte laufen nicht erneut
+```
+
+## Traceability
+
+| Szenario | Go-Test (Pakete `internal/llm`, `internal/pipeline`) |
+|---|---|
+| Erfolgreiche Anreicherung | `TestEnrichStage_SuccessEmitsDocumentEnriched` |
+| LLM nicht erreichbar führt zu Wiederholung mit Backoff | `TestEnrichStage_UnreachableLLMIsRetryableWithBackoff` |
+| Nach erschöpften Versuchen läuft die Pipeline weiter | `TestPipeline_EnrichExhaustedSkipsAndContinues` |
+| Unbrauchbare LLM-Antwort ist wiederholbar | `TestEnricher_MalformedJSONIsRetryable` |
+| Übersprungene Anreicherung ist nachholbar | `TestReprocess_FromEnrichOnlyRunsEnrich` |
diff --git a/doc/requirements/05-volltextsuche.md b/doc/requirements/05-volltextsuche.md
new file mode 100644
index 0000000..f85979f
--- /dev/null
+++ b/doc/requirements/05-volltextsuche.md
@@ -0,0 +1,58 @@
+# Feature: Volltextsuche und Index-Rebuild
+
+Als API-Client möchte ich Dokumente über ihren Inhalt und ihre Metadaten finden
+und den Suchindex jederzeit neu aufbauen können.
+
+```gherkin
+Funktionalität: Volltextsuche
+
+ Szenario: Dokument ist über seinen Inhalt findbar
+ Angenommen ein verarbeitetes Dokument, dessen Text "Stromabrechnung Stadtwerke" enthält
+ Wenn GET /v1/search?q=stadtwerke aufgerufen wird
+ Dann enthält das Ergebnis das Dokument mit id, title, original_name, archive_path und snippet
+
+ Szenario: Suche berücksichtigt Metadaten
+ Angenommen ein angereichertes Dokument mit dem Tag "versicherung"
+ Wenn GET /v1/search?q=versicherung aufgerufen wird
+ Dann wird das Dokument gefunden, auch wenn der Begriff nicht im Text vorkommt
+
+ Szenario: Umlaute und Diakritika werden tolerant behandelt
+ Angenommen ein Dokument, dessen Text "Gebührenbescheid" enthält
+ Wenn nach "gebuhrenbescheid" gesucht wird
+ Dann wird das Dokument gefunden
+
+ Szenario: Treffer sind nach Relevanz sortiert
+ Angenommen mehrere Dokumente enthalten den Suchbegriff unterschiedlich häufig
+ Wenn gesucht wird
+ Dann sind die Treffer nach BM25-Relevanz absteigend sortiert
+
+ Szenario: OCR-Inhalte sind durchsuchbar
+ Angenommen ein gescanntes Dokument, dessen Text nur per OCR gewonnen wurde
+ Wenn nach einem Begriff aus dem OCR-Text gesucht wird
+ Dann wird das Dokument gefunden
+
+Funktionalität: Index-Rebuild über die API
+
+ Szenario: Rebuild stellt einen konsistenten Index wieder her
+ Angenommen ein inkonsistenter oder leerer FTS-Index bei gefüllter documents-Projektion
+ Wenn POST /v1/admin/search/rebuild aufgerufen wird
+ Dann wird der Index vollständig aus der Projektion neu aufgebaut
+ Und alle zuvor findbaren Dokumente sind wieder findbar
+
+ Szenario: Rebuild erfordert Authentifizierung
+ Angenommen kein oder ein falscher API-Token
+ Wenn POST /v1/admin/search/rebuild aufgerufen wird
+ Dann antwortet das System mit 401
+```
+
+## Traceability
+
+| Szenario | Go-Test (Pakete `internal/search`, `internal/api`) |
+|---|---|
+| Dokument ist über seinen Inhalt findbar | `TestSearch_FindsDocumentByContent` |
+| Suche berücksichtigt Metadaten | `TestSearch_FindsDocumentByTag` |
+| Umlaute und Diakritika werden tolerant behandelt | `TestSearch_DiacriticsInsensitive` |
+| Treffer sind nach Relevanz sortiert | `TestSearch_ResultsOrderedByBM25` |
+| OCR-Inhalte sind durchsuchbar | `TestSearch_FindsOCRContent` |
+| Rebuild stellt einen konsistenten Index wieder her | `TestSearchRebuild_RestoresConsistentIndex` |
+| Rebuild erfordert Authentifizierung | `TestSearchRebuild_RequiresAuth` |
diff --git a/doc/requirements/06-ablage.md b/doc/requirements/06-ablage.md
new file mode 100644
index 0000000..86d4c73
--- /dev/null
+++ b/doc/requirements/06-ablage.md
@@ -0,0 +1,48 @@
+# Feature: Finale Ablage in Jahresordnern
+
+Als Nutzer möchte ich, dass verarbeitete Dokumente unverändert und nachvollziehbar benannt
+in einer Jahresstruktur abgelegt werden und über die UUID im Dateinamen jederzeit
+ihrem Kontext in der Datenbank zuzuordnen sind.
+
+```gherkin
+Funktionalität: Finale Ablage
+
+ Szenario: Verarbeitetes Dokument landet im Jahresordner
+ Angenommen ein am 2026-07-12 importiertes Dokument
+ Wenn die File-Stage läuft
+ Dann liegt die Datei unter archive/2026/
+ Und ein Event "DocumentFiled" mit dem relativen archive_path wird aufgezeichnet
+ Und die Datei liegt nicht mehr im Staging
+
+ Szenario: Dateiname enthält UUID und optional den Importzeitstempel
+ Angenommen die Konfiguration filename_timestamp=true
+ Wenn ein Dokument "Rechnung Stadtwerke.pdf" abgelegt wird
+ Dann entspricht der Dateiname dem Muster {JJJJMMTT-HHMMSS}_rechnung-stadtwerke_{uuid}.pdf
+ Und bei filename_timestamp=false entfällt nur das Zeitstempel-Präfix
+
+ Szenario: Jahresordner folgt dem Importzeitpunkt
+ Angenommen ein Dokument mit LLM-erkanntem doc_date "2019-03-01", importiert 2026
+ Wenn es abgelegt wird
+ Dann liegt es in archive/2026/ (Importjahr, nicht Dokumentjahr)
+
+ Szenario: Ablage über Volume-Grenzen ist atomar sicher
+ Angenommen Staging und Archiv liegen auf unterschiedlichen Dateisystemen
+ Wenn die File-Stage läuft
+ Dann wird per Kopie mit fsync und anschließendem Rename verschoben
+ Und zu keinem Zeitpunkt ist im Archiv eine unvollständige Datei sichtbar
+
+ Szenario: Datei ist über die API abrufbar
+ Angenommen ein abgelegtes Dokument
+ Wenn GET /v1/documents/{id}/file aufgerufen wird
+ Dann wird die unveränderte Originaldatei mit korrektem Content-Type gestreamt
+```
+
+## Traceability
+
+| Szenario | Go-Test (Pakete `internal/storage`, `internal/pipeline`, `internal/api`) |
+|---|---|
+| Verarbeitetes Dokument landet im Jahresordner | `TestFileStage_MovesToYearFolder` |
+| Dateiname enthält UUID und optional den Importzeitstempel | `TestFiler_FilenamePatternWithUUIDAndTimestamp` |
+| Jahresordner folgt dem Importzeitpunkt | `TestFiler_YearFromImportDateNotDocDate` |
+| Ablage über Volume-Grenzen ist atomar sicher | `TestStorage_CrossDeviceMoveIsAtomic` |
+| Datei ist über die API abrufbar | `TestGetFile_StreamsOriginal` |
diff --git a/doc/requirements/07-nachvollziehbarkeit.md b/doc/requirements/07-nachvollziehbarkeit.md
new file mode 100644
index 0000000..450433b
--- /dev/null
+++ b/doc/requirements/07-nachvollziehbarkeit.md
@@ -0,0 +1,61 @@
+# Feature: Nachvollziehbarkeit, Event Sourcing und Wiederaufnahme
+
+Als Betreiber möchte ich jede Zustandsänderung eines Dokuments lückenlos nachvollziehen,
+den Zustand aus den Ereignissen rekonstruieren und fehlgeschlagene Verarbeitung
+gezielt wiederaufnehmen können.
+
+```gherkin
+Funktionalität: Event Sourcing und Audit-Trail
+
+ Szenario: Jede Zustandsänderung ist als Event nachvollziehbar
+ Angenommen ein vollständig verarbeitetes Dokument
+ Wenn GET /v1/documents/{id}/events aufgerufen wird
+ Dann enthält die Antwort die lückenlose Eventfolge von DocumentReceived bis DocumentFiled
+ Und jedes Event enthält seq, event_type, payload und created_at
+
+ Szenario: Der Zustand ist aus den Events rekonstruierbar
+ Angenommen die aufgezeichnete Eventfolge eines Dokuments
+ Wenn die Events per Replay gefaltet werden
+ Dann entspricht das Ergebnis exakt der documents-Projektion
+
+ Szenario: Konkurrierende Änderungen werden erkannt
+ Angenommen zwei Schreiber mit derselben erwarteten Version eines Aggregats
+ Wenn beide Events anhängen wollen
+ Dann gelingt genau ein Append und der zweite erhält einen Konsistenzfehler
+
+ Szenario: Events und Projektion sind transaktional konsistent
+ Angenommen ein Append von Events schlägt in der Projektion fehl
+ Wenn die Transaktion zurückgerollt wird
+ Dann sind weder Events noch Projektionsänderung gespeichert
+
+Funktionalität: Wiederaufnahme nach Fehlern
+
+ Szenario: Neustart verliert keine Arbeit
+ Angenommen ein Dokument war beim Absturz des Systems in Verarbeitung
+ Wenn das System neu startet und der Claim-Lease abläuft
+ Dann wird die Verarbeitung automatisch wiederaufgenommen
+
+ Szenario: Wiederaufnahme setzt an der richtigen Stelle an
+ Angenommen ein Dokument mit fehlgeschlagener OCR-Stage und Status "failed"
+ Wenn POST /v1/documents/{id}/reprocess aufgerufen wird
+ Dann wird ein Event "RetryRequested" aufgezeichnet
+ Und die Verarbeitung setzt bei der OCR-Stage wieder an
+ Und bereits abgeschlossene Stages laufen nicht erneut
+
+ Szenario: Wiederaufnahme während laufender Verarbeitung wird abgelehnt
+ Angenommen ein Dokument, das aktuell von einem Worker geclaimt ist
+ Wenn POST /v1/documents/{id}/reprocess aufgerufen wird
+ Dann antwortet das System mit 409
+```
+
+## Traceability
+
+| Szenario | Go-Test (Pakete `internal/events`, `internal/pipeline`, `internal/api`) |
+|---|---|
+| Jede Zustandsänderung ist als Event nachvollziehbar | `TestGetEvents_ReturnsCompleteTrail` |
+| Der Zustand ist aus den Events rekonstruierbar | `TestReplay_MatchesProjection` |
+| Konkurrierende Änderungen werden erkannt | `TestAppend_ConcurrencyConflictReturnsError` |
+| Events und Projektion sind transaktional konsistent | `TestAppend_RollbackLeavesNoPartialState` |
+| Neustart verliert keine Arbeit | `TestQueue_ExpiredLeaseIsReclaimed` |
+| Wiederaufnahme setzt an der richtigen Stelle an | `TestReprocess_ResumesFromFailedStage` |
+| Wiederaufnahme während laufender Verarbeitung wird abgelehnt | `TestReprocess_ClaimedDocumentReturns409` |
diff --git a/doc/requirements/README.md b/doc/requirements/README.md
new file mode 100644
index 0000000..6f6b57e
--- /dev/null
+++ b/doc/requirements/README.md
@@ -0,0 +1,27 @@
+# Anforderungen als Gherkin-Szenarien
+
+Anforderungen werden als Gherkin-Szenarien (deutsche Schlüsselwörter: *Angenommen / Wenn / Dann*)
+dokumentiert und im Sinne von TDD umgesetzt (DEC-REQ-01, siehe [architecture.md](../architecture.md)):
+
+1. **Szenario schreiben** (hier, vor der Implementierung)
+2. **Roten Test schreiben** — Stdlib `testing`, Testname = Szenario-Referenz aus der Traceability-Tabelle
+3. **Implementieren**, bis der Test grün ist
+
+Regeln:
+
+- Jedes Szenario hat genau einen gleichnamigen Go-Test; die Zuordnung steht in der
+ Traceability-Tabelle am Ende jeder Datei.
+- Wird ein Test geändert, wird das Szenario im selben Commit nachgezogen (Drift-Kontrolle).
+- Szenarien beschreiben fachliches Verhalten, keine Implementierungsdetails.
+
+## Übersicht
+
+| Datei | Feature |
+|---|---|
+| [01-import-api.md](01-import-api.md) | Dokumenten-Upload über die API |
+| [02-import-ordner.md](02-import-ordner.md) | Automatischer Import aus dem überwachten Ordner |
+| [03-textextraktion-ocr.md](03-textextraktion-ocr.md) | Textextraktion und OCR (PDF + Bilder), Unversehrtheit der Originale |
+| [04-llm-anreicherung.md](04-llm-anreicherung.md) | Optionale LLM-Anreicherung |
+| [05-volltextsuche.md](05-volltextsuche.md) | Volltextsuche und Index-Rebuild |
+| [06-ablage.md](06-ablage.md) | Finale Ablage in Jahresordnern |
+| [07-nachvollziehbarkeit.md](07-nachvollziehbarkeit.md) | Event Sourcing, Audit-Trail, Wiederaufnahme |