diff --git a/.planning/phases/11-filter-engine-results-ui-saved-searches/11-RESEARCH.md b/.planning/phases/11-filter-engine-results-ui-saved-searches/11-RESEARCH.md
new file mode 100644
index 0000000..2804653
--- /dev/null
+++ b/.planning/phases/11-filter-engine-results-ui-saved-searches/11-RESEARCH.md
@@ -0,0 +1,567 @@
+# Phase 11: Filter Engine, Results UI & Saved Searches - Research
+
+**Researched:** 2026-07-21
+**Domain:** Server-side Query/Filter-Engine über eine globale Postgres-Tabelle (Prisma/NestJS) + Next.js-App-Router-Trefferliste mit persönlichen (per-user) Suchprofilen und Triage.
+**Confidence:** HIGH (alle Kernbefunde gegen die laufende DB und den Quellcode verifiziert)
+
+## Summary
+
+Phase 11 baut die Anzeige- und Interaktionsschicht auf der bereits gefüllten globalen `Tender`-Tabelle (verifiziert: 1671 Zeilen, davon 1639 `active`). Die Arbeit ist **fast vollständig mit dem vorhandenen Stack umsetzbar** — es sind keine neuen Runtime-npm-Pakete nötig. Der Backend-Teil erweitert die bestehenden Read-Endpunkte in `TendersController` um `where`/`orderBy`/Pagination; der Frontend-Teil ersetzt den Platzhalter `modules/tender-radar/page.tsx` durch eine echte Trefferliste, Filter-Panel und Detailansicht.
+
+Die **Datenrealität diktiert mehrere Design-Entscheidungen** und weicht von naiven Annahmen ab (alle gegen die Live-DB verifiziert): `bundesland` ist zu **100 % NULL** (Normalizer hat NUTS→Bundesland explizit auf diese Phase vertagt), `estimatedValue` ist zu **91,6 % NULL** (Graceful-Handling ist damit kein Randfall, sondern der Normalfall), `rawPayload` ist zu **100 % NULL** (es gibt keine gespeicherten Dokument-URLs für UI-02), und `cpvCodes` liegen in **inkonsistenten Formaten** vor ("45", "45000000", "45000000-7"). `region` enthält NUTS-Codes (DE27B…), aus deren NUTS-1-Präfix sich Bundesland zuverlässig ableiten lässt.
+
+Der etablierte Codebase-Konvention für **per-user-Modultabellen** ist NICHT `forTenant()`/RLS, sondern manuelles `where: { userId }`-Scoping im Service (siehe `FavoritesService`, T-08-06) — RLS-Policies existieren nur für Auth-Kerntabellen. TanStack Query ist trotz CLAUDE.md-Empfehlung **nicht installiert**; das reale Datenabruf-Muster ist `fetch` + `useState`/`useEffect`.
+
+**Primary recommendation:** Backend-Filter deklarativ über einen konditionalen Prisma-`where`-Builder (kein Raw-SQL außer optional für CPV-Präfix), neue Modelle `TenderSavedSearch` + `TenderTriage` nach dem `FavoritesService`-Muster (userId-Scoping, kein RLS), Bundesland aus `region`-NUTS-Präfix ableiten (Normalizer + Backfill-Migration), Frontend als URL-Param-getriebene Master-Detail-Ansicht mit Plain-`fetch`-Client — konsistent mit dem bestehenden `tender-radar-api.ts`.
+
+
+## User Constraints (from CONTEXT.md)
+
+### Locked Decisions
+
+**Filter (alle in dieser Phase)**
+- **D-01:** Freitext-Suche über Titel + Auftraggeber (`buyerName`). FILTER-01. (REQUIREMENTS nennt Titel/Beschreibung — Beschreibung liegt ggf. in `rawPayload`; Freitext auf indexierte Felder Titel+buyerName, `rawPayload`-Volltext ist Claude's Discretion.)
+- **D-02:** Region / PLZ / Bundesland. FILTER-02. Felder liegen vor (`region`, `plz`, `bundesland`).
+- **D-03:** CPV-Code / Branche — hierarchische Auswahl mit Autocomplete. FILTER-03. `cpvCodes` liegt als Array vor. Hierarchie/Autocomplete-Datenquelle (CPV-Katalog) = Research-Punkt.
+- **D-04:** Abgabefrist inkl. Option „nur noch offene" (Standard-Ansicht). FILTER-04. `deadlineAt` liegt vor.
+- **D-05:** Geschätzter Auftragswert min/max. FILTER-05. **Ausschreibungen ohne Wertangabe (`estimatedValue` null) dürfen NIE ausgeschlossen oder zu Fehlern führen** — sie erscheinen weiter, nur nicht bei aktivem min/max-Wertfilter, oder mit klarer „keine Wertangabe"-Markierung (genaue Semantik = Plan-Entscheidung, aber „graceful" ist Pflicht-Erfolgskriterium).
+
+**Trefferliste & Detail**
+- **D-06:** Durchsuchbare, sortierbare Liste — Sortierung nach Frist, Wert, Veröffentlichungsdatum. UI-01.
+- **D-07:** Detailansicht pro Ausschreibung: alle Felder + Link zur Quelle (`sourceUrl`) + ggf. Dokument-URLs aus dem Notice. **Keine lokale Spiegelung der Vergabeunterlagen** — nur Links. UI-02.
+
+**Persönliche Funktionen (pro Nutzer, mandantenbewusst)**
+- **D-08:** Suchprofile: Filter-Kombination benannt speichern, bearbeiten, löschen. **Pro Nutzer**, auf den eigenen Mandanten gescoped. FILTER-06.
+- **D-09:** Gelesen/Ungelesen pro Nutzer. UI-03.
+- **D-10:** Favorit/Merkliste pro Nutzer + Merklisten-Filter in der Liste. UI-04.
+- **D-11:** Triage-Zustände (gelesen, favorit) und Suchprofile sind **per-user**, NICHT tenant-weit geteilt.
+
+**Abdeckungs-Transparenz**
+- **D-12:** UI weist Datenabdeckung aus: Oberschwelle vs. Unterschwelle. UI-05. Aktuell nur DÖE-Quelle — die Anzeige verhindert, dass eine dünne/leere Liste als Fehler gelesen wird. Genaue Formulierung/Platzierung = Plan-Entscheidung; der Hinweis MUSS aber existieren.
+
+### Claude's Discretion
+- Query-Architektur: serverseitige Filter/Sortierung/Pagination über die globale `Tender`-Tabelle (kein `forTenant`-Scoping — Tender ist global), Prisma `where`-Aufbau, Indizes für Filter-Performance.
+- Neue **per-user/per-tenant** Tabellen für Suchprofile + Triage (read/favourite) — Namen, Schema, Unique-Constraints. `FavoriteLink` existiert bereits für ein anderes Feature; NICHT wiederverwenden, eigenes Tender-Triage-Modell.
+- CPV-Hierarchie/Autocomplete-Umsetzung (statischer Katalog vs. abgeleitet aus vorhandenen `cpvCodes`).
+- Frontend-Aufteilung: Liste, Filter-Panel, Detailseite, Suchprofil-Verwaltung — Komponenten + TanStack-Query-Hooks.
+
+### Deferred Ideas (OUT OF SCOPE)
+- E-Mail-Benachrichtigungen (Phase 12)
+- Scraping-Adapter + Cross-Source-Dedup (Phase 13)
+- RSS/E-Mail-Quellen + ausgeschlossene-Portale-Transparenz UI-06 + i18n-Rollout (Phase 14)
+- CSV/Excel-Export (Backlog)
+
+
+
+## Phase Requirements
+
+| ID | Description | Research Support |
+|----|-------------|------------------|
+| FILTER-01 | Volltext-Stichwort (Titel/Auftraggeber) | `where.OR` mit `title`/`buyerName` `contains … mode:'insensitive'` — bei 1671 Zeilen kein Index nötig (Pattern 1). Beschreibung nicht durchsuchbar: `rawPayload` = 100 % NULL. |
+| FILTER-02 | Region / PLZ / Bundesland | `plz` (60 % befüllt) direkt; `region` = NUTS-Code; **`bundesland` 100 % NULL → aus `region`-NUTS-1-Präfix ableiten** (Pattern 2, Pitfall 1). |
+| FILTER-03 | CPV hierarchisch + Autocomplete | Statischer CPV-Divisions/Gruppen-Katalog mit DE-Labels + Präfix-Match; `cpvCodes` inkonsistent formatiert → beidseitig normalisieren (Pattern 3, Pitfall 2). |
+| FILTER-04 | Abgabefrist + „nur noch offene" (Default) | `deadlineAt` Range; „offen" = `deadlineAt >= now OR deadlineAt IS NULL` — 17 % NULL-Deadlines nicht verstecken (Pattern 4). |
+| FILTER-05 | Auftragswert min/max, NULL graceful | 91,6 % NULL → `OR:[{estimatedValue:{gte,lte}},{estimatedValue:null}]` + „ohne Wertangabe einschließen"-Toggle (Default an) (Pattern 5, Pitfall 3). |
+| FILTER-06 | Suchprofile speichern/bearbeiten/löschen (per-user) | Neues Modell `TenderSavedSearch` (userId+tenantId, `filters Json`), `@@unique([userId,name])`, `FavoritesService`-Scoping-Muster. |
+| UI-01 | Durchsuchbare, sortierbare Liste (Frist/Wert/Datum) | `orderBy` aus whitelisted Sort-Key; Pagination wie bestehend. Indizes vorhanden für `deadlineAt`/`publishedAt`, `estimatedValue` ergänzen. |
+| UI-02 | Detailansicht + Quell-Link + Dokument-URLs | `sourceUrl` 100 % befüllt; **Dokument-URLs NICHT gespeichert (`rawPayload` NULL)** → nur `sourceUrl` + `ocid`-Portal-Link; Live-Fetch optional (Open Question 1). |
+| UI-03 | Gelesen/Ungelesen (per-user) | `TenderTriage.isRead`, `@@unique([userId,tenderId])`, Upsert; Batch-Merge in Listen-Page. |
+| UI-04 | Favorit/Merkliste + Merklisten-Filter (per-user) | `TenderTriage.isFavorite`; Merklisten-Filter treibt Tender-Query aus Triage-`tenderId`-Liste. |
+| UI-05 | Abdeckungs-Anzeige (Oberschwelle/Unterschwelle) | Statisches Coverage-Banner, gespeist aus `distinct sourcePortal` (=1: `doe-opendata`); kein Schwellen-Feld in Daten (Pattern 6). |
+
+
+## Architectural Responsibility Map
+
+| Capability | Primary Tier | Secondary Tier | Rationale |
+|------------|-------------|----------------|-----------|
+| Filter/Sort/Pagination über Tender | API / Backend (NestJS + Prisma) | — | Globale Tabelle, kein Client-seitiges Filtern über 1671+ Zeilen; `where`-Builder gehört in den Controller/Service. |
+| Bundesland-Ableitung aus NUTS | API / Backend (Normalizer + Backfill) | Database (Backfill-Migration) | Ableitung muss für neue Ingests im Normalizer greifen + einmalig für Bestand nachgezogen werden. |
+| CPV-Katalog + Autocomplete | Frontend Server (Static Data) | API (Filter-Match) | Katalog ist statische Referenzdaten (JSON im Repo); Autocomplete rein clientseitig, Match serverseitig per Präfix. |
+| Saved Searches (CRUD) | API / Backend | Database (neue Tabelle) | Per-user Persistenz; userId-Scoping im Service. |
+| Triage read/favourite | API / Backend | Database (neue Tabelle) | Per-user Zustand pro Tender; Upsert auf `@@unique([userId,tenderId])`. |
+| Filter-State (aktive Filter) | Browser / Client (URL searchParams) | — | Teilbar, deep-linkbar, deckungsgleich mit Saved-Search-Serialisierung. |
+| Trefferliste / Detail-Rendering | Browser / Client ('use client') | — | Module werden via dynamic import mit `ssr:false` geladen (module-loader.ts). |
+| Coverage-Banner | Browser / Client | API (`distinct sourcePortal`) | Signal kommt vom Backend, Darstellung statisch im Client. |
+
+## Standard Stack
+
+### Core (alles bereits im Projekt vorhanden — keine Neuinstallation)
+| Library | Version | Purpose | Why Standard |
+|---------|---------|---------|--------------|
+| Prisma | 7.x (Client generiert) | `where`/`orderBy`/`count`/`upsert` Query-Bau | Bereits im `TendersController` genutzt; typsicher [VERIFIED: codebase apps/api/src/tenders/tenders.controller.ts]. |
+| NestJS + class-validator | 11.x | Erweitertes `TenderQueryDto` (Filter-Params validieren) | Bestehendes `TenderQueryDto`-Muster mit `@Type`/`@IsOptional` [VERIFIED: codebase dto/tender-query.dto.ts]. |
+| Next.js App Router (React 19) | 16.x | Trefferliste, Filter-Panel, Detail — `'use client'` | Module via `dynamic(..., {ssr:false})` in module-loader.ts [VERIFIED: codebase]. |
+| next-intl | 4.13.x | i18n — bereits verdrahtet (`useTranslations`) | dkv-fleet nutzt es; tender-radar-Stub hat i18n bewusst nach Phase 14 vertagt (Discretion, s. Open Question 3). |
+| Zustand | 5.0.x | optionaler UI-State (Panel offen/zu) | Installiert; für Filter-State jedoch URL-Params bevorzugt. |
+| Tailwind + shadcn-artige Komponenten | 4.x | Layout (bestehende Utility-Klassen-Konvention) | SourceConfigForm/dkv-Komponenten als Vorlage [VERIFIED: codebase]. |
+
+### Supporting
+| Library | Version | Purpose | When to Use |
+|---------|---------|---------|-------------|
+| Vitest | 3.x | Unit-Tests Web (`vitest run`) + API-Specs | Web-`test`-Script vorhanden; API hat `.spec.ts` neben Services [VERIFIED: codebase]. |
+
+### Alternatives Considered
+| Instead of | Could Use | Tradeoff |
+|------------|-----------|----------|
+| Plain `fetch` + `useState` (etabliert) | TanStack Query | **NICHT installiert** — Einführung wäre Netto-Neu-Infra für eine Phase; inkonsistent mit `tender-radar-api.ts`/dkv. Nur einführen, wenn Caching/Background-Refresh explizit gewünscht (Open Question 4). |
+| `forTenant()`/RLS für neue Tabellen | manuelles `where:{userId}`-Scoping | Kein Modultabellen-Modell nutzt RLS; `FavoritesService` (T-08-06) ist die belegte Konvention. CONTEXT nennt `forTenant`, die Codebase-Realität ist manuelles Scoping (Pitfall 4). |
+| CPV-Präfix via Prisma `hasSome` auf normalisierter Spalte | Raw-SQL `unnest … LIKE` | `hasSome` ist typsicher + GIN-indexierbar, erfordert aber eine normalisierte CPV-Spalte (Ingestion/Backfill). Raw-SQL vermeidet Schema-Änderung, mischt aber SQL in den Controller. |
+
+**Installation:** Keine neuen Runtime-Pakete. (Optionaler CPV-Vollkatalog = statische JSON-Datendatei im Repo, kein npm-Dependency.)
+
+## Package Legitimacy Audit
+
+> Diese Phase installiert **keine** externen Pakete — sie nutzt ausschließlich bereits im Monorepo vorhandene Dependencies (Prisma, NestJS, Next.js, next-intl, Zustand, Vitest).
+
+| Package | Registry | Age | Downloads | Source Repo | Verdict | Disposition |
+|---------|----------|-----|-----------|-------------|---------|-------------|
+| — (keine Neuinstallation) | — | — | — | — | N/A | — |
+
+**Packages removed due to [SLOP] verdict:** none
+**Packages flagged as suspicious [SUS]:** none
+
+*Falls der Planer sich für einen CPV-Vollkatalog entscheidet: die offizielle EU-CPV-Liste ist ein statischer Datensatz (JSON/CSV), der als Datei ins Repo committet wird — kein npm-Paket, keine Supply-Chain-Fläche.*
+
+## Architecture Patterns
+
+### System Architecture Diagram
+
+```
+[Browser: tender-radar/page.tsx ('use client')]
+ │ URL searchParams (?q=&plz=&cpv=&open=1&sort=deadline&fav=1&tender=)
+ │ ─ FilterPanel schreibt Params ─ ResultsList liest Params
+ ▼
+[tender-radar-api.ts fetch(credentials:'include')]
+ │
+ ├── GET /modules/tender-radar? ──┐
+ │ (globaler Katalog, @UseModule-gated) │
+ ├── GET /modules/tender-radar/:id (Detail) │
+ ├── GET/POST/PATCH/DELETE /modules/tender-radar/ │
+ │ saved-searches (per-user) │
+ └── GET/PUT /modules/tender-radar/triage │
+ (per-user read/favourite) │
+ ▼ ▼
+[TendersController → PrismaService] [SavedSearch/Triage Service → where:{userId}]
+ │ buildTenderWhere(dto): konditionaler │ userId aus @CurrentUser/req.user
+ │ where-Builder (keyword/plz/region→NUTS/ │ @@unique([userId,name]) / ([userId,tenderId])
+ │ cpv-präfix/deadline/value-OR-null) │ onDelete:Cascade an Tender (Retention!)
+ ▼ ▼
+[Postgres "Tender" (global, 1671 Zeilen, kein tenantId)] [neue Tabellen (per-user)]
+ Indizes: status, deadlineAt, publishedAt (+ estimatedValue, region/bundesland ergänzen)
+```
+
+Datenfluss-Kern: Der Filter-State lebt in der URL, wird vom Client in Query-Params übersetzt, serverseitig zu einem Prisma-`where` gebaut. Triage/Saved-Searches laufen über **getrennte, user-gescopte** Endpunkte — die globale Tender-Query bleibt ungescoped.
+
+### Recommended Project Structure
+```
+apps/api/src/tenders/
+├── tenders.controller.ts # ERWEITERN: listTenders(where-Builder), getTender bleibt
+├── dto/tender-query.dto.ts # ERWEITERN: q, plz, bundesland, region, cpv[], deadlineFrom/To, openOnly, valueMin/Max, includeNullValue, sort, favOnly
+├── dto/saved-search.dto.ts # NEU: create/update Suchprofil
+├── tender-saved-search.service.ts # NEU: userId-gescopt (FavoritesService-Muster)
+├── tender-triage.service.ts # NEU: upsert read/favourite
+├── tender-query.builder.ts # NEU: buildTenderWhere(dto) + buildOrderBy(sort)
+├── cpv/cpv-catalog.ts # NEU: statischer Divisions/Gruppen-Katalog (DE-Labels) + normalizeCpv()
+└── geo/nuts-bundesland.ts # NEU: NUTS-1-Präfix → Bundesland-Name Map
+
+apps/web/src/app/(portal)/modules/tender-radar/
+├── page.tsx # ERSETZEN: Platzhalter → Master-Detail-Container
+├── components/
+│ ├── ResultsList.tsx # Tabelle/Cards + Sort-Header + Read/Fav-Toggles
+│ ├── FilterPanel.tsx # Keyword, PLZ/Bundesland, CPV-Autocomplete, Frist, Wert
+│ ├── TenderDetail.tsx # Detail-Panel/Drawer (via ?tender=)
+│ ├── SavedSearchBar.tsx # Speichern/Laden/Löschen von Profilen
+│ └── CoverageBanner.tsx # UI-05
+apps/web/src/lib/tender-radar-api.ts # ERWEITERN: listTenders, getTender, savedSearches, triage
+```
+
+### Pattern 1: Konditionaler Prisma `where`-Builder (keyword + insensitive contains)
+**What:** Filter-Bedingungen nur anhängen, wenn Param gesetzt — leere Params ⇒ kein Constraint.
+**When to use:** `listTenders` in `TendersController`.
+```typescript
+// buildTenderWhere(dto): Prisma.TenderWhereInput
+const where: Prisma.TenderWhereInput = {};
+const AND: Prisma.TenderWhereInput[] = [];
+
+// status/openOnly (D-04): "nur noch offene" = Standard-Default
+where.status = dto.status ?? 'active';
+if (dto.openOnly ?? true) {
+ AND.push({ OR: [{ deadlineAt: { gte: new Date() } }, { deadlineAt: null }] });
+}
+
+// FILTER-01: keyword über title + buyerName, case-insensitive
+if (dto.q) {
+ AND.push({
+ OR: [
+ { title: { contains: dto.q, mode: 'insensitive' } },
+ { buyerName: { contains: dto.q, mode: 'insensitive' } },
+ ],
+ });
+}
+
+// FILTER-02: plz exakt/Präfix; bundesland → NUTS-1-Präfix auf region
+if (dto.plz) AND.push({ plz: { startsWith: dto.plz } });
+if (dto.bundesland) AND.push({ region: { startsWith: nutsPrefixFor(dto.bundesland) } });
+// (nach Backfill alternativ: { bundesland: dto.bundesland })
+
+if (AND.length) where.AND = AND;
+return where;
+```
+> Bei ~1671 Zeilen ist `contains`/`ILIKE` ein Full-Scan im Sub-Millisekunden-Bereich — **kein** Trigram/GIN-Index nötig (siehe State of the Art bei Wachstum).
+
+### Pattern 2: NULL-Werte graceful (FILTER-05, D-05 — Pflichtkriterium)
+**What:** Aktiver min/max-Wertfilter darf `estimatedValue = null` (91,6 % der Daten!) nicht stillschweigend eliminieren.
+**When to use:** Immer wenn `valueMin`/`valueMax` gesetzt sind.
+```typescript
+// FILTER-05: 1530/1671 Zeilen haben estimatedValue = NULL.
+if (dto.valueMin != null || dto.valueMax != null) {
+ const range: Prisma.DecimalFilter = {};
+ if (dto.valueMin != null) range.gte = dto.valueMin;
+ if (dto.valueMax != null) range.lte = dto.valueMax;
+ // includeNullValue Default true: Einträge ohne Wertangabe bleiben sichtbar.
+ AND.push(
+ (dto.includeNullValue ?? true)
+ ? { OR: [{ estimatedValue: range }, { estimatedValue: null }] }
+ : { estimatedValue: range },
+ );
+}
+```
+**Kritisch:** Ohne das `OR … { estimatedValue: null }` würde ein Wertfilter 91,6 % der echten Daten verschwinden lassen — genau der „leere Liste wirkt wie Bug"-Effekt, den D-12 adressiert. Das Frontend markiert NULL-Wert-Zeilen mit „keine Wertangabe".
+
+### Pattern 3: CPV-Präfix-Match über inkonsistente Array-Werte (FILTER-03)
+**What:** `cpvCodes` enthalten gemischt "45", "45000000", "45000000-7". CPV ist hierarchisch per Präfix (erste 2 Ziffern = Division). Match beidseitig auf die führenden Ziffern normalisieren.
+```typescript
+// normalizeCpv('45000000-7') -> '45000000'; führende 2 Ziffern = Division '45'
+// Nutzer wählt Division/Gruppe im Katalog → wir matchen Präfix gegen JEDES Array-Element.
+// Prisma kann kein per-Element-LIKE auf String[]; zwei tragfähige Wege:
+
+// (A) MVP ohne Schema-Änderung — Raw-SQL-Fragment für die CPV-Bedingung:
+// EXISTS (SELECT 1 FROM unnest("cpvCodes") c WHERE c LIKE '45%')
+// via prisma.$queryRaw für die Vorfilter-ID-Liste, dann in where.id in(...)
+
+// (B) EMPFOHLEN — normalisierte Divisions-Spalte an Tender:
+// cpvDivisions String[] (z.B. ['45','71']) @ Ingestion + Backfill
+// Filter: { cpvDivisions: { hasSome: selectedDivisions } } // typsicher, GIN-indexierbar
+```
+**Katalog:** Statischer Divisions/Gruppen-Katalog (2-stellige Divisionen ≈ 45 Einträge + 3-stellige Gruppen) mit deutschen Labels als JSON im Repo — liefert Hierarchie + menschenlesbares Autocomplete („45 – Bauarbeiten"), ohne die ~9500 EU-CPV-Codes zu bündeln. Vollkatalog optional als Upgrade (Open Question 2).
+
+### Pattern 4: Sort-Whitelist (UI-01, D-06)
+```typescript
+// buildOrderBy(sort) — nur erlaubte Keys, verhindert beliebige Feldsortierung
+const SORT_MAP = {
+ deadline: { deadlineAt: 'asc' as const }, // nächste Frist zuerst
+ value: { estimatedValue: 'desc' as const },
+ published: { publishedAt: 'desc' as const }, // Default (wie bisher)
+} satisfies Record;
+const orderBy = SORT_MAP[dto.sort ?? 'published'];
+```
+> Postgres sortiert NULLs bei `asc` per Default zuletzt / bei `desc` zuerst. Bei Sortierung nach Frist/Wert (17 %/92 % NULL) ggf. `{ sort: { nulls: 'last' } }` erwägen — Prisma unterstützt `nulls`-Positionierung.
+
+### Pattern 5: Per-user Triage via Upsert + Batch-Merge (UI-03/04)
+**What:** Read/Favourite-Zustand pro (user, tender). Setzen = Upsert auf `@@unique([userId,tenderId])`; Liste = Triage der sichtbaren IDs in einem Batch nachladen und mergen.
+```typescript
+// Setzen (idempotent):
+prisma.tenderTriage.upsert({
+ where: { userId_tenderId: { userId, tenderId } },
+ update: { isRead: dto.isRead ?? undefined, isFavorite: dto.isFavorite ?? undefined },
+ create: { userId, tenantId, tenderId, isRead: !!dto.isRead, isFavorite: !!dto.isFavorite },
+});
+
+// Merklisten-Filter (UI-04): Query aus Triage treiben, dann Tender laden
+if (dto.favOnly) {
+ const favIds = (await prisma.tenderTriage.findMany({
+ where: { userId, isFavorite: true }, select: { tenderId: true },
+ })).map(t => t.tenderId);
+ AND.push({ id: { in: favIds.length ? favIds : ['__none__'] } });
+}
+```
+
+### Anti-Patterns to Avoid
+- **`forTenant()`/RLS für Triage/Saved-Search:** Kein Modultabellen-Modell nutzt RLS; das erzeugt inkonsistente Infrastruktur. Manuelles `where:{userId}` (FavoritesService) ist die Konvention.
+- **Client-seitiges Filtern der Gesamtliste:** Niemals alle Tender laden und im Browser filtern — serverseitig `where` bauen.
+- **Wertfilter ohne NULL-`OR`:** Würde 91,6 % der Daten verschwinden lassen (verletzt D-05).
+- **`@Get(':id')` vor statischen Routen:** 404-Shadowing (Phase-10-Bug) — neue statische Routen wie `saved-searches`/`triage` VOR `@Get(':id')` deklarieren.
+- **Detail als Next.js-Unterroute unter dem dynamic-loader-Mount:** Module werden als EINE `dynamic(ssr:false)`-Komponente unter `[category]/[moduleSlug]` gemountet — Sub-Routing ist nicht sauber erreichbar. Detail als In-Component-View (`?tender=`)/Drawer lösen.
+
+## Don't Hand-Roll
+
+| Problem | Don't Build | Use Instead | Why |
+|---------|-------------|-------------|-----|
+| Filter-`where`-Zusammenbau | String-konkatenierte SQL-`WHERE` | Prisma konditionaler `where`-Builder | Typsicher, injection-frei, testbar. |
+| Case-insensitive Keyword | `toLowerCase()`-Vergleich im JS über alle Zeilen | Prisma `contains … mode:'insensitive'` (ILIKE) | DB-seitig, paginierbar. |
+| Pagination/Bounds | eigene Skip/Take-Logik ohne Limit | Bestehendes `TenderQueryDto`-Muster (`@Max(100)`) | T-10-15 DoS-Schutz bereits etabliert. |
+| Per-user Scoping | eigene Ownership-Checks pro Route | `FavoritesService`-Muster (userId im `where`, Ownership vor Mutation) | T-08-06 bereits verifiziert. |
+| Bundesland-Zuordnung | PLZ→Bundesland-Heuristik (unsauber, PLZ-Zonen kreuzen Grenzen) | NUTS-1-Präfix aus `region` (DE1..DEG → 16 Länder) | Zuverlässig, deterministisch, Datenquelle vorhanden (933 Zeilen `region`). |
+| NUTS-Region-Verständnis | eigene Interpretation der Codes | Feste NUTS-1-Map (16 Einträge) | Standardisiertes EU-Schema, stabil. |
+
+**Key insight:** Fast alles ist ein Config-/Query-Problem, kein Algorithmus-Problem — der Wert steckt im korrekten Umgang mit den NULL-lastigen Realdaten, nicht in Custom-Logik.
+
+## Runtime State Inventory
+
+> Diese Phase ist **kein** Rename/Refactor, aber sie führt eine **Backfill-Datenmigration** ein (Bundesland/CPV-Divisionen). Relevante Zustände:
+
+| Category | Items Found | Action Required |
+|----------|-------------|------------------|
+| Stored data | `Tender.bundesland` = 100 % NULL (1671/1671); `Tender.region` NUTS-Codes in 933 Zeilen; `cpvCodes` inkonsistent formatiert | **Backfill-Migration**: bundesland aus region-NUTS ableiten; optional cpvDivisions ableiten. Normalizer zusätzlich für neue Ingests anpassen (Code-Edit). |
+| Stored data | `Tender.rawPayload` = 100 % NULL | Keine Dokument-URLs verfügbar → UI-02 nur `sourceUrl`/Portal-Link (Open Question 1). Kein Backfill möglich (Payload nie gespeichert). |
+| Live service config | Keiner — Phase ändert keine externen Service-Configs. | None. |
+| OS-registered state | Keiner. | None — verifiziert (reine App-Feature-Phase). |
+| Secrets/env vars | Keiner — Tender-Daten & Triage tragen keine Secrets (`tender-radar-api.ts` T-10-18). | None. |
+| Build artifacts | Prisma-Client muss nach Schema-Erweiterung neu generiert werden (`prisma generate`); neue Migration unter `apps/api/prisma/migrations/`. | `prisma migrate` + Client-Regenerierung; Docker-Rebuild der API. |
+
+**Nichts gefunden bei „Live service config", „OS-registered state", „Secrets": explizit verifiziert.**
+
+## Common Pitfalls
+
+### Pitfall 1: `bundesland` ist zu 100 % NULL
+**What goes wrong:** Ein Bundesland-Filter auf die `bundesland`-Spalte liefert IMMER 0 Treffer — sieht aus wie ein Bug, ist aber leere Datenspalte.
+**Why it happens:** Der Phase-10-Normalizer setzt `bundesland = null` und vertagt NUTS→Bundesland ausdrücklich auf Phase 11 (`tender-normalizer.service.ts:36-38`, verifiziert).
+**How to avoid:** Bundesland aus `region`-NUTS-1-Präfix ableiten (DE1=Baden-Württemberg … DEA=Nordrhein-Westfalen … DEG=Thüringen). Empfohlen: Normalizer anpassen (neue Ingests) + Backfill-Migration (Bestand) → dann auf indexierter `bundesland`-Spalte filtern. Zwischenlösung ohne Backfill: `region startsWith nutsPrefix`.
+**Warning signs:** Bundesland-Dropdown zeigt nur „(leer)"; Filter ergibt konstant 0.
+
+### Pitfall 2: CPV-Formate sind inkonsistent
+**What goes wrong:** Exakter Match `cpvCodes has '45000000'` verfehlt Zeilen mit "45" oder "45000000-7".
+**Why it happens:** DÖE liefert CPV mal 2-stellig, mal 8-stellig, mal mit Prüfziffer ("-7"). 638 distinct Werte, gemischt (verifiziert).
+**How to avoid:** Beidseitig auf führende Ziffern normalisieren und Präfix-matchen (Division/Gruppe). Nie exakter Gleichheits-Match für den Hierarchie-Filter.
+**Warning signs:** „Bauarbeiten"-Filter (45) übersieht offensichtliche Baulose.
+
+### Pitfall 3: Wertfilter eliminiert stillschweigend NULL-Werte
+**What goes wrong:** `estimatedValue: { gte, lte }` schließt via SQL-NULL-Semantik alle 1530 NULL-Zeilen aus → Liste kollabiert auf ~8 %.
+**Why it happens:** `NULL >= x` ist in SQL `unknown` → Zeile fällt raus. Kein Fehler, aber verletzt D-05.
+**How to avoid:** `OR:[{estimatedValue:range},{estimatedValue:null}]` mit „ohne Wertangabe einschließen"-Toggle (Default an); NULL-Zeilen im UI als „keine Wertangabe" markieren.
+**Warning signs:** Sobald irgendein Wertfilter aktiv ist, wird die Liste drastisch kürzer.
+
+### Pitfall 4: Falsches Scoping-Muster für neue Tabellen
+**What goes wrong:** `forTenant()` (RLS) für Triage/Saved-Search verwenden, obwohl kein Modultabellen-Modell RLS nutzt → Transaktions-Overhead + inkonsistente Architektur; per-user-Isolation fehlt (RLS scoped nur tenant, nicht user).
+**Why it happens:** CONTEXT erwähnt `forTenant`; RLS-Policies existieren real aber nur für `User`/`PasswordResetToken`/`LdapConfig`/`LdapFieldMapping` (verifiziert in `20260618112133_rls_policies`).
+**How to avoid:** `FavoritesService`-Muster: Modell trägt `userId` + `tenantId`; jede Query `where:{userId}`; Ownership vor Mutation prüfen. Für per-user Daten ist userId-Scoping ohnehin strenger als tenant-RLS.
+**Warning signs:** `$transaction`/`set_config` im Triage-Service; ein Nutzer sieht Triage eines anderen im selben Tenant.
+
+### Pitfall 5: Route-Order 404-Shadowing (Phase-10-Bug wiederholt)
+**What goes wrong:** Neue `GET /modules/tender-radar/saved-searches` nach `@Get(':id')` → „saved-searches" wird als `:id` interpretiert → 404/falscher Handler.
+**How to avoid:** Alle statischen Sub-Routen (`saved-searches`, `triage`, `coverage`) VOR `@Get(':id')` deklarieren — exakt wie `source-config` bereits korrekt platziert ist.
+**Warning signs:** GET auf statische Route liefert „Tender not found".
+
+### Pitfall 6: Retention löscht Tender → verwaiste Triage/Saved-Search-Referenzen
+**What goes wrong:** Phase-10-Retention (90 Tage) löscht Tender-Zeilen; Triage-Zeilen mit `tenderId` würden verwaisen.
+**How to avoid:** `TenderTriage.tender Tender @relation(..., onDelete: Cascade)` — Triage verschwindet mit dem Tender. Saved-Search speichert nur Filter-Kriterien (kein Tender-FK), daher unkritisch.
+**Warning signs:** Detailansicht eines gemerkten Tenders wirft „not found" nach Retention.
+
+## Code Examples
+
+### Neues Modell (Prisma) — nach FavoritesService-Muster
+```prisma
+// FILTER-06 — per-user Suchprofil (kein RLS; userId-Scoping im Service)
+model TenderSavedSearch {
+ id String @id @default(uuid())
+ userId String
+ tenantId String
+ name String
+ filters Json // serialisierte Filterkombination (deckungsgleich mit URL-Params)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@unique([userId, name]) // keine zwei Profile gleichen Namens pro Nutzer
+ @@index([userId])
+}
+
+// UI-03/04 — per-user Triage-Zustand pro Tender
+model TenderTriage {
+ id String @id @default(uuid())
+ userId String
+ tenantId String
+ tenderId String
+ isRead Boolean @default(false)
+ isFavorite Boolean @default(false)
+ readAt DateTime?
+ favoritedAt DateTime?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ tender Tender @relation(fields: [tenderId], references: [id], onDelete: Cascade)
+
+ @@unique([userId, tenderId]) // Upsert-Target; ein Triage-Row je (user,tender)
+ @@index([userId])
+ @@index([tenderId])
+}
+// Auf Tender ergänzen: triage TenderTriage[] (Back-Relation, sonst keine Änderung an Tender-Semantik)
+```
+
+### DTO-Erweiterung (Auszug) — validiert Filter-Params
+```typescript
+// tender-query.dto.ts ERWEITERN (bestehende page/limit/status behalten)
+@IsOptional() @IsString() @MaxLength(200) q?: string; // FILTER-01
+@IsOptional() @IsString() @MaxLength(5) plz?: string; // FILTER-02
+@IsOptional() @IsString() bundesland?: string; // FILTER-02 (NUTS-abgeleitet)
+@IsOptional() @IsArray() @IsString({ each: true }) cpv?: string[]; // FILTER-03 (Divisionen)
+@IsOptional() @Type(() => Date) @IsDate() deadlineFrom?: Date; // FILTER-04
+@IsOptional() @Type(() => Date) @IsDate() deadlineTo?: Date;
+@IsOptional() @Type(() => Boolean) @IsBoolean() openOnly?: boolean; // FILTER-04 default true
+@IsOptional() @Type(() => Number) @IsNumber() valueMin?: number; // FILTER-05
+@IsOptional() @Type(() => Number) @IsNumber() valueMax?: number;
+@IsOptional() @Type(() => Boolean) @IsBoolean() includeNullValue?: boolean; // D-05 default true
+@IsOptional() @IsIn(['deadline','value','published']) sort?: string; // UI-01
+@IsOptional() @Type(() => Boolean) @IsBoolean() favOnly?: boolean; // UI-04
+```
+
+### NUTS-1 → Bundesland Map
+```typescript
+// geo/nuts-bundesland.ts — DE + ein Zeichen = NUTS-1 (Bundesland)
+export const NUTS1_BUNDESLAND: Record = {
+ DE1: 'Baden-Württemberg', DE2: 'Bayern', DE3: 'Berlin', DE4: 'Brandenburg',
+ DE5: 'Bremen', DE6: 'Hamburg', DE7: 'Hessen', DE8: 'Mecklenburg-Vorpommern',
+ DE9: 'Niedersachsen', DEA: 'Nordrhein-Westfalen', DEB: 'Rheinland-Pfalz',
+ DEC: 'Saarland', DED: 'Sachsen', DEE: 'Sachsen-Anhalt',
+ DEF: 'Schleswig-Holstein', DEG: 'Thüringen',
+};
+export const bundeslandFromRegion = (r?: string | null) =>
+ r ? NUTS1_BUNDESLAND[r.slice(0, 3).toUpperCase()] ?? null : null;
+// Reverse (Filter): Bundesland-Name → NUTS-1-Präfix für region startsWith.
+```
+
+### Frontend: URL-Param-getriebener Client (Auszug)
+```typescript
+// tender-radar-api.ts ERWEITERN — Plain fetch, wie fetchSourceConfig()
+export async function listTenders(params: URLSearchParams) {
+ const res = await fetch(`${API_URL}/modules/tender-radar?${params}`, { credentials: 'include' });
+ if (!res.ok) throw new Error('Failed to fetch tenders');
+ return res.json() as Promise<{ items: Tender[]; total: number; page: number; limit: number }>;
+}
+// FilterPanel schreibt in useRouter().replace(`?${params}`); ResultsList liest useSearchParams().
+```
+
+## State of the Art
+
+| Old Approach | Current Approach | When Changed | Impact |
+|--------------|------------------|--------------|--------|
+| ILIKE-Full-Scan für Keyword | pg_trgm GIN-Index | erst ab ~50–100k Zeilen relevant | Bei 1671 Zeilen unnötig; als zukünftige Optimierung vormerken, nicht jetzt bauen. |
+| `bundesland` roh aus Quelle | NUTS-1-Ableitung | diese Phase (in P10 vertagt) | Bundesland-Filter überhaupt erst funktionsfähig. |
+| Client-State-Libs für Filter | URL searchParams (App Router) | Next.js App-Router-Ära | Teilbar, deep-linkbar, serialisierbar für Saved-Search. |
+
+**Deprecated/outdated:**
+- Annahme, `rawPayload` enthalte Notice-Dokumente: **falsch** — Spalte ist zu 100 % NULL.
+- Annahme, TanStack Query sei verfügbar (CLAUDE.md): **nicht installiert**; Plain fetch nutzen.
+
+## Assumptions Log
+
+| # | Claim | Section | Risk if Wrong |
+|---|-------|---------|---------------|
+| A1 | NUTS-1-Präfix (DE + 1 Zeichen) mappt eindeutig auf die 16 Bundesländer | Pitfall 1 / Code Examples | Falsche Bundesland-Zuordnung bei Filter — mittleres Risiko; NUTS-Schema ist stabil und öffentlich, sollte aber gegen 2–3 Stichproben validiert werden. |
+| A2 | Ein statischer CPV-Divisions/Gruppen-Katalog (2-/3-stellig, DE-Labels) genügt für „hierarchisch + Autocomplete" (FILTER-03) | Pattern 3 | Falls Nutzer bis auf 8-stellige Codes zoomen wollen, reicht der Kurzkatalog nicht → Vollkatalog nötig (Open Question 2). |
+| A3 | „nur noch offene" schließt NULL-Deadlines ein (nicht verstecken) | Pattern 1 / FILTER-04 | Falls Fachlogik NULL-Deadline als „nicht offen" wertet, ändert sich der Default-View um 281 Zeilen — Plan-Entscheidung. |
+| A4 | Hardcodierte deutsche UI-Strings sind für Phase 11 akzeptabel (i18n = Phase 14) | Open Question 3 | Mehr Nacharbeit in Phase 14, falls jetzt hardcodiert — geringes Risiko, konsistent mit Stub-Konvention. |
+| A5 | Detail via In-Component-View/`?tender=` statt Next.js-Unterroute | Anti-Patterns | Falls dedizierte Route gewünscht, muss die dual-routing-Fläche (statisch vs. dynamic-loader) geklärt werden. |
+
+## Open Questions
+
+1. **UI-02 Dokument-URLs, wo `rawPayload` = 100 % NULL**
+ - Was wir wissen: `sourceUrl` (OCDS-Notice-API, `oeffentlichevergabe.de/api/notices/{id}?format=ocds`) und `ocid` sind zu 100 % vorhanden; die eigentlichen Vergabeunterlagen-Links stehen nur im Notice, der nicht persistiert wurde.
+ - Was unklar ist: Ob UI-02 „Dokument-URLs" zwingend erfordert oder ob der Quell-Link genügt.
+ - Empfehlung: MVP zeigt `sourceUrl` + einen menschenlesbaren Portal-Link; Dokument-URLs optional per **Live-Fetch beim Öffnen des Details** (D-07-konform: nur Links, keine Spiegelung). Alternativ ab jetzt `rawPayload`/Dokument-URLs beim Ingest persistieren (Scope-Zuwachs, ggf. Phase 12+).
+
+2. **CPV: Kurzkatalog vs. Vollkatalog**
+ - Empfehlung: MVP mit Divisions/Gruppen-Katalog (DE-Labels) starten; Vollkatalog (offizielle EU-CPV-Liste als statisches JSON) nur wenn Nutzer feingranulares Autocomplete brauchen.
+
+3. **i18n jetzt oder Phase 14**
+ - `next-intl` ist verdrahtet; der tender-radar-Stub hat i18n bewusst vertagt. Entscheidung: hardcodiertes Deutsch (Stub-Konsistenz, weniger jetzt) vs. `tenderRadar`-Message-Keys jetzt (weniger Nacharbeit Phase 14).
+
+4. **TanStack Query einführen?**
+ - Nicht installiert. Empfehlung: NICHT einführen — Plain-fetch-Muster reicht für Listen/Detail. Nur erwägen, wenn Background-Refresh/optimistische Triage-Updates explizit gewünscht sind.
+
+## Environment Availability
+
+| Dependency | Required By | Available | Version | Fallback |
+|------------|------------|-----------|---------|----------|
+| PostgreSQL (Container `tessera-ctl-db-1`, DB `tessera`) | Alle Queries + Backfill-Migration | ✓ | 16 (libc/en_US.utf8) | — |
+| Prisma CLI (migrate/generate) | Schema-Erweiterung | ✓ (Projekt-Dependency) | 7.x | — |
+| Node/pnpm Monorepo | Build/Test | ✓ | — | — |
+| Vitest | Web- + API-Tests | ✓ | 3.x | — |
+
+**Hinweis Migrationen (aus MEMORY):** DB hat keinen Host-Port; lokale Prisma-Migration vom Host via Container-IP + `tessera:tessera_dev`, oder direkt `docker exec tessera-ctl-db-1 psql -U tessera -d tessera`. Kein Docker-Deploy auf dem Testserver durch Claude.
+
+**Missing dependencies with no fallback:** keine.
+**Missing dependencies with fallback:** keine.
+
+## Validation Architecture
+
+### Test Framework
+| Property | Value |
+|----------|-------|
+| Framework | Vitest 3.x (Web + API) |
+| Config file | vorhanden (Web `test: "vitest run"`; API `.spec.ts` neben Services) |
+| Quick run command | `pnpm --filter @tessera/api vitest run src/tenders` (Backend-Slice) |
+| Full suite command | `pnpm -r test` |
+
+### Phase Requirements → Test Map
+| Req ID | Behavior | Test Type | Automated Command | File Exists? |
+|--------|----------|-----------|-------------------|-------------|
+| FILTER-01 | keyword contains title+buyerName insensitive | unit | `vitest run tender-query.builder.spec` | ❌ Wave 0 |
+| FILTER-05 | NULL-Wert bleibt bei aktivem Wertfilter sichtbar | unit | `vitest run tender-query.builder.spec` | ❌ Wave 0 |
+| FILTER-02 | bundesland → NUTS-Präfix; region startsWith | unit | `vitest run nuts-bundesland.spec` | ❌ Wave 0 |
+| FILTER-03 | CPV-Präfix matcht "45"/"45000000"/"45000000-7" | unit | `vitest run cpv-catalog.spec` | ❌ Wave 0 |
+| FILTER-04 | openOnly schließt NULL-Deadline ein | unit | `vitest run tender-query.builder.spec` | ❌ Wave 0 |
+| FILTER-06 | Saved-Search userId-Scoping + `@@unique([userId,name])` | integration | `vitest run tender-saved-search.service.spec` | ❌ Wave 0 |
+| UI-03/04 | Triage upsert, Merklisten-Filter, Cascade-Delete | integration | `vitest run tender-triage.service.spec` | ❌ Wave 0 |
+| UI-01 | Sort-Whitelist + Pagination-Bounds | unit | `vitest run tenders.controller.spec` (erweitern) | ⚠️ erweitern |
+| UI-02/05 | Detail-Render + Coverage-Banner | component | `vitest run ResultsList.test / CoverageBanner.test` | ❌ Wave 0 |
+
+### Sampling Rate
+- **Per task commit:** betroffener Slice-Spec (`vitest run `)
+- **Per wave merge:** `pnpm --filter @tessera/api test && pnpm --filter test`
+- **Phase gate:** `pnpm -r test` grün vor `/gsd-verify-work`
+
+### Wave 0 Gaps
+- [ ] `tender-query.builder.spec.ts` — FILTER-01/04/05 where-Bau (NULL-Graceful ist der Kern-Test)
+- [ ] `nuts-bundesland.spec.ts` — NUTS-1-Ableitung inkl. NULL-region
+- [ ] `cpv-catalog.spec.ts` — Präfix-Normalisierung über inkonsistente Formate
+- [ ] `tender-saved-search.service.spec.ts` + `tender-triage.service.spec.ts` — userId-Scoping, Ownership, Cascade
+- [ ] Component-Tests `ResultsList` / `CoverageBanner` (Testing-Library-Muster wie `SourceConfigForm.test.tsx`)
+
+## Security Domain
+
+### Applicable ASVS Categories
+| ASVS Category | Applies | Standard Control |
+|---------------|---------|-----------------|
+| V2 Authentication | yes (indirekt) | Globaler JwtAuthGuard + TenantGuard bereits aktiv; Triage/Saved-Search-Routen erben Auth. |
+| V3 Session Management | no | Cookie-Session unverändert. |
+| V4 Access Control | **yes** | Per-user Scoping: jede Triage/Saved-Search-Query `where:{userId}`; Ownership vor Mutation prüfen (FavoritesService T-08-06). Tender-Read weiterhin nur `@UseModule`-gated (global, D-03). |
+| V5 Input Validation | **yes** | Erweitertes `TenderQueryDto` mit class-validator (`@Max`, `@IsIn`, `@MaxLength`); Sort-Whitelist statt beliebigem Feld; Pagination-Bounds (T-10-15). |
+| V6 Cryptography | no | Tender/Triage/Saved-Search tragen keine Secrets (T-10-18). |
+
+### Known Threat Patterns for NestJS/Prisma + Next.js
+| Pattern | STRIDE | Standard Mitigation |
+|---------|--------|---------------------|
+| IDOR auf fremde Triage/Saved-Search | Elevation/Info-Disclosure | Query-Scoping `where:{userId}`; keine ID-only-Lookups ohne userId. |
+| SQL/Query-Injection via Filter-Param | Tampering | Prisma parametrisiert; falls CPV-Raw-SQL genutzt wird, ausschließlich parametrisiert (`$queryRaw`-Template, kein `$queryRawUnsafe` mit Interpolation). |
+| Unbounded Query / DoS | Denial of Service | `limit @Max(100)` beibehalten; favOnly-`in`-Liste begrenzen. |
+| Sort/Filter-Feld-Injection | Tampering | Sort-Whitelist (SORT_MAP), keine dynamischen `orderBy`-Keys aus User-Input. |
+| Cross-tenant Leak bei neuen Tabellen | Info-Disclosure | tenantId + userId setzen; per-user Scoping ist strenger als tenant-RLS — dennoch tenantId mitführen für spätere Tenant-Isolation. |
+
+## Sources
+
+### Primary (HIGH confidence)
+- Live-DB `tessera-ctl-db-1` / DB `tessera` — Tender-Statistiken (1671 Zeilen, NULL-Verteilungen, CPV/region-Samples) [VERIFIED: docker exec psql]
+- `apps/api/prisma/schema.prisma` — Tender + bestehende Modelle, Indizes [VERIFIED: codebase]
+- `apps/api/src/tenders/tenders.controller.ts` / `tenders.module.ts` / `dto/tender-query.dto.ts` — Read-Endpunkte + Route-Order-Konvention [VERIFIED: codebase]
+- `apps/api/src/tenders/tender-normalizer.service.ts` — NUTS→Bundesland auf Phase 11 vertagt (`bundesland=null`) [VERIFIED: codebase]
+- `apps/api/src/favorites/favorites.service.ts` + `favorites.controller.ts` — per-user Scoping-Muster (T-08-06) [VERIFIED: codebase]
+- `apps/api/src/prisma/prisma-tenant.extension.ts` + `migrations/20260618112133_rls_policies/migration.sql` — RLS nur für Auth-Tabellen [VERIFIED: codebase]
+- `apps/web/src/lib/tender-radar-api.ts`, `.../tender-radar/page.tsx`, `settings/components/SourceConfigForm.tsx`, `apps/web/src/lib/module-loader.ts`, `dkv-fleet/page.tsx` — Frontend-Muster (Plain fetch, dynamic-loader) [VERIFIED: codebase]
+- `apps/web/package.json` — TanStack NICHT installiert; next-intl + zustand vorhanden [VERIFIED: codebase]
+
+### Secondary (MEDIUM confidence)
+- CONTEXT.md D-01..D-12, ROADMAP § Phase 11, REQUIREMENTS FILTER/UI — Vorgaben [CITED: .planning/…]
+
+### Tertiary (LOW confidence)
+- NUTS-1 → Bundesland Zuordnung [ASSUMED: Trainingswissen, öffentliches EU-Schema — gegen Stichproben validieren]
+- EU-CPV-Divisionsbezeichnungen (DE-Labels) [ASSUMED: gegen offizielle CPV-Liste zu verifizieren beim Katalog-Bau]
+
+## Metadata
+
+**Confidence breakdown:**
+- Standard stack: HIGH — alles im Repo verifiziert, keine Neuinstallation.
+- Architecture (Query-Builder, neue Modelle, Scoping): HIGH — an FavoritesService/RLS-Migration/Controller belegt.
+- Datenbefunde (NULL-Verteilungen, NUTS, CPV-Formate): HIGH — Live-DB abgefragt.
+- CPV-Katalog-Labels & NUTS-Namen: MEDIUM/LOW — als ASSUMED markiert, beim Bau zu validieren.
+
+**Research date:** 2026-07-21
+**Valid until:** 2026-08-20 (stabile interne Codebase; Datenverteilungen können sich durch weitere Ingests leicht verschieben — die NULL-Dominanz bei estimatedValue/bundesland bleibt strukturell).