diff --git a/.planning/phases/11-filter-engine-results-ui-saved-searches/11-01-PLAN.md b/.planning/phases/11-filter-engine-results-ui-saved-searches/11-01-PLAN.md
index 67e39aa..84a4ede 100644
--- a/.planning/phases/11-filter-engine-results-ui-saved-searches/11-01-PLAN.md
+++ b/.planning/phases/11-filter-engine-results-ui-saved-searches/11-01-PLAN.md
@@ -24,6 +24,7 @@ must_haves:
- "Öffnet ein Nutzer das Ausschreibungs-Radar-Modul, sieht er statt des Platzhalters eine Liste der vorhandenen DÖE-Ausschreibungen."
- "Freitext-Eingabe filtert die Liste über Titel und Auftraggeber (case-insensitive)."
- "Die Liste ist nach Frist, Wert und Veröffentlichungsdatum sortierbar; Standard-Ansicht zeigt nur noch offene Ausschreibungen."
+ - "Nutzer kann die Abgabefrist auf einen Datumsbereich (von/bis) einschränken."
- "Ausschreibungen ohne Wertangabe verschwinden nie und werden als „keine Wertangabe“ markiert."
- "Ein Coverage-Banner erklärt die Datenabdeckung (Oberschwelle DÖE), damit eine dünne Liste nicht als Fehler gelesen wird."
artifacts:
@@ -71,11 +72,12 @@ Output: `tender-query.builder.ts` (where + orderBy), erweitertes `TenderQueryDto
apps/api/src/tenders/dto/tender-query.dto.ts, apps/api/src/tenders/tender-query.builder.ts, apps/api/src/tenders/tender-query.builder.spec.ts
- buildTenderWhere: bei leerem DTO ⇒ where.status='active', openOnly-Default true ⇒ AND enthält OR(deadlineAt gte now, deadlineAt null) — NULL-Deadlines bleiben sichtbar (D-04, FILTER-04).
+ - deadlineFrom/deadlineTo gesetzt ⇒ AND enthält { deadlineAt: { gte: from, lte: to } } (expliziter Frist-Datumsbereich, D-04, FILTER-04) — kombinierbar mit dem openOnly-Toggle.
- q gesetzt ⇒ AND enthält OR(title contains q insensitive, buyerName contains q insensitive) (D-01, FILTER-01).
- valueMin/valueMax gesetzt bei includeNullValue-Default true ⇒ AND enthält OR(estimatedValue range, estimatedValue null); ohne includeNullValue ⇒ reiner range (D-05, FILTER-05). Kern-Test: aktiver Wertfilter darf estimatedValue-null-Zeilen NICHT eliminieren.
- buildOrderBy: nur Keys deadline/value/published erlaubt (Whitelist), unbekannter/fehlender Key ⇒ Default publishedAt desc (UI-01, D-06).
- Erweitere `TenderQueryDto` um die validierten Filter-/Sort-Params (behalte page/limit/status/T-10-15-Bounds): q (@IsString @MaxLength(200)), openOnly (@IsBoolean, Default-Semantik true im Builder), valueMin/valueMax (@IsNumber, @Type Number), includeNullValue (@IsBoolean, Default true im Builder), sort (@IsIn ['deadline','value','published']). plz/bundesland/region/cpv/favOnly werden in späteren Plänen ergänzt — hier NICHT hinzufügen. Erstelle `tender-query.builder.ts` mit reinen Funktionen buildTenderWhere(dto): Prisma.TenderWhereInput (konditionaler AND-Aufbau exakt nach RESEARCH Pattern 1, 2, 4, 5 — für D-05 zwingend das OR mit estimatedValue null) und buildOrderBy(sort): Prisma.TenderOrderByWithRelationInput (SORT_MAP-Whitelist nach Pattern 4). Schreibe zuerst die Spec (RED), dann die Implementierung (GREEN). Erwäge Prisma nulls:'last' bei deadline/value-Sortierung. Kein Raw-SQL. Keine der negativ-getesteten Filterbedingungen als Literal in Kommentaren wiederholen.
+ Erweitere `TenderQueryDto` um die validierten Filter-/Sort-Params (behalte page/limit/status/T-10-15-Bounds): q (@IsString @MaxLength(200)), openOnly (@IsBoolean, Default-Semantik true im Builder), deadlineFrom/deadlineTo (@IsOptional @Type(() => Date) @IsDate — ISO-Datumsbereich der Abgabefrist, FILTER-04), valueMin/valueMax (@IsNumber, @Type Number), includeNullValue (@IsBoolean, Default true im Builder), sort (@IsIn ['deadline','value','published']). plz/bundesland/region/cpv/favOnly werden in späteren Plänen ergänzt — hier NICHT hinzufügen. Erstelle `tender-query.builder.ts` mit reinen Funktionen buildTenderWhere(dto): Prisma.TenderWhereInput (konditionaler AND-Aufbau exakt nach RESEARCH Pattern 1, 2, 4, 5 — für D-05 zwingend das OR mit estimatedValue null; für FILTER-04 sowohl der openOnly-Default-Branch ALS AUCH der explizite deadlineFrom/deadlineTo-Range-Branch { deadlineAt: { gte, lte } }) und buildOrderBy(sort): Prisma.TenderOrderByWithRelationInput (SORT_MAP-Whitelist nach Pattern 4). Schreibe zuerst die Spec (RED) inkl. deadlineFrom/deadlineTo-Range, dann die Implementierung (GREEN). Erwäge Prisma nulls:'last' bei deadline/value-Sortierung. Kein Raw-SQL. Keine der negativ-getesteten Filterbedingungen als Literal in Kommentaren wiederholen.
pnpm --filter @tessera/api exec vitest run src/tenders/tender-query.builder.spec.ts
@@ -100,7 +102,7 @@ Output: `tender-query.builder.ts` (where + orderBy), erweitertes `TenderQueryDto
- Eine Zeile mit estimatedValue null zeigt „keine Wertangabe“ statt eines leeren/0-Werts (D-05).
- CoverageBanner rendert den Oberschwelle-DÖE-Hinweis, wenn coverage nur `doe-opendata` meldet.
- Erweitere `tender-radar-api.ts` (Plain fetch, credentials:'include', Muster wie fetchSourceConfig — KEIN TanStack Query, D-Discretion/Research OQ4) um listTenders(params: URLSearchParams): Promise<{items,total,page,limit}>, den Tender-Typ (Felder aus dem Prisma-Modell) und fetchCoverage(). Ersetze `page.tsx` vollständig durch einen 'use client' Master-Container: liest Filter-State aus useSearchParams(), schreibt via useRouter().replace(`?${params}`); rendert CoverageBanner + FilterPanel + ResultsList; useSearchParams in eine Suspense-Grenze wrappen (Next 16 App Router). FilterPanel: Freitextfeld (schreibt q) + Sort-Auswahl (deadline/value/published) + „nur noch offene“-Toggle (openOnly, Default an, D-04). ResultsList: sortierbare Spalten/Cards; estimatedValue-null-Zeilen mit „keine Wertangabe“ markieren (D-05); Paginierung über page/limit. CoverageBanner (UI-05, D-12): kurzer deutscher Hinweistext, dass aktuell nur die DÖE-Quelle (EU-weite Oberschwelle) abgedeckt ist und Unterschwelle-Vergaben noch fehlen — damit eine dünne/leere Liste nicht als Defekt gelesen wird. Strings hardcodiert Deutsch (Stub-Konvention; i18n = Phase 14, KEINE next-intl-Keys). Vitest+Testing-Library-Test (Muster SourceConfigForm.test.tsx) für ResultsList: rendert items, zeigt „keine Wertangabe“ bei null. Keine der negativ-geprüften Strings verbatim in Kommentaren.
+ Erweitere `tender-radar-api.ts` (Plain fetch, credentials:'include', Muster wie fetchSourceConfig — KEIN TanStack Query, D-Discretion/Research OQ4) um listTenders(params: URLSearchParams): Promise<{items,total,page,limit}>, den Tender-Typ (Felder aus dem Prisma-Modell) und fetchCoverage(). Ersetze `page.tsx` vollständig durch einen 'use client' Master-Container: liest Filter-State aus useSearchParams(), schreibt via useRouter().replace(`?${params}`); rendert CoverageBanner + FilterPanel + ResultsList; useSearchParams in eine Suspense-Grenze wrappen (Next 16 App Router). FilterPanel: Freitextfeld (schreibt q) + Sort-Auswahl (deadline/value/published) + „nur noch offene“-Toggle (openOnly, Default an, D-04) + zwei Frist-Datumsfelder „Abgabefrist von/bis“ (schreiben deadlineFrom/deadlineTo als ISO in die URL-Params, FILTER-04) — die URL-Param-Namen deadlineFrom/deadlineTo MÜSSEN mit dem Saved-Search-Serialisierungsvertrag aus 11-06 deckungsgleich sein. ResultsList: sortierbare Spalten/Cards; estimatedValue-null-Zeilen mit „keine Wertangabe“ markieren (D-05); Paginierung über page/limit. CoverageBanner (UI-05, D-12): kurzer deutscher Hinweistext, dass aktuell nur die DÖE-Quelle (EU-weite Oberschwelle) abgedeckt ist und Unterschwelle-Vergaben noch fehlen — damit eine dünne/leere Liste nicht als Defekt gelesen wird. Strings hardcodiert Deutsch (Stub-Konvention; i18n = Phase 14, KEINE next-intl-Keys). Vitest+Testing-Library-Test (Muster SourceConfigForm.test.tsx) für ResultsList: rendert items, zeigt „keine Wertangabe“ bei null. Keine der negativ-geprüften Strings verbatim in Kommentaren.
pnpm --filter @tessera/web exec vitest run src/app/\(portal\)/modules/tender-radar/components/ResultsList.test.tsx
diff --git a/.planning/phases/11-filter-engine-results-ui-saved-searches/11-04-PLAN.md b/.planning/phases/11-filter-engine-results-ui-saved-searches/11-04-PLAN.md
index 649026c..9e5694a 100644
--- a/.planning/phases/11-filter-engine-results-ui-saved-searches/11-04-PLAN.md
+++ b/.planning/phases/11-filter-engine-results-ui-saved-searches/11-04-PLAN.md
@@ -3,7 +3,7 @@ phase: 11-filter-engine-results-ui-saved-searches
plan: 04
type: execute
wave: 4
-depends_on: ["11-01"]
+depends_on: ["11-03"]
files_modified:
- apps/web/src/lib/tender-radar-api.ts
- apps/web/src/app/(portal)/modules/tender-radar/page.tsx
diff --git a/.planning/phases/11-filter-engine-results-ui-saved-searches/11-05-PLAN.md b/.planning/phases/11-filter-engine-results-ui-saved-searches/11-05-PLAN.md
index 5a0a15d..4a6c52c 100644
--- a/.planning/phases/11-filter-engine-results-ui-saved-searches/11-05-PLAN.md
+++ b/.planning/phases/11-filter-engine-results-ui-saved-searches/11-05-PLAN.md
@@ -3,7 +3,7 @@ phase: 11-filter-engine-results-ui-saved-searches
plan: 05
type: execute
wave: 5
-depends_on: ["11-01"]
+depends_on: ["11-04"]
files_modified:
- apps/api/prisma/schema.prisma
- apps/api/prisma/migrations/20260721160000_add_tender_triage/migration.sql
@@ -79,7 +79,7 @@ Output: TenderTriage-Modell + Migration, Triage-Service (upsert/batch), Controll
Task 2: Controller-Routen (GET/PUT /triage) + favOnly im Builder + Modul-Provider
- apps/api/src/tenders/dto/tender-triage.dto.ts, apps/api/src/tenders/tenders.controller.ts, apps/api/src/tenders/tenders.module.ts, apps/api/src/tenders/tender-query.dto.ts, apps/api/src/tenders/tender-query.builder.ts, apps/api/src/tenders/tender-query.builder.spec.ts
+ apps/api/src/tenders/dto/tender-triage.dto.ts, apps/api/src/tenders/tenders.controller.ts, apps/api/src/tenders/tenders.module.ts, apps/api/src/tenders/dto/tender-query.dto.ts, apps/api/src/tenders/tender-query.builder.ts, apps/api/src/tenders/tender-query.builder.spec.ts
Erstelle `dto/tender-triage.dto.ts` (tenderId @IsString/@IsUUID, isRead? @IsBoolean, isFavorite? @IsBoolean). Registriere TenderTriageService als Provider in `tenders.module.ts`. Ergänze in `tenders.controller.ts` die statischen Routen `GET /modules/tender-radar/triage?ids=` (Batch-Triage der sichtbaren IDs für den aktuellen Nutzer) und `PUT /modules/tender-radar/triage` (Body = TriageDto, upsert) — userId/tenantId aus dem Request-Kontext extrahieren wie FavoritesController.extractContext. KRITISCH (Pitfall 5): beide Routen VOR `@Get(':id')` deklarieren. Erweitere `TenderQueryDto` um favOnly (@IsBoolean) und buildTenderWhere so, dass der favOnly-Branch eine übergebene favIds-Liste als { id: { in: favIds.length ? favIds : ['__none__'] } } anhängt (RESEARCH Pattern 5, UI-04) — favIds ermittelt der Controller via triageService.favoriteIds(userId) vor dem buildTenderWhere-Aufruf und reicht sie in den Builder. Ergänze die Builder-Spec um den favOnly-Fall (inkl. leere favIds ⇒ keine Treffer statt aller). Begrenze die in-Liste (DoS).
pnpm --filter @tessera/api exec vitest run src/tenders/tender-query.builder.spec.ts src/tenders/tenders.controller.spec.ts
diff --git a/.planning/phases/11-filter-engine-results-ui-saved-searches/11-06-PLAN.md b/.planning/phases/11-filter-engine-results-ui-saved-searches/11-06-PLAN.md
index b87ba0c..6e3be15 100644
--- a/.planning/phases/11-filter-engine-results-ui-saved-searches/11-06-PLAN.md
+++ b/.planning/phases/11-filter-engine-results-ui-saved-searches/11-06-PLAN.md
@@ -3,7 +3,7 @@ phase: 11-filter-engine-results-ui-saved-searches
plan: 06
type: execute
wave: 6
-depends_on: ["11-01"]
+depends_on: ["11-05"]
files_modified:
- apps/api/prisma/schema.prisma
- apps/api/prisma/migrations/20260721170000_add_tender_saved_search/migration.sql
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
index 2804653..e296bd4 100644
--- 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
@@ -451,21 +451,21 @@ export async function listTenders(params: URLSearchParams) {
| 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
+## Open Questions (RESOLVED)
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+).
+ - **RESOLVED:** Detailansicht zeigt nur `sourceUrl` (Link zur Quelle), KEIN Live-Dokument-Fetch, KEINE lokale Spiegelung. Umgesetzt in Plan 11-04 (UI-02, D-07).
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.
+ - **RESOLVED:** Leichtgewichtiger statischer CPV-Divisions-Katalog (2-stellig, DE-Labels) mit beidseitiger Präfix-Normalisierung; KEIN EU-Vollkatalog. Umgesetzt in Plan 11-03 (FILTER-03).
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).
+ - **RESOLVED:** Hardcodiertes Deutsch nach Stub-Konvention; KEINE next-intl-Keys in dieser Phase — i18n-Rollout ist Phase 14. Gilt für alle Frontend-Tasks in 11-01..06.
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.
+ - **RESOLVED:** NICHT einführen (nicht installiert). Plain `fetch` über das bestehende `tender-radar-api.ts`-Muster. Gilt für alle Client-Aufrufe in 11-01..06.
## Environment Availability
diff --git a/.planning/phases/11-filter-engine-results-ui-saved-searches/11-VALIDATION.md b/.planning/phases/11-filter-engine-results-ui-saved-searches/11-VALIDATION.md
new file mode 100644
index 0000000..3233e60
--- /dev/null
+++ b/.planning/phases/11-filter-engine-results-ui-saved-searches/11-VALIDATION.md
@@ -0,0 +1,101 @@
+---
+phase: 11
+slug: filter-engine-results-ui-saved-searches
+status: draft
+nyquist_compliant: true
+wave_0_complete: false
+created: 2026-07-21
+---
+
+# Phase 11 — Validation Strategy
+
+> Per-phase validation contract for feedback sampling during execution.
+> Extracted from 11-RESEARCH.md § Validation Architecture (Test framework, Requirements→Test map, Sampling rate, Wave-0 gaps).
+
+---
+
+## Test Infrastructure
+
+| Property | Value |
+|----------|-------|
+| **Framework** | Vitest (API 3.x, Web 4.x — both existing, no install) |
+| **Config file** | `apps/api/vitest.config.ts` + `apps/web` `test: "vitest run"` (existing — not modified by this phase) |
+| **Quick run command (API slice)** | `pnpm --filter @tessera/api exec vitest run src/tenders` |
+| **Quick run command (Web slice)** | `pnpm --filter @tessera/web exec vitest run src/app/\(portal\)/modules/tender-radar` |
+| **Full suite command** | `pnpm -r test` |
+| **Estimated runtime** | ~10-15 seconds (scoped) |
+
+---
+
+## Sampling Rate
+
+- **After every task commit:** Run the affected slice spec (`vitest run `).
+- **After every plan wave:** Run `pnpm --filter @tessera/api test && pnpm --filter @tessera/web test`.
+- **Before `/gsd-verify-work`:** `pnpm -r test` must be green.
+- **Max feedback latency:** ~15 seconds (scoped run).
+
+---
+
+## Per-Task Verification Map
+
+| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
+|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
+| 11-01-01 | 01 | 1 | FILTER-01/04/05, UI-01 | T-11-01/02/03 | where-Builder: NULL-Wert graceful, Frist-Range + openOnly, Sort-Whitelist | unit | `pnpm --filter @tessera/api exec vitest run src/tenders/tender-query.builder.spec.ts` | ❌ W0 | ⬜ pending |
+| 11-01-02 | 01 | 1 | UI-01/05 | T-11-01/02 | listTenders nutzt Builder; GET /coverage VOR :id (Route-Order) | unit | `pnpm --filter @tessera/api exec vitest run src/tenders/tenders.controller.spec.ts` | ⚠️ extend | ⬜ pending |
+| 11-01-03 | 01 | 1 | FILTER-01, UI-01/05 | — | ResultsList render + „keine Wertangabe" + Coverage-Banner | component | `pnpm --filter @tessera/web exec vitest run src/app/\(portal\)/modules/tender-radar/components/ResultsList.test.tsx` | ❌ W0 | ⬜ pending |
+| 11-02-01 | 02 | 2 | FILTER-02 | T-11-04 | NUTS-1→Bundesland-Map, null-region-safe, Reverse-Präfix | unit | `pnpm --filter @tessera/api exec vitest run src/tenders/geo/nuts-bundesland.spec.ts` | ❌ W0 | ⬜ pending |
+| 11-02-02 | 02 | 2 | FILTER-02 | T-11-05 | Normalizer setzt bundesland; Backfill-Migration füllt Bestand | integration | `pnpm --filter @tessera/api exec vitest run src/tenders/tender-normalizer.service.spec.ts`; `prisma migrate status` | ⚠️ extend | ⬜ pending |
+| 11-02-03 | 02 | 2 | FILTER-02 | T-11-04 | plz/region/bundesland-Branch + FilterPanel-Dropdown | unit | `pnpm --filter @tessera/api exec vitest run src/tenders/tender-query.builder.spec.ts` | ⚠️ extend | ⬜ pending |
+| 11-03-01 | 03 | 3 | FILTER-03 | T-11-06 | normalizeCpv über "45"/"45000000"/"45000000-7"; Divisions-Labels | unit | `pnpm --filter @tessera/api exec vitest run src/tenders/cpv/cpv-catalog.spec.ts` | ❌ W0 | ⬜ pending |
+| 11-03-02 | 03 | 3 | FILTER-03 | T-11-06 | cpvDivisions-Spalte + Normalizer + Backfill | integration | `pnpm --filter @tessera/api exec vitest run src/tenders/tender-normalizer.service.spec.ts`; `prisma migrate status` | ⚠️ extend | ⬜ pending |
+| 11-03-03 | 03 | 3 | FILTER-03/05 | T-11-06/07 | cpv hasSome-Branch + Wert-UI-Toggle (NULL-graceful) | unit | `pnpm --filter @tessera/api exec vitest run src/tenders/tender-query.builder.spec.ts` | ⚠️ extend | ⬜ pending |
+| 11-04-01 | 04 | 4 | UI-02 | T-11-08/09 | TenderDetail via ?tender=; sourceUrl-Link, kein Mirroring | component | `pnpm --filter @tessera/web exec vitest run src/app/\(portal\)/modules/tender-radar/components/TenderDetail.test.tsx` | ❌ W0 | ⬜ pending |
+| 11-04-02 | 04 | 4 | UI-02 | T-11-09 | Detail: sourceUrl, NULL-graceful, Kein-Mirroring-Hinweis | component | `pnpm --filter @tessera/web exec vitest run src/app/\(portal\)/modules/tender-radar/components/TenderDetail.test.tsx` | ❌ W0 | ⬜ pending |
+| 11-05-01 | 05 | 5 | UI-03/04 | T-11-10/12 | Triage upsert idempotent, userId-Scoping, Cascade-Delete | integration | `pnpm --filter @tessera/api exec vitest run src/tenders/tender-triage.service.spec.ts`; `prisma migrate status` | ❌ W0 | ⬜ pending |
+| 11-05-02 | 05 | 5 | UI-04 | T-11-11/13 | /triage-Routen VOR :id; favOnly-Branch (leer ⇒ leere Liste) | unit | `pnpm --filter @tessera/api exec vitest run src/tenders/tender-query.builder.spec.ts src/tenders/tenders.controller.spec.ts` | ⚠️ extend | ⬜ pending |
+| 11-05-03 | 05 | 5 | UI-03/04 | T-11-10 | Read/Fav-Toggles + Merklisten-Filter + Batch-Merge | component | `pnpm --filter @tessera/web exec vitest run src/app/\(portal\)/modules/tender-radar/components/ResultsList.test.tsx` | ⚠️ extend | ⬜ pending |
+| 11-06-01 | 06 | 6 | FILTER-06 | T-11-14/15 | SavedSearch CRUD: @@unique[userId,name], userId-Scoping, Ownership | integration | `pnpm --filter @tessera/api exec vitest run src/tenders/tender-saved-search.service.spec.ts`; `prisma migrate status` | ❌ W0 | ⬜ pending |
+| 11-06-02 | 06 | 6 | FILTER-06 | T-11-16/17 | /saved-searches-Routen VOR :id (:searchId-Param) | unit | `pnpm --filter @tessera/api exec vitest run src/tenders/tenders.controller.spec.ts` | ⚠️ extend | ⬜ pending |
+| 11-06-03 | 06 | 6 | FILTER-06 | T-11-14 | SavedSearchBar speichern/laden/umbenennen/löschen; URL-Roundtrip | component | `pnpm --filter @tessera/web exec vitest run src/app/\(portal\)/modules/tender-radar` | ❌ W0 | ⬜ pending |
+
+*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
+
+---
+
+## Wave 0 Requirements
+
+- [ ] `apps/api/src/tenders/tender-query.builder.spec.ts` — FILTER-01/04/05 (NULL-Graceful ist der Kern-Test) + Frist-Range + Sort-Whitelist (Plan 11-01 Task 1, RED first)
+- [ ] `apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.test.tsx` — Render + „keine Wertangabe" + Coverage-Banner (Plan 11-01 Task 3)
+- [ ] `apps/api/src/tenders/geo/nuts-bundesland.spec.ts` — NUTS-1-Ableitung inkl. NULL-region (Plan 11-02 Task 1, RED first)
+- [ ] `apps/api/src/tenders/cpv/cpv-catalog.spec.ts` — Präfix-Normalisierung über inkonsistente Formate (Plan 11-03 Task 1, RED first)
+- [ ] `apps/web/src/app/(portal)/modules/tender-radar/components/TenderDetail.test.tsx` — sourceUrl, NULL-graceful, kein Mirroring (Plan 11-04)
+- [ ] `apps/api/src/tenders/tender-triage.service.spec.ts` — Upsert-Idempotenz, userId-Scoping, Cascade (Plan 11-05 Task 1, RED first)
+- [ ] `apps/api/src/tenders/tender-saved-search.service.spec.ts` — @@unique, Scoping, Ownership (Plan 11-06 Task 1, RED first)
+- [ ] (extend) `apps/api/src/tenders/tenders.controller.spec.ts` — Sort/Pagination-Bounds, coverage, triage- + saved-searches-Route-Order
+- [ ] (extend) `apps/api/src/tenders/tender-normalizer.service.spec.ts` — bundesland + cpvDivisions-Ableitung
+
+Vitest-Framework existiert bereits (api + web) — keine Framework-Installation nötig.
+
+---
+
+## Manual-Only Verifications
+
+| Behavior | Requirement | Why Manual | Test Instructions |
+|----------|-------------|------------|-------------------|
+| Backfill-Migrationen (bundesland, cpvDivisions) auf DB angewendet | FILTER-02/03 | DB hat keinen Host-Port; lokale Anwendung via `docker exec tessera-ctl-db-1 psql -U tessera -d tessera` (MEMORY-Hinweis) | `SELECT bundesland,count(*) FROM "Tender" GROUP BY bundesland` zeigt gefüllte Länder; cpvDivisions nicht-leer > 0 |
+| Neue Tabellen-Migrationen (TenderTriage, TenderSavedSearch) angewendet | UI-03/04, FILTER-06 | Lokale Migration + `prisma generate`; kein Docker-Deploy auf dem Testserver durch Claude | `prisma migrate status` grün nach lokalem Apply |
+| Triage/Suchprofile sind per-user isoliert | UI-03/04, FILTER-06, D-11 | Cross-User-Sichtbarkeit ist ein Browser-/Mehrbenutzer-Flow | Zweiter Testnutzer sieht Markierungen + Profile des ersten NICHT |
+| Coverage-Banner + dünne-Liste-Wahrnehmung | UI-05 | Visuelle/UX-Beurteilung | Banner erklärt Oberschwelle-DÖE; leere Filterergebnisse wirken nicht wie Defekt |
+
+---
+
+## Validation Sign-Off
+
+- [x] All tasks have `` verify or a documented Wave 0 / manual-gate dependency
+- [x] Sampling continuity: no 3 consecutive tasks without automated verify
+- [x] Wave 0 covers all MISSING references (spec scaffolds; RED-first for pure-logic units)
+- [x] No watch-mode flags (all use `vitest run`)
+- [x] Feedback latency < 15s (scoped runs)
+- [x] `nyquist_compliant: true` set in frontmatter
+
+**Approval:** approved 2026-07-21