diff --git a/.planning/phases/13-scraping-adapters-cross-source-dedup/13-RESEARCH.md b/.planning/phases/13-scraping-adapters-cross-source-dedup/13-RESEARCH.md
new file mode 100644
index 0000000..3b34120
--- /dev/null
+++ b/.planning/phases/13-scraping-adapters-cross-source-dedup/13-RESEARCH.md
@@ -0,0 +1,595 @@
+# Phase 13: Scraping Adapters & Cross-Source Deduplication - Research
+
+**Researched:** 2026-07-23
+**Domain:** Multi-Source-Ingestion (HTML-Scraping-Adapter), Cross-Source-Deduplizierung (Fuzzy-Fingerprint), Prisma-Schema-Migration
+**Confidence:** HIGH (Codebase + Live-DB verifiziert; Portal-Erreichbarkeit live geprüft)
+
+
+## User Constraints (from CONTEXT.md)
+
+### Locked Decisions
+- **D-01:** Robuster Kern zuerst, Scraping best-effort. Adapter-**Framework** + **Fuzzy-Dedup** + **harte Denylist** voll gebaut + getestet. Scraping-Selektoren nur so weit füllen, wie das Portal es zulässt; blockende/instabile Portale dokumentieren, nicht erzwingen. Kein fragiles All-or-Nothing.
+- **D-02:** Portal-Machbarkeit (NetServer lhs-vpbw/tender24/vergabe.landbw, cosinex/DTVP) ist Research-Punkt — live prüfen (HTML vs. Export/Feed, Rate-Limits, robots/AGB). Ergebnis steuert Selektor-Tiefe.
+- **D-03:** Ein Eintrag + Liste ALLER Quell-Links. Deduplizierte Ausschreibung = EIN `Tender` mit allen Quell-Portalen/Links/NoticeIds via neuer Kind-Tabelle `TenderSource` (1:n). NICHT "Primärquelle gewinnt, Rest verworfen".
+- **D-04:** Dedup-Schlüssel-Hierarchie: OCID → `Quelle:NoticeId` → Fuzzy-Fingerprint (Auftraggeber + Titel + CPV + Frist + Wert). Match → zusätzliche `TenderSource` anhängen statt neuen `Tender`.
+- **D-05:** Dedup greift erst ab der 2. aktiven Quelle. Mit nur DÖE aktiv läuft keine Fuzzy-Logik (Erfolgskriterium 3). Backfill: jeder Bestands-Tender bekommt eine `TenderSource`-Zeile.
+- **D-06:** vergabe24 + aumass = harte Denylist im Adapter-/Quellen-Registry. Registrierungs-Versuch wird im Code abgelehnt (Exception), nicht nur per Doku. Test beweist Ablehnung. Grund: AGB-Verbot automatisierter Zugriffe; Oberschwellen-Daten kommen eh via DÖE.
+
+### Claude's Discretion
+- Fingerprint-Algorithmus: Normalisierung (lowercase, Umlaute, Whitespace, CPV-Divisions-Präfix, Wert-Bucketing, Frist-Datum) + Hash oder Ähnlichkeits-Schwellwert; Felder-Gewichtung. Muss NULL-tolerant sein.
+- Adapter-Framework: Verallgemeinerung `TenderSourceAdapter` + Registry mit Denylist-Gate; `pollDueSources`/`TenderSourcePollConfig` auf mehrere `sourceType` (poll-once-fan-out-many bleibt).
+- Scraping-Client pro Portal (native `fetch`, evtl. HTML-Parser), Pagination/Rate-Limit, Fehlertoleranz.
+- Datenmodell `TenderSource`: Felder, Unique-Constraints, Beziehung zu `Tender`, Migration von `dedupKey`/`sourcePortal`.
+
+### Deferred Ideas (OUT OF SCOPE)
+- RSS/E-Mail-Quellen (Phase 14), Admin-Quellen-UI + ausgeschlossene-Portale-Anzeige UI-06 (Phase 14), i18n-Rollout (Phase 14). Keine neuen Benachrichtigungs-/Filter-Features.
+
+
+
+## Phase Requirements
+
+| ID | Beschreibung | Research Support |
+|----|--------------|------------------|
+| INGEST-02 | NetServer-Adapter (lhs-vpbw, tender24, vergabe.landbw) via EINEN config-getriebenen Adapter | Live: alle drei sind AI-AG-NetServer; `PublicationSearchControllerServlet` liefert öffentliche HTML-Trefferliste ohne Auth → EIN config-getriebener Adapter (Base-URL pro Portal). Siehe Portal-Feasibility + Pattern "Config-driven multi-portal adapter". |
+| INGEST-03 | cosinex/DTVP-Adapter | Live: cosinex VMP `Satellite/company/welcome.do` öffentlich erreichbar (40 KB HTML), kein öffentliches API/Feed → separater HTML-Adapter (cosinex ≠ NetServer HTML). |
+| INGEST-07 | vergabe24/aumass harte Denylist im Code | Registry mit `assertNotDenied()`-Gate wirft bei Registrierung; Test beweist Refusal. AGB-Verbot dokumentiert in feasibility.md. |
+| SCHEMA-03 | Dedup-Hierarchie OCID → source:noticeId → Fuzzy-Fingerprint; Dedup ab 2. Quelle; `TenderSource` 1:n | `TenderSource`-Modell + Fingerprint-Algorithmus + Ingestion-Upsert-Hook + Backfill. Alle Details unten. |
+
+
+## Summary
+
+Phase 13 verallgemeinert das in Phase 10 gebaute Single-Source-DÖE-Ingestion-Fundament zu einem Multi-Source-Framework mit Cross-Source-Deduplizierung. Drei tragende Säulen (alle voll getestet, unabhängig von Live-Scrapebarkeit — D-01): (1) ein Adapter-**Registry** mit Denylist-Gate, über das `pollDueSources` über mehrere `sourceType` fächert (poll-once-fan-out-many, KEIN findFirst), (2) ein **Fuzzy-Fingerprint-Dedup** im Upsert-Pfad, das ab der 2. Quelle greift, und (3) eine neue Kind-Tabelle **`TenderSource`** (1:n zu `Tender`), die pro Ausschreibung die Liste aller Quell-Portale + Links führt.
+
+Live-Befunde stützen die Machbarkeit: Die drei NetServer-Portale (tender24, lhs-vpbw, vergabe.landbw) laufen alle auf AI-AG-Vergabe@Net; `tender24.de/NetServer/PublicationSearchControllerServlet?function=SearchPublications` liefert öffentlich (HTTP 200, 161 KB) eine `
`-Trefferliste ohne Auth-Wall → **HTML-scrapebar jetzt** über EINEN config-getriebenen Adapter. cosinex/DTVP (`www.dtvp.de/Satellite/company/welcome.do`, HTTP 200, 40 KB) ist ebenfalls öffentlich, braucht aber einen eigenen Adapter (cosinex-HTML ≠ NetServer). Kein Portal bietet ein maschinenlesbares Export-Feed (OCDS/RSS/CSV) — reines HTML-Parsing. Die Live-DB zählt **2851 DÖE-Tender** (0% NULL-OCID, 92.4% NULL-Wert, 15.7% NULL-Frist, 100% sourceUrl) — das ist die Backfill-Menge und bestätigt: Fingerprint MUSS NULL-tolerant sein.
+
+**Primary recommendation:** Registry-getriebenes Fan-out-Framework + `TenderSource`-1:n-Modell + normalisierter kanonischer Fingerprint-String (nicht Ähnlichkeits-Threshold), gehängt an einen dreistufigen Match im Upsert-Pfad (OCID → source:noticeId → Fingerprint), aktiv nur ab ≥2 aktiven Quellen. HTML-Parser: `node-html-parser` (leichtgewichtig) für NetServer/cosinex-Selektoren.
+
+## Architectural Responsibility Map
+
+| Capability | Primary Tier | Secondary Tier | Rationale |
+|------------|-------------|----------------|-----------|
+| Portal-Fetch (HTTP + HTML-Parse) | API / Backend (Adapter) | — | native `fetch` in Adapter, wie DoeOpenDataAdapter; kein Frontend-Zugriff auf Portale |
+| Source-Registry + Denylist-Gate | API / Backend | — | Sicherheits-/Compliance-Grenze gehört in DI-Layer, testbar |
+| Fan-out-Scheduling | API / Backend (Scheduler+Ingestion) | — | ein globaler Cron, platform-global (D-03 Phase 10) |
+| Dedup / Fingerprint | API / Backend (Ingestion-Upsert) | Database (unique constraints) | Match-Logik im Service, Integrität via DB-Constraints |
+| `TenderSource`-Persistenz | Database / Storage | — | 1:n-Relation, Backfill-Migration |
+| Multi-Source-Link-Anzeige | Frontend (TenderDetail) | API (Read-Endpoint `include`) | Detailansicht listet Quellen; Endpoint liefert sie via Prisma `include` |
+
+## Standard Stack
+
+### Core
+| Library | Version | Purpose | Why Standard |
+|---------|---------|---------|--------------|
+| native `fetch` + `AbortController` | Node 20+ built-in | HTTP-Client pro Portal | Projekt-Konvention (kein axios); exakt DoeOpenDataAdapter/icon-discovery-Idiom `[VERIFIED: codebase]` |
+| Prisma | 7.8.x | Schema + Migration `TenderSource` | Bestehender ORM; handgeschriebene Migrationen lokal via `docker compose exec` `[VERIFIED: codebase]` |
+| Node `crypto` (`createHash`) | built-in | Fingerprint-Hash (sha256) | Bereits für contentHash genutzt (tender-normalizer) `[VERIFIED: codebase]` |
+
+### Supporting
+| Library | Version | Purpose | When to Use |
+|---------|---------|---------|-------------|
+| `node-html-parser` | ^7.x | HTML-Trefferlisten der NetServer/cosinex-Portale parsen | Wenn Selektoren gefüllt werden. Leichtgewichtig, CSS-Selektor-API, keine Browser-Engine. `[ASSUMED]` — vor Install verifizieren |
+| `fast-xml-parser` | bereits vorhanden | Falls ein Portal per-Notice-XML liefert (aumass-Muster, hier N/A) | Bereits Dependency (DÖE); NetServer/cosinex liefern aber HTML, nicht XML `[VERIFIED: codebase]` |
+
+### Alternatives Considered
+| Instead of | Could Use | Tradeoff |
+|------------|-----------|----------|
+| `node-html-parser` | `cheerio` | cheerio hat vollere jQuery-API, aber schwerer + mehr Deps. Für einfache ``-Extraktion reicht node-html-parser. Beide erfüllen den Zweck. |
+| Normalisierter Fingerprint-String (Hash) | Ähnlichkeits-Threshold (Levenshtein/Trigram) | Threshold braucht O(n)-Scan pro Ingest gegen Bestand (2851+ Rows) + Tuning; Hash ist O(1)-Lookup und deterministisch/testbar. Empfehlung: Hash. Siehe Pattern 3. |
+| HTML-Scraping | Portal-natives E-Mail-Alert-Ingest | Phase 14 (deferred). Diese Phase: HTML best-effort. |
+
+**Installation:**
+```bash
+# Nur falls Selektoren gefüllt werden (D-01 best-effort):
+pnpm --filter @tessera/api add node-html-parser
+```
+
+**Version verification:** `node-html-parser` vor Verwendung mit `npm view node-html-parser version` prüfen und Legitimitäts-Gate durchlaufen (siehe Audit).
+
+## Package Legitimacy Audit
+
+| Package | Registry | Age | Downloads | Source Repo | Verdict | Disposition |
+|---------|----------|-----|-----------|-------------|---------|-------------|
+| node-html-parser | npm | reif (>7 Jahre) | mehrere Mio/Woche | github.com/taoqf/node-html-parser | `[ASSUMED]` bis Gate-Lauf | Planner: `checkpoint:human-verify` vor Install ODER `cheerio` (bereits weit verbreitet) |
+
+**Packages removed due to [SLOP] verdict:** keine
+**Packages flagged as suspicious [SUS]:** keine
+*Der Planner muss den `node-html-parser`-Install hinter `checkpoint:human-verify` (Package-Legitimacy-Gate: `gsd-tools query package-legitimacy check --ecosystem npm node-html-parser cheerio`) setzen, da der Name aus Trainingswissen stammt, nicht aus autoritativer Quelle. Alternativ `cheerio` wählen. Der Kern (Registry, Dedup, Denylist, `TenderSource`) braucht KEIN Scraping-Package und ist unabhängig davon voll testbar (D-01).*
+
+## Architecture Patterns
+
+### System Architecture Diagram
+
+```
+ TenderSchedulerService (1 globaler Cron-Tick)
+ │
+ ▼
+ TenderIngestionService.pollDueSources()
+ │ liest ALLE aktiven TenderSourcePollConfig
+ │ (fan-out über sourceType, kein findFirst)
+ ┌───────────────┼───────────────┐
+ ▼ ▼ ▼
+ SourceRegistry.get(sourceType) → adapter
+ │ │ │
+ DoeOpenDataAdapter NetServerAdapter CosinexAdapter
+ (Batch-XML/OCDS) (HTML-Table, (HTML,
+ │ config: portalId) Satellite)
+ ▼ ▼ ▼
+ RawTenderRecord[] (pro Adapter)
+ │
+ ▼
+ TenderNormalizerService.normalize() → NormalizedTenderFields
+ │
+ ▼
+ ┌──────────────────────────────────────────┐
+ │ Dedup-Resolver (nur wenn ≥2 aktive │
+ │ Quellen — sonst inert, D-05): │
+ │ 1. OCID-Match? ─┐ │
+ │ 2. source:noticeId? ├─► Tender X │
+ │ 3. Fingerprint-Match? ─┘ gefunden │
+ │ │ ja │ nein │
+ │ ▼ ▼ │
+ │ TenderSource.create Tender.create │
+ │ (an Tender X hängen) + TenderSource │
+ └──────────────────────────────────────────┘
+ │
+ ▼
+ Tender (1) ──1:n──► TenderSource (n) [DB]
+ │
+ ▼
+ GET /modules/tender-radar/:id (include: { sources })
+ │
+ ▼
+ TenderDetail.tsx → Liste aller Quell-Links
+```
+
+**Denylist-Gate:** `SourceRegistry.register(adapter)` wirft, wenn `adapter.sourcePortal ∈ {vergabe24, aumass}` — greift bereits bei DI-Boot, bevor irgendein Poll läuft.
+
+### Recommended Project Structure
+```
+apps/api/src/tenders/
+├── adapters/
+│ ├── tender-source-adapter.interface.ts # erweitern (siehe Pattern 1)
+│ ├── doe-opendata.adapter.ts # unverändert
+│ ├── netserver.adapter.ts # NEU: config-driven, 3 Portale
+│ ├── cosinex.adapter.ts # NEU: DTVP
+│ └── *.spec.ts
+├── source-registry.ts # NEU: Registry + Denylist-Gate
+├── source-registry.spec.ts # NEU: beweist Denylist-Refusal
+├── tender-fingerprint.ts # NEU: pure Fingerprint-Fn
+├── tender-fingerprint.spec.ts # NEU: NULL-Toleranz, Kollisionen
+├── tender-dedup.service.ts # NEU: 3-stufiger Resolver
+├── tender-ingestion.service.ts # erweitern: fan-out + dedup-hook
+└── tender-normalizer.service.ts # ggf. Fingerprint-Felder ergänzen
+apps/api/prisma/
+├── schema.prisma # + model TenderSource
+└── migrations/_tender_source/ # Schema + Backfill (2 Steps)
+```
+
+### Pattern 1: Adapter-Interface verallgemeinern (INGEST-02/03)
+**What:** `SourceType` wird von einem String-Literal zu einer offenen Union; Adapter deklariert zusätzlich `sourcePortal(s)`. Config-driven NetServer-Adapter serviert 3 Portale.
+**When to use:** Basis für alle neuen Adapter.
+```typescript
+// tender.types.ts — SourceType erweitern (aktuell: nur 'doe-opendata')
+export type SourceType = 'doe-opendata' | 'ai-netserver' | 'cosinex-dtvp';
+
+// tender-source-adapter.interface.ts — Adapter kennt seine bedienten Portale
+export interface TenderSourceAdapter {
+ readonly sourceType: SourceType;
+ /** Ein Adapter kann mehrere Portale bedienen (NetServer: 3). */
+ readonly portals: readonly string[]; // z.B. ['tender24','lhs-vpbw','vergabe.landbw']
+ fetchTenders(dayCursor: string): Promise;
+}
+
+// netserver.adapter.ts — EIN Adapter, portal-Config injiziert
+const NETSERVER_PORTALS = {
+ 'tender24': { baseUrl: 'https://www.tender24.de' },
+ 'lhs-vpbw': { baseUrl: 'https://lhs-vpbw.vmstart.de' },
+ 'vergabe.landbw': { baseUrl: 'https://vergabe.landbw.de' },
+} as const;
+// fetchTenders() iteriert die Portale, ruft je
+// `${baseUrl}/NetServer/PublicationSearchControllerServlet?function=SearchPublications`
+// (live verifiziert: tender24 → HTTP 200, 161 KB ), parst die Trefferzeilen,
+// setzt sourcePortal je Zeile korrekt.
+```
+**Source:** Live-Probe tender24 NetServer `[VERIFIED: live curl 2026-07-23]`; interface aus `[VERIFIED: codebase]`.
+
+### Pattern 2: Source-Registry mit Denylist-Gate (INGEST-07)
+**What:** Zentrale Registry, die Adapter nach `sourceType` bereitstellt und bei Denylist-Portalen die Registrierung ablehnt.
+```typescript
+// source-registry.ts
+export const DENYLISTED_PORTALS = ['vergabe24', 'aumass'] as const;
+
+export class DeniedPortalError extends Error {
+ constructor(portal: string) {
+ super(`Portal '${portal}' ist AGB-seitig für automatisierten Zugriff gesperrt und darf nicht registriert werden.`);
+ this.name = 'DeniedPortalError';
+ }
+}
+
+@Injectable()
+export class SourceRegistry {
+ private readonly adapters = new Map();
+
+ register(adapter: TenderSourceAdapter): void {
+ for (const portal of adapter.portals) {
+ if ((DENYLISTED_PORTALS as readonly string[]).includes(portal)) {
+ throw new DeniedPortalError(portal); // D-06: harte Ablehnung im Code
+ }
+ }
+ this.adapters.set(adapter.sourceType, adapter);
+ }
+
+ get(type: SourceType): TenderSourceAdapter | undefined {
+ return this.adapters.get(type);
+ }
+ activeAdapters(): TenderSourceAdapter[] { return [...this.adapters.values()]; }
+}
+```
+**Test (beweist D-06 / Erfolgskriterium 4):** Ein Fake-Adapter mit `portals: ['vergabe24']` → `expect(() => registry.register(fake)).toThrow(DeniedPortalError)`.
+**Source:** D-06 `[CITED: 13-CONTEXT.md]`; AGB-Verbot `[CITED: ausschreibungs-portale-feasibility.md]`.
+
+### Pattern 3: Fan-out in pollDueSources (poll-once-fan-out-many bleiben)
+**What:** Statt `findUnique({ sourceType: 'doe-opendata' })` alle aktiven Configs laden und je Adapter pollen. Der bestehende Day-Cursor-Gate + Delta-Matching bleibt PRO Quelle erhalten.
+```typescript
+async pollDueSources(): Promise {
+ const configs = await this.prisma.tenderSourcePollConfig.findMany({
+ where: { isActive: true }, // fan-out, NICHT findFirst/findUnique
+ });
+ const activePortalCount = configs.length; // D-05 Dedup-Gate-Signal
+ const newTenderIds: string[] = [];
+ for (const config of configs) {
+ const adapter = this.registry.get(config.sourceType as SourceType);
+ if (!adapter) continue; // Adapter fehlt → skip, kein Crash
+ try {
+ // ... bestehender Day-Cursor-Gate + fetch + normalize PRO Quelle ...
+ // Upsert läuft jetzt über den Dedup-Resolver (Pattern 5), der
+ // activePortalCount kennt (D-05).
+ } catch (err) { this.logger.error(...); } // catch-per-source: eine kaputte
+ // Quelle killt die anderen nicht (D-01 Fehlertoleranz)
+ }
+ if (newTenderIds.length) await this.matching.matchDelta(newTenderIds);
+}
+```
+**Wichtig:** `catch-per-source` ist neu und zentral für D-01 („robuster Kern, ein blockendes Portal darf den Tick nicht killen").
+**Source:** bestehendes `pollDueSources` `[VERIFIED: codebase]`.
+
+### Pattern 4: Fingerprint (SCHEMA-03, NULL-tolerant)
+**What:** Pure Funktion → deterministischer normalisierter kanonischer String → sha256. KEINE null-Felder in den Hash mischen, die instabil sind; NULL wird zu leerem Segment, sodass zwei Records mit denselben *vorhandenen* Feldern matchen.
+```typescript
+// tender-fingerprint.ts
+import { createHash } from 'crypto';
+
+function normText(s: string | null): string {
+ if (!s) return '';
+ return s.toLowerCase()
+ .replace(/ä/g,'ae').replace(/ö/g,'oe').replace(/ü/g,'ue').replace(/ß/g,'ss')
+ .replace(/[^a-z0-9]+/g,' ').trim().replace(/\s+/g,' ');
+}
+function cpvDivisionKey(cpvDivisions: string[]): string {
+ return [...new Set(cpvDivisions)].sort().join(','); // 2-stellige Division, stabil
+}
+function valueBucket(v: number | null): string {
+ if (v === null) return ''; // NULL-tolerant (92.4% NULL live!)
+ // grobe Buckets gegen Rundungs-/Formatdifferenzen zwischen Quellen
+ return String(Math.floor(Math.log10(Math.max(v, 1)))); // Größenordnung
+}
+function deadlineKey(d: Date | null): string {
+ return d ? d.toISOString().slice(0, 10) : ''; // nur Datum, Tageskorn
+}
+
+export function tenderFingerprint(f: {
+ buyerName: string | null; title: string; cpvDivisions: string[];
+ deadlineAt: Date | null; estimatedValue: number | null;
+}): string {
+ const canonical = [
+ normText(f.buyerName),
+ normText(f.title),
+ cpvDivisionKey(f.cpvDivisions),
+ deadlineKey(f.deadlineAt),
+ valueBucket(f.estimatedValue),
+ ].join('|');
+ return createHash('sha256').update(canonical).digest('hex');
+}
+```
+**Design-Begründung (Discretion-Bereich):**
+- **Titel + Auftraggeber** tragen das Hauptgewicht (fast immer vorhanden). **CPV-Division** statt voller CPV-Code: robuster gegen Quell-Formatdifferenzen (Normalizer liefert `cpvDivisions` bereits, Pitfall-2-Muster). **Wert** nur als Größenordnungs-Bucket (Quellen runden unterschiedlich, 92.4% eh NULL). **Frist** auf Tageskorn (Zeitzonen/Uhrzeit-Differenzen).
+- **NULL-Toleranz:** NULL → leeres Segment. Zwei Records mit gleichem Titel/Auftraggeber/CPV matchen auch wenn beide Wert=NULL. **Risiko:** zu grob → Kollision unähnlicher Ausschreibungen desselben Auftraggebers. **Gegenmaßnahme:** Titel ist im Hash → verschiedene Titel = verschiedener Fingerprint. Wenn Kollisionen auftreten, Feld-Granularität erhöhen (voller CPV statt Division), NICHT Threshold-Matching einführen.
+- **Warum Hash statt Ähnlichkeits-Threshold:** deterministisch, O(1)-Lookup via `@unique`-Spalte, unit-testbar. Threshold bräuchte O(n)-Scan gegen 2851+ Rows pro Ingest + Tuning.
+**Source:** NULL-Statistik `[VERIFIED: live DB 2026-07-23 — 92.4% NULL value, 15.7% NULL deadline]`; cpvDivisions-Muster `[VERIFIED: codebase normalizer]`.
+
+### Pattern 5: Dreistufiger Dedup-Resolver im Upsert-Pfad (D-04/D-05)
+**What:** Ersetzt den direkten `tender.upsert` für Nicht-DÖE-Quellen. Reihenfolge: OCID → source:noticeId → Fingerprint. Nur aktiv wenn ≥2 Quellen.
+```typescript
+// tender-dedup.service.ts (Pseudo)
+async resolve(n: NormalizedTenderFields, opts: { dedupActive: boolean }): Promise<{ tenderId: string; created: boolean }> {
+ // 1) OCID (falls vorhanden)
+ let match = n.ocid ? await this.prisma.tender.findFirst({ where: { ocid: n.ocid } }) : null;
+ // 2) source:noticeId — exakt gleiche Quelle re-seen (idempotenter Re-Poll)
+ if (!match) match = await this.prisma.tenderSource.findUnique({
+ where: { sourcePortal_sourceNoticeId: { sourcePortal: n.sourcePortal, sourceNoticeId: n.sourceNoticeId } },
+ }).then(s => s ? this.prisma.tender.findUnique({ where: { id: s.tenderId } }) : null);
+ // 3) Fingerprint — NUR ab 2. Quelle (D-05); mit nur DÖE ist dedupActive=false → übersprungen
+ if (!match && opts.dedupActive) {
+ match = await this.prisma.tender.findFirst({ where: { fingerprint: tenderFingerprint(n) } });
+ }
+ if (match) { // → als zusätzliche Quelle anhängen (D-03)
+ await this.prisma.tenderSource.upsert({
+ where: { sourcePortal_sourceNoticeId: { sourcePortal: n.sourcePortal, sourceNoticeId: n.sourceNoticeId } },
+ create: { tenderId: match.id, sourcePortal: n.sourcePortal, sourceNoticeId: n.sourceNoticeId, sourceUrl: n.sourceUrl, ocid: n.ocid },
+ update: { sourceUrl: n.sourceUrl },
+ });
+ // Tender-Felder ggf. anreichern (z.B. Wert/Frist füllen wenn bisher NULL) — optional
+ return { tenderId: match.id, created: false };
+ }
+ const created = await this.prisma.tender.create({ data: { ...n /* + fingerprint */ } });
+ await this.prisma.tenderSource.create({ data: { tenderId: created.id, sourcePortal: n.sourcePortal, sourceNoticeId: n.sourceNoticeId, sourceUrl: n.sourceUrl, ocid: n.ocid } });
+ return { tenderId: created.id, created: true };
+}
+```
+**D-05-Inert-Beweis:** `dedupActive = (activePortalCount >= 2)`. Mit nur DÖE aktiv wird Stufe 3 nie erreicht → bestehendes DÖE-Verhalten unverändert (Erfolgskriterium 3). Test: nur DÖE aktiv → zwei Tender mit gleichem Fingerprint bleiben zwei Tender.
+**Source:** D-04/D-05 `[CITED: 13-CONTEXT.md]`.
+
+### Anti-Patterns to Avoid
+- **findFirst/findUnique statt fan-out:** würde nur eine Quelle pollen. Muss `findMany({ where: { isActive: true } })` sein.
+- **Threshold-Matching gegen ganzen Bestand pro Ingest:** O(n)-Kosten, nicht deterministisch. Fingerprint-Hash mit `@unique`-Lookup.
+- **Dedup mit nur einer Quelle laufen lassen:** verletzt D-05/Erfolgskriterium 3. `dedupActive`-Gate hart an `activePortalCount >= 2`.
+- **Ein blockendes Portal wirft den ganzen Tick:** catch-per-source Pflicht (D-01).
+- **Denylist nur per Doku:** muss Code-Exception sein (D-06). Ein Test ohne geworfene Exception = Requirement nicht erfüllt.
+
+## Don't Hand-Roll
+
+| Problem | Don't Build | Use Instead | Why |
+|---------|-------------|-------------|-----|
+| HTML-Trefferliste parsen | Regex über HTML | `node-html-parser`/`cheerio` CSS-Selektoren | Regex-HTML-Parsing bricht bei jeder Markup-Änderung; Selektoren sind robuster + lesbar |
+| Fingerprint-Hash | eigene Hash-Impl | Node `crypto.createHash('sha256')` | Bereits für contentHash genutzt, gleiche Konvention |
+| Umlaut-Normalisierung | ad-hoc überall | eine `normText()`-Fn in `tender-fingerprint.ts` | Eine Quelle der Wahrheit, testbar |
+| Unique-Enforcement Quelle | App-seitige Prüfung | Prisma `@@unique([sourcePortal, sourceNoticeId])` | DB garantiert Integrität auch bei Races |
+
+**Key insight:** Der Kern (Registry, Fingerprint, Dedup, `TenderSource`, Backfill) hat KEINE externe Abhängigkeit und ist 100% unit-testbar — genau das fordert D-01. Nur das Selektor-Füllen der HTML-Adapter ist best-effort und darf hinter der Adapter-Grenze instabil bleiben.
+
+## Runtime State Inventory
+
+> Rename/Migration-Aspekt: `dedupKey @unique` → `TenderSource`-Ebene + neue `fingerprint`-Spalte. Backfill der Bestands-Tender.
+
+| Category | Items Found | Action Required |
+|----------|-------------|------------------|
+| Stored data | **2851 `Tender`-Rows** (alle `sourcePortal='doe-opendata'`, 100% OCID, 100% sourceUrl) `[VERIFIED: live DB]` | Backfill: pro Tender EINE `TenderSource`-Zeile aus `sourcePortal`/`sourceNoticeId`/`sourceUrl`/`ocid`. Datenmigration (kein Code-Edit). |
+| Stored data | `Tender.dedupKey @unique` (aktuell ocid \| sourcePortal:sourceNoticeId) | Bleibt vorerst (SCHEMA-02-Upsert-Target für DÖE). NEUE `fingerprint`-Spalte (nullable) additiv; Backfill berechnet Fingerprint für Bestand. `dedupKey` NICHT droppen in dieser Phase (Risiko), nur ergänzen. |
+| Live service config | `TenderSourcePollConfig` hat nur 1 Row (`doe-opendata`) `[VERIFIED: codebase — findUnique-Muster]` | Neue Rows für `ai-netserver`/`cosinex-dtvp` werden vom Admin (Phase 14 UI) oder Seed angelegt; `isActive=false` Default. Kein Zwang, sie in P13 zu aktivieren. |
+| OS-registered state | Ein globaler Cron `tender-doe-poll` (SchedulerRegistry) | Job-Name ist DÖE-spezifisch. Empfehlung: umbenennen zu `tender-poll` (generisch) ODER belassen — es bleibt EIN globaler Job für alle Quellen (poll-once-fan-out-many). Kein Multi-Job. |
+| Secrets/env vars | Keine — Portale sind öffentlich, keine Auth `[VERIFIED: live — kein Login-Wall auf Suchergebnissen]` | Keine. |
+| Build artifacts | Prisma Client muss nach `TenderSource`-Schema neu generiert werden | `pnpm --filter @tessera/api prisma generate` nach Migration. |
+
+**Nothing found in category:** Secrets — verifiziert: NetServer/cosinex-Suchtreffer sind ohne Login erreichbar (Live-Probe HTTP 200 ohne Auth-Redirect).
+
+## Common Pitfalls
+
+### Pitfall 1: Fingerprint-Kollision desselben Auftraggebers
+**What goes wrong:** Zwei verschiedene Ausschreibungen desselben Auftraggebers mit NULL-Wert/NULL-Frist könnten kollidieren, wenn Titel zu grob normalisiert wird.
+**Why it happens:** NULL-Toleranz entfernt unterscheidende Felder; über-aggressive Titel-Normalisierung.
+**How to avoid:** Titel bleibt im Hash (nach `normText`, aber vollständig). Wert nur als Bucket, nicht weggelassen. Test mit realen Auftraggeber-Titel-Paaren.
+**Warning signs:** Zwei sichtbar verschiedene Ausschreibungen erscheinen als ein Eintrag mit zwei Quellen.
+
+### Pitfall 2: NetServer-HTML-Struktur bricht Selektoren
+**What goes wrong:** AI-AG ändert Markup → Adapter liefert leere/falsche Records.
+**Why it happens:** HTML-Scraping ist inhärent fragil (D-01 erkennt das an).
+**How to avoid:** Adapter fängt Parse-Fehler pro Zeile (wie DoeOpenDataAdapter `logger.warn` pro Entry), liefert `[]` bei Totalausfall statt zu werfen; catch-per-source im Ingestion-Tick. Selektoren zentral als Konstanten.
+**Warning signs:** Poll-Tick loggt „0 records" für ein zuvor funktionierendes Portal.
+
+### Pitfall 3: Dedup läuft versehentlich mit einer Quelle
+**What goes wrong:** Fingerprint-Stufe aktiv bei nur DÖE → Erfolgskriterium 3 verletzt, evtl. falsche Merges im Bestand.
+**Why it happens:** `dedupActive`-Gate vergessen oder falsch (z.B. an `>=1` statt `>=2`).
+**How to avoid:** `dedupActive = activePortalCount >= 2`, explizit getestet (nur-DÖE-Fall).
+**Warning signs:** Tender-Anzahl sinkt nach Ingest ohne zweite Quelle.
+
+### Pitfall 4: NetServer `www.` vs. non-`www.` / Redirect
+**What goes wrong:** `tender24.de/NetServer/…` redirected (301); `vergabe.landbw.de/` liefert nur meta-refresh-Stub (641 B) auf `/NetServer`.
+**Why it happens:** Portale haben unterschiedliche Host-/Pfad-Konventionen. `[VERIFIED: live — tender24 www→301, landbw meta-refresh]`
+**How to avoid:** Base-URL pro Portal in Config exakt setzen (inkl. `www.` wo nötig); `fetch` mit `redirect: 'follow'`; direkt den `/NetServer/PublicationSearchControllerServlet`-Pfad ansteuern statt der Root.
+**Warning signs:** 301/302 oder 641-Byte-Stub statt Trefferliste.
+
+### Pitfall 5: `TenderSource`-Backfill vor Unique-Constraint
+**What goes wrong:** Migration setzt `@@unique([sourcePortal, sourceNoticeId])` bevor Backfill läuft → Backfill-Insert schlägt fehl bei Alt-Duplikaten (unwahrscheinlich, aber DÖE hat theoretisch eindeutige noticeIds).
+**Why it happens:** Reihenfolge Schema-Change vs. Data-Backfill.
+**How to avoid:** Backfill in zwei Migrations-Schritten: (1) Tabelle + Spalten anlegen, (2) Daten backfillen, dann Constraint. DÖE-noticeIds sind live eindeutig (0% NULL OCID) → geringes Risiko, trotzdem Reihenfolge sichern.
+
+## Code Examples
+
+### `TenderSource`-Modell (Prisma)
+```prisma
+// SCHEMA-03 — Quell-Ebene einer Ausschreibung. 1:n zu Tender (D-03).
+// Ein Tender = ein Trefferlisten-Eintrag; mehrere TenderSource = mehrere Portale.
+model TenderSource {
+ id String @id @default(uuid())
+ tenderId String
+ sourcePortal String // 'doe-opendata' | 'tender24' | 'lhs-vpbw' | 'vergabe.landbw' | 'cosinex-dtvp'
+ sourceNoticeId String
+ ocid String?
+ sourceUrl String?
+ createdAt DateTime @default(now())
+ tender Tender @relation(fields: [tenderId], references: [id], onDelete: Cascade)
+
+ @@unique([sourcePortal, sourceNoticeId]) // eine Quell-Notiz gehört zu genau einem Tender
+ @@index([tenderId])
+}
+
+// In model Tender ergänzen:
+// fingerprint String? // SCHEMA-03 Fuzzy-Dedup-Schlüssel (nullable, backfilled)
+// sources TenderSource[]
+// @@index([fingerprint])
+// dedupKey @unique bleibt vorerst (DÖE-Upsert-Target, SCHEMA-02).
+```
+
+### Backfill-Migration (SQL-Skizze)
+```sql
+-- Step 2 der Migration (nach Tabellen-Anlage): eine TenderSource pro Bestands-Tender
+INSERT INTO "TenderSource" (id, "tenderId", "sourcePortal", "sourceNoticeId", ocid, "sourceUrl", "createdAt")
+SELECT gen_random_uuid(), t.id, t."sourcePortal", t."sourceNoticeId", t.ocid, t."sourceUrl", now()
+FROM "Tender" t;
+-- fingerprint-Backfill erfolgt im TS-Migrations-Script (braucht normText/cpvDivisions-Logik),
+-- nicht in reinem SQL — Umlaut-/CPV-Normalisierung lebt im Code.
+```
+
+### Read-Endpoint mit Quell-Links (Display)
+```typescript
+// tenders.controller.ts — getTender include sources
+@Get(':id')
+@UseModule('tender-radar')
+async getTender(@Param('id') id: string) {
+ const tender = await this.prisma.tender.findUnique({
+ where: { id },
+ include: { sources: { select: { sourcePortal: true, sourceUrl: true, sourceNoticeId: true } } },
+ });
+ if (!tender) throw new NotFoundException('Tender not found');
+ return tender; // tender.sources[] → Frontend rendert Liste
+}
+```
+
+### TenderDetail: Multi-Source-Liste (Display)
+```tsx
+// TenderDetail.tsx — ersetzt den einzelnen sourceUrl-Block durch eine Liste.
+// Fallback: wenn tender.sources leer/undefined (alte API), auf tender.sourceUrl zurückfallen.
+{tender.sources && tender.sources.length > 0 ? (
+
+) : ( /* bestehender Einzel-sourceUrl-Block als Fallback */ )}
+```
+
+## State of the Art
+
+| Old Approach (Phase 10) | Current Approach (Phase 13) | Impact |
+|--------------------------|------------------------------|--------|
+| `pollDueSources` → `findUnique({ sourceType:'doe-opendata' })` | `findMany({ where:{ isActive:true } })` fan-out | Mehrere Quellen pro Tick |
+| Single `DoeOpenDataAdapter` injiziert | `SourceRegistry` mit Denylist-Gate | INGEST-07 strukturell erzwungen |
+| `dedupKey @unique` = einziger Match | 3-stufig OCID→source:noticeId→Fingerprint | Cross-Source-Merge |
+| `sourcePortal`/`sourceNoticeId` auf `Tender` | zusätzlich `TenderSource` 1:n | Liste aller Quellen pro Tender |
+| Detail zeigt ein `sourceUrl` | Detail zeigt `sources[]`-Liste | D-03-Anzeige |
+
+**Deprecated/outdated:** nichts entfernt — additive Migration. `SourceType`-Literal `'doe-opendata'` wird zu Union erweitert (breaking für Typen, aber lokal begrenzt).
+
+## Assumptions Log
+
+| # | Claim | Section | Risk if Wrong |
+|---|-------|---------|---------------|
+| A1 | `node-html-parser` ist das passende, legitime HTML-Parse-Package | Standard Stack | Planner gated via `checkpoint:human-verify`; `cheerio` als Alternative. Kern unabhängig. |
+| A2 | NetServer-Trefferliste ist ohne Auth vollständig parsebar (nicht nur Teaser) | Portal-Feasibility | Selektoren liefern evtl. nur Teil-Felder → best-effort (D-01), Framework bleibt grün |
+| A3 | Fingerprint-Feld-Auswahl (Titel+Buyer dominant, CPV-Division, Wert-Bucket, Frist-Tag) balanciert Kollision vs. Match korrekt | Pattern 4 | Bei Kollisionen Granularität erhöhen; Threshold NICHT einführen |
+| A4 | cosinex DTVP-Suche ist per HTTP paginierbar ohne JS-Rendering | Portal-Feasibility | cosinex evtl. JS-lastig → Selektoren nur teilweise füllbar (best-effort, D-01) |
+| A5 | Ein globaler Cron-Job bleibt (kein Multi-Job pro Quelle) | Runtime State | Falls per-Quelle-Intervalle nötig → mehr Jobs; MVP: ein Job reicht |
+
+## Open Questions
+
+1. **cosinex DTVP JS-Rendering-Grad**
+ - Was wir wissen: `Satellite/company/welcome.do` liefert 40 KB HTML (HTTP 200, öffentlich).
+ - Was unklar ist: Ob die Trefferliste server-seitig gerendert ist oder per JS/AJAX nachlädt (kein Headless-Browser im Stack, D-01 verbietet fragiles Erzwingen).
+ - Empfehlung: Selektoren gegen die server-gerenderte Seite füllen; wenn JS-abhängig → als „needs-JS, deferred" dokumentieren, Adapter-Skelett + Test trotzdem liefern.
+2. **NetServer Pagination + Detail-URL pro Notiz**
+ - Was wir wissen: Suchservlet liefert `` mit Ausschreibungszeilen (161 KB).
+ - Was unklar ist: Exakte Query-Parameter für Blättern und der stabile Deep-Link je Notiz (`sourceUrl`/`sourceNoticeId`).
+ - Empfehlung: Beim Selektor-Füllen die Zeilen-Anchor-hrefs als `sourceUrl` + eine stabile ID daraus als `sourceNoticeId` extrahieren; Pagination best-effort (erste Seite reicht für MVP-Nachweis).
+3. **`dedupKey`-Zukunft**
+ - Was wir wissen: `dedupKey @unique` ist DÖE-Upsert-Target (SCHEMA-02).
+ - Was unklar ist: Ob es langfristig durch `fingerprint` ersetzt wird.
+ - Empfehlung: In P13 belassen (additive `fingerprint`-Spalte). Konsolidierung ist späterer Refactor, nicht MVP-Scope.
+
+## Environment Availability
+
+| Dependency | Required By | Available | Version | Fallback |
+|------------|------------|-----------|---------|----------|
+| tender24.de NetServer | INGEST-02 | ✓ HTTP 200, `` öffentlich | Vergabe@Net | HTML-Parse |
+| lhs-vpbw.vmstart.de | INGEST-02 | ✓ (NetServer, robots 404) | Vergabe@Net | HTML-Parse |
+| vergabe.landbw.de | INGEST-02 | ✓ meta-refresh→/NetServer | Vergabe@Net | Base-URL auf /NetServer zeigen |
+| dtvp.de cosinex VMP | INGEST-03 | ✓ HTTP 200, 40 KB (`Satellite/company/welcome.do`) | cosinex | HTML-Parse, ggf. JS-limitiert (OQ1) |
+| DÖE OpenData API | Bestand | ✓ HTTP 200 (live re-verifiziert) | ocds-mnwr74 | — |
+| vergabe24 / aumass | INGEST-07 (Denylist) | ✗ absichtlich NICHT genutzt | — | Denylist-Refusal, KEIN Zugriff (D-06) |
+| PostgreSQL (tessera db) | Migration/Backfill | ✓ (Container, db `tessera`, user `tessera`) | 16 | — |
+
+**Missing dependencies with no fallback:** keine — alle scrapebaren Portale live erreichbar; Kern (Registry/Dedup/`TenderSource`) braucht keine externen Deps.
+**Missing dependencies with fallback:** `node-html-parser` (via Gate; `cheerio` alternativ). Portale ohne stabile Selektoren → best-effort, Adapter-Skelett + Test bleiben grün (D-01).
+
+## Validation Architecture
+
+### Test Framework
+| Property | Value |
+|----------|-------|
+| Framework | Vitest 3.x |
+| Config file | vorhanden (bestehende `*.spec.ts` im tenders-Modul) |
+| Quick run command | `pnpm --filter @tessera/api test src/tenders` |
+| Full suite command | `pnpm --filter @tessera/api test` |
+
+### Phase Requirements → Test Map
+| Req ID | Behavior | Test Type | Automated Command | File Exists? |
+|--------|----------|-----------|-------------------|-------------|
+| INGEST-07 | Denylist-Portal-Registrierung wirft | unit | `pnpm --filter @tessera/api test source-registry` | ❌ Wave 0 |
+| SCHEMA-03 | Fingerprint NULL-tolerant + kollisionsarm | unit | `pnpm --filter @tessera/api test tender-fingerprint` | ❌ Wave 0 |
+| SCHEMA-03 | Dedup inert bei nur DÖE (D-05) | unit/integration | `pnpm --filter @tessera/api test tender-dedup` | ❌ Wave 0 |
+| SCHEMA-03 | 2. Quelle hängt TenderSource an statt neuem Tender | integration | `pnpm --filter @tessera/api test tender-ingestion` | ⚠️ erweitern |
+| INGEST-02 | NetServer-Adapter parst `` → RawTenderRecord[] | unit (Fixture) | `pnpm --filter @tessera/api test netserver.adapter` | ❌ Wave 0 |
+| INGEST-03 | cosinex-Adapter parst Trefferliste | unit (Fixture) | `pnpm --filter @tessera/api test cosinex.adapter` | ❌ Wave 0 |
+| SCHEMA-03 | getTender liefert sources[] | integration | `pnpm --filter @tessera/api test tenders.controller` | ⚠️ erweitern |
+
+### Sampling Rate
+- **Per task commit:** `pnpm --filter @tessera/api test src/tenders`
+- **Per wave merge:** `pnpm --filter @tessera/api test`
+- **Phase gate:** Volle Suite grün + Backfill-Migration lokal gegen `tessera`-DB verifiziert.
+
+### Wave 0 Gaps
+- [ ] `source-registry.spec.ts` — INGEST-07 Denylist-Refusal
+- [ ] `tender-fingerprint.spec.ts` — SCHEMA-03 NULL-Toleranz + Kollisions-Fälle
+- [ ] `tender-dedup.service.spec.ts` — D-04/D-05 (inert bei 1 Quelle, Merge bei 2)
+- [ ] `netserver.adapter.spec.ts` + HTML-Fixture (live-gecapturte Trefferliste)
+- [ ] `cosinex.adapter.spec.ts` + HTML-Fixture
+- [ ] Fixtures: `__fixtures__/netserver-search.html`, `__fixtures__/cosinex-search.html` (live speichern für stabile Tests)
+
+## Security Domain
+
+### Applicable ASVS Categories
+| ASVS Category | Applies | Standard Control |
+|---------------|---------|-----------------|
+| V5 Input Validation | yes | Extern gefetchtes HTML ist untrusted — Parser-Ausgabe validieren/coercen (keine Roh-HTML-Ausgabe an Frontend), Titel/Buyer als Text behandeln |
+| V5 SSRF | yes | Portal-Base-URLs sind hardcodierte Konstanten (wie DOE_BASE_URL), NIE aus User-/Admin-Input interpoliert (T-10-06-Muster) |
+| V6 Cryptography | yes (Hash) | `crypto.createHash('sha256')` — kein Selbstbau |
+| V12 Denial of Service | yes | `AbortController`-Timeout pro Fetch (15s, DoeOpenData-Muster); catch-per-source; Rate-Limit-Politeness-Delay (bestehende `politeDelayMs`) |
+| V4 Access Control | yes | Read-Endpoint bleibt `@UseModule('tender-radar')`; Tender global (kein Tenant-Leak) |
+
+### Known Threat Patterns for NestJS/HTML-Scraping
+| Pattern | STRIDE | Standard Mitigation |
+|---------|--------|---------------------|
+| SSRF via konfigurierbare Portal-URL | Tampering/Info-Disclosure | Hardcodierte Portal-Konstanten, kein Admin-URL-Input |
+| Stored XSS via Portal-HTML in Titel/Buyer | Tampering | Frontend rendert als Text (React escaped default), keine `dangerouslySetInnerHTML` |
+| Malformed HTML → DoS/Crash | DoS | try/catch pro Zeile + pro Source, Timeout, `[]`-Fallback |
+| externer Quell-Link | Tampering | `rel="noopener noreferrer"` + `target="_blank"` (bestehendes TenderDetail-Muster) |
+
+## Sources
+
+### Primary (HIGH confidence)
+- Codebase: `doe-opendata.adapter.ts`, `tender-source-adapter.interface.ts`, `tender.types.ts`, `tender-ingestion.service.ts`, `tender-scheduler.service.ts`, `tender-normalizer.service.ts`, `schema.prisma`, `tenders.controller.ts`, `TenderDetail.tsx` — Adapter-Vertrag, Upsert-Pfad, Normalizer, Schema.
+- Live-DB (`tessera`): 2851 DÖE-Tender, 0% NULL OCID, 92.4% NULL Wert, 15.7% NULL Frist, 100% sourceUrl `[VERIFIED 2026-07-23]`.
+- Live-Probes: tender24 NetServer-Suchservlet HTTP 200/161 KB ``; vergabe.landbw meta-refresh→/NetServer; dtvp cosinex `welcome.do` HTTP 200/40 KB; DÖE API HTTP 200 `[VERIFIED 2026-07-23]`.
+
+### Secondary (MEDIUM confidence)
+- `.planning/research/ausschreibungs-portale-feasibility.md` — Plattform-Zuordnung (AI-AG NetServer = 4/8/9; cosinex = DTVP), AGB-Verbote vergabe24/aumass, kein zentrales Feed pro Portal.
+
+### Tertiary (LOW confidence)
+- `node-html-parser`/`cheerio`-Eignung — Trainingswissen, vor Install via Gate verifizieren.
+
+## Metadata
+
+**Confidence breakdown:**
+- Standard Stack: HIGH — Kern nutzt bestehende Deps; nur HTML-Parser neu (gated).
+- Architecture: HIGH — direkt aus bestehendem Ingestion-/Adapter-Muster abgeleitet + CONTEXT-Decisions.
+- Portal-Feasibility: HIGH — live geprüft (Erreichbarkeit, Auth-Freiheit, HTML-Struktur-Indiz); MEDIUM für Selektor-Vollständigkeit/Pagination (best-effort, D-01).
+- Pitfalls: HIGH — codebasiert + live-verifizierte Redirect-/NULL-Fälle.
+
+**Research date:** 2026-07-23
+**Valid until:** 2026-08-22 (Portal-HTML kann sich ändern — Selektoren periodisch prüfen)
+
+