diff --git a/.planning/phases/12-tender-notifications/12-RESEARCH.md b/.planning/phases/12-tender-notifications/12-RESEARCH.md
new file mode 100644
index 0000000..548d4dd
--- /dev/null
+++ b/.planning/phases/12-tender-notifications/12-RESEARCH.md
@@ -0,0 +1,533 @@
+# Phase 12: Tender Notifications — Research
+
+**Researched:** 2026-07-22
+**Domain:** E-Mail-Benachrichtigung über eine bestehende NestJS-Poll/Match-Pipeline (matched-vs-notified-State, per-user Digest-Scheduler, per-profile Sofort-Alert, mandanten-SMTP-Versand)
+**Confidence:** HIGH (alles gegen den Live-Code + Live-DB verifiziert; keine neuen externen Pakete)
+**Sprache:** Prosa Deutsch, Code/Identifier Englisch (response_language: de)
+
+
+## User Constraints (from CONTEXT.md)
+
+### Locked Decisions (verbatim aus 12-CONTEXT.md)
+
+**Digest (NOTIFY-01)**
+- **D-01:** Wählbare Intervalle im Webinterface: **Täglich (Default), Wöchentlich, Aus**. (Stündlich bewusst NICHT angeboten.) „Aus" = kein Digest, Nutzer verlässt sich auf Sofort-Alerts.
+- **D-02:** Digest-Aufbau: **eine einzige Mail pro Nutzer, innerhalb nach Suchprofil gegliedert** (Abschnitte je Profil). Nicht eine Mail pro Profil.
+- **D-03:** Digest-Intervall ist eine **Nutzer-Einstellung** (pro Nutzer, mandantenbewusst), nicht pro Profil. Ein Digest deckt alle Profile des Nutzers ab.
+
+**Sofort-Alert (NOTIFY-02)**
+- **D-04:** Sofort-Alerts sind **pro Suchprofil** aktivierbar, **Default AUS**.
+- **D-05:** Ein Sofort-Alert = eine Mail kurz nach einem neuen Treffer gegen dieses Profil. Mehrere gleichzeitig neu eingelesene Treffer desselben Profils dürfen zu einer Sammel-Sofort-Mail gebündelt werden (Claude's Discretion: Einzelmail vs. kurze Bündelung pro Poll-Tick) — aber nie ein Alert je Profil-Treffer-Paar, das schon benachrichtigt wurde.
+
+**Getroffen vs. Benachrichtigt (NOTIFY-03) — Kern-Invariante**
+- **D-06:** Expliziter **matched-vs-notified-State**. Ein Treffer (Tender × Suchprofil) wird als eigener Datensatz erfasst („getroffen"), separat davon der Benachrichtigungsstatus („benachrichtigt via digest/instant"). Ein Tender+Profil-Paar wird **nie zweimal** benachrichtigt — weder Digest **und** Sofort, noch zweimal im selben Kanal.
+- **D-07:** **Rückstau-Unterdrückung beim Anlegen/Aktivieren eines Suchprofils:** vorhandene historische Treffer (der ~2150 Bestands-Ausschreibungen) werden beim ersten Aktivieren als „bereits benachrichtigt"/unterdrückt markiert. Nur ab-jetzt-neue Treffer lösen Benachrichtigungen aus.
+
+**Versand (NOTIFY-04)**
+- **D-08:** E-Mail-Versand über die **mandantenspezifische SMTP-Konfiguration** (`SettingsService`/`SmtpConfig` + `MailModule`, exakt wie `DkvMailService`), NICHT über einen globalen System-Mailer.
+
+### Claude's Discretion (verbatim)
+- Matching-Mechanismus: bevorzugt Wiederverwendung der Phase-11-`tender-query.builder.ts`-`where`-Logik (Profil-`filters`-JSON → Prisma-where), ausgelöst am Ende des Ingestion-Poll-Ticks (`TenderIngestionService.pollDueSources`) oder als separater Match-Scheduler. Match-Granularität, Batch-Größe, Performance.
+- Datenmodell: neue Tabelle(n) für Treffer + Benachrichtigungsstatus (z.B. `TenderMatch` mit `notifiedDigestAt`/`notifiedInstantAt` oder getrennte notification-log-Tabelle) — Namen, Unique-Constraints (Tender×Profil), Scoping (`where:{userId}` wie Phase-11-Triage, kein RLS/forTenant).
+- Digest-Scheduler (`@nestjs/schedule`), Zeitpunkt-Berechnung (täglich/wöchentlich), Sofort-Alert-Auslösung am Poll-Tick.
+- E-Mail-Templating (HTML/Text) — schlicht, hardcodiertes Deutsch (i18n = Phase 14).
+- Web-UI: Digest-Intervall-Einstellung (pro Nutzer) + Sofort-Alert-Toggle pro Profil (erweitert die Phase-11-`SavedSearchBar`/Settings).
+
+### Deferred Ideas (OUT OF SCOPE)
+- Scraping-Adapter + Cross-Source-Dedup (Phase 13), RSS/E-Mail-Quellen-Ingestion + Admin-Quellen-UI + i18n-Rollout + ausgeschlossene-Portale-UI (Phase 14).
+- Stündlicher Digest (D-01). In-App-/Push-/Webhook-Benachrichtigungen — nur E-Mail.
+
+
+
+## Phase Requirements
+
+| ID | Beschreibung | Research Support |
+|----|-------------|------------------|
+| NOTIFY-01 | Periodischer E-Mail-Digest, Intervall im Webinterface konfigurierbar | Pattern D (Digest-Scheduler, ein globaler Cron), Datenmodell `TenderNotificationPref` (per-user `digestInterval`), UI erweitert Settings-Page |
+| NOTIFY-02 | Optionaler Sofort-Alert pro aktivem Suchprofil, im Webinterface aktivierbar | Pattern E (Instant am Poll-Tick), Schema `TenderSavedSearch.instantAlert Boolean`, UI-Toggle in `SavedSearchBar` |
+| NOTIFY-03 | „getroffen" vs. „benachrichtigt"; kein Doppelversand, kein Rückstau-Massenversand | Pattern B (`TenderMatch` + `notifiedAt`-Invariante) + Pattern C (delta-only Matching = strukturelle Rückstau-Vermeidung) |
+| NOTIFY-04 | Versand über mandantenspezifische SMTP-Konfig (DKV-Muster) | Pattern F (`TenderMailService` als 1:1-Kopie des `DkvMailService`-Musters, `getDecryptedSmtpConfig(tenantId)`) |
+
+
+## Summary
+
+Phase 12 baut keine neue Infrastruktur — sie verdrahtet drei bereits fertige Bausteine: (1) die Phase-11-Filterlogik `buildTenderWhere(filters)`, (2) den Phase-10-Poll-Tick `TenderIngestionService.pollDueSources`, und (3) den DKV-Mandanten-SMTP-Versandpfad (`DkvMailService` → `SettingsService.getDecryptedSmtpConfig(tenantId)` → frischer `nodemailer`-Transport pro Send). Der gesamte fachliche Kern reduziert sich auf **ein neues Datenmodell** (`TenderMatch` mit einem `notifiedAt`-Zustand) plus zwei Auslöser (Match am Poll-Tick, Digest per globalem Cron). Es werden **keine neuen npm-Pakete** benötigt.
+
+Die Kern-Invariante von NOTIFY-03 („nie zweimal, kein Rückstau-Flut") lässt sich am elegantesten **strukturell** statt durch nachträgliche Unterdrückung lösen: Wenn das Matching ausschließlich gegen die in *diesem* Poll-Tick **neu angelegten** Tender läuft (delta-only) und die Profil-Erstellung **keinen** historischen Full-Table-Rescan auslöst, kann ein Rückstau-Flut per Konstruktion nicht entstehen — ein neu angelegtes Profil sammelt Treffer erst ab dem nächsten Tick. Damit ist D-07 („nur ab jetzt", identisch zur Phase-10-Day-Cursor-Philosophie) ohne explizite „suppressed rows"-Backfilltabelle erfüllt. Der Doppelversand-Schutz ist ein einzelnes nullable `notifiedAt` je (tender × savedSearch)-Paar: ist es gesetzt (egal ob durch Instant oder Digest), wird das Paar nie wieder benachrichtigt.
+
+Der wichtigste Fallstrick ist der Multi-Tenant-Scheduler: Der Digest-Cron muss — wie der Phase-10-`TenderSchedulerService` und **entgegen** dem `DkvSchedulerService` (dokumentiert als „v1 single-tenant, `findFirst()`") — ein **einziger globaler Job** sein, der per `findMany` über *alle* fälligen Nutzer aller Mandanten iteriert und je Nutzer über dessen `tenantId` den korrekten SMTP-Transport auflöst. Ein per-Tenant-Cron oder ein `findFirst` würde nur den ersten Mandanten bedienen.
+
+**Primary recommendation:** Neue Tabelle `TenderMatch` (`@@unique([tenderId, savedSearchId])`, denormalisiert `userId`+`tenantId`, ein `notifiedAt DateTime?` + `notifiedChannel String?`) + `TenderSavedSearch.instantAlert Boolean @default(false)` + neue Tabelle `TenderNotificationPref` (per-user `digestInterval`). Matching **delta-only** am Ende von `pollDueSources` (nur neu angelegte Tender-IDs dieses Ticks). Instant im selben Tick, Digest als **ein** globaler `@nestjs/schedule`-Cron. Versand via neuem `TenderMailService` (1:1-Klon des `DkvMailService`-Musters).
+
+## Architectural Responsibility Map
+
+| Capability | Primary Tier | Secondary Tier | Rationale |
+|------------|-------------|----------------|-----------|
+| Match-Erzeugung (Tender × Profil) | API / Backend (`TenderMatchingService`) | Database (`TenderMatch` upsert) | Reine Serverlogik am Poll-Tick; Postgres macht die Filterarbeit (indexierte `where`) |
+| matched-vs-notified-State | Database (`TenderMatch.notifiedAt`) | API (Invariantenprüfung) | Der Zustand IST die Zeile; die Invariante ist ein `WHERE notifiedAt IS NULL`-Gate |
+| Rückstau-Unterdrückung (D-07) | API (delta-only Matching-Grenze) | — | Strukturell: Matching sieht nur Neu-diesen-Tick-Tender; kein Historien-Rescan |
+| Digest-Terminierung | API (`@nestjs/schedule`, ein globaler Cron) | Database (`TenderNotificationPref`) | Poll-once-fan-out-many-Muster (Phase 10), kein per-Tenant-Job |
+| Sofort-Alert-Auslösung | API (`pollDueSources`-Ende) | — | Synchron im Tick, direkt nach Match-Erzeugung (DKV-Direktaufruf-Stil) |
+| SMTP-Versand | API (`TenderMailService`) | Settings/Crypto (`getDecryptedSmtpConfig`) | Mandanten-SMTP, frischer Transport pro Send (Pitfall-3-Mitigation) |
+| Digest-Intervall-UI / Instant-Toggle | Frontend (Next.js, native `fetch`) | API (neue REST-Routen) | Erweitert Phase-11-`SavedSearchBar` + Settings-Page |
+
+## Standard Stack
+
+### Core (alles bereits im Projekt installiert — verifiziert gegen `apps/api/package.json`)
+| Library | Version (installiert) | Purpose | Why Standard |
+|---------|---------|---------|--------------|
+| `nodemailer` | `^9.0.1` | SMTP-Versand, frischer Transport pro Send | Bereits vom `DkvMailService` genutzt; exakt dasselbe Muster [VERIFIED: apps/api/package.json] |
+| `@nestjs/schedule` | `^6.1.3` | Digest-Cron via `SchedulerRegistry` | Bereits vom `TenderSchedulerService`/`DkvSchedulerService` genutzt [VERIFIED: apps/api/package.json] |
+| `cron` | `4.4.0` | CronJob-Klasse (transitive Peer, via `require('cron')`) | Exakt der Phase-10-Resolution-Workaround, verbatim wiederverwenden [VERIFIED: tender-scheduler.service.ts:14] |
+| `@prisma/client` / `prisma` | `^6.0.0` | ORM, neue Modelle + Migration | Handgeschriebene timestamped Migration wie alle Phase-10/11-Migrationen [VERIFIED: apps/api/package.json] |
+
+> Hinweis: CLAUDE.md nennt aspirativ Prisma 7.8.x / Next.js 16; **installiert und maßgeblich** ist Prisma `^6.0.0`. Der Planner MUSS gegen die installierte Version planen, nicht gegen die CLAUDE.md-Wunschversion. [VERIFIED: apps/api/package.json]
+
+### Supporting (bereits vorhanden, wiederverwenden)
+| Asset | Purpose | When to Use |
+|---------|---------|-------------|
+| `SettingsService.getDecryptedSmtpConfig(tenantId)` | Entschlüsselte SMTP-Konfig pro Mandant | Im `TenderMailService` pro Send [VERIFIED: settings.service.ts:88] |
+| `CalendarCryptoService` | AES-256-GCM decrypt (via `CalendarModule.exports`) | Transitiv über `SettingsService` — nicht direkt nötig [VERIFIED: calendar/crypto.service.ts] |
+| `buildTenderWhere(dto)` | Profil-`filters` → Prisma-`where` | Im Matching, `filters`-JSON als `TenderQueryDto` casten [VERIFIED: tender-query.builder.ts:35] |
+
+### Alternatives Considered
+| Instead of | Could Use | Tradeoff |
+|------------|-----------|----------|
+| Ein `notifiedAt`-Feld | Zwei Felder `notifiedDigestAt`/`notifiedInstantAt` | Zwei Felder machen die Invariante SCHWERER (man muss beide auf NULL prüfen) und suggerieren fälschlich, ein Paar dürfe zweimal (je Kanal) benachrichtigt werden — was D-06 explizit verbietet. Ein einzelnes `notifiedAt` + `notifiedChannel` erfüllt D-06 einfacher. Siehe Pattern B. |
+| Globaler `MailService`/`@nestjs-modules/mailer` | — | Verboten durch D-08: das ist der System-Mailer (Passwort-Reset), fester Startup-Transport, kein Mandanten-SMTP [VERIFIED: mail.module.ts] |
+| Backfill via „suppressed rows" bei Profil-Erstellung | delta-only Matching (kein Backfill) | Empfohlen: delta-only vermeidet ~2188 × N suppressed rows und erfüllt D-07 strukturell. Siehe Pattern C. |
+
+**Installation:** Keine. Es werden keine neuen Pakete installiert.
+
+## Package Legitimacy Audit
+
+> Diese Phase installiert **keine** externen Pakete — alle benötigten Libraries (`nodemailer`, `@nestjs/schedule`, `cron`, `prisma`) sind bereits Bestand von `apps/api/package.json` und wurden in Phase 7/10 legitimiert.
+
+| Package | Registry | Disposition |
+|---------|----------|-------------|
+| — (keine Neuinstallation) | — | N/A |
+
+**Packages removed due to [SLOP] verdict:** none
+**Packages flagged as suspicious [SUS]:** none
+
+## Architecture Patterns
+
+### System Architecture Diagram
+
+```
+[Cron-Tick: TenderSchedulerService] (bestehend, unverändert)
+ │
+ ▼
+[TenderIngestionService.pollDueSources] ── ERWEITERN: neue Tender-IDs dieses Ticks sammeln
+ │ (Tender.upsert-Schleife; sammelt IDs der NEU angelegten Rows)
+ ▼
+[TenderMatchingService.matchDelta(newTenderIds)] ← NEU
+ │ für jedes TenderSavedSearch: buildTenderWhere(filters) AND id IN (newTenderIds)
+ ▼
+[prisma.tenderMatch.upsert(@@unique(tenderId,savedSearchId))] notifiedAt = null
+ │
+ ├───────────────► [Instant] Profile mit instantAlert=true:
+ │ pro Profil eine Sammel-Mail (D-05) → notifiedAt=now, channel='instant'
+ │
+ ▼ (asynchron, entkoppelt)
+[Digest-Cron (EIN globaler @nestjs/schedule-Job)] ← NEU
+ │ findMany über ALLE fälligen Nutzer (daily immer / weekly montags), alle Mandanten
+ │ je Nutzer: TenderMatch WHERE userId=? AND notifiedAt IS NULL, gruppiert nach Profil
+ ▼
+[TenderMailService.sendDigest / sendInstant] ← NEU (Klon des DkvMailService-Musters)
+ │ getDecryptedSmtpConfig(user.tenantId) → nodemailer.createTransport() pro Send → close()
+ ▼
+[Mandanten-SMTP-Relay] danach: notifiedAt=now, channel='digest' auf allen versendeten Matches
+```
+
+### Recommended Project Structure
+```
+apps/api/src/tenders/
+├── tender-matching.service.ts # NEU: matchDelta(newTenderIds), Instant-Dispatch
+├── tender-mail.service.ts # NEU: sendInstant/sendDigest (DkvMailService-Klon)
+├── tender-digest.scheduler.ts # NEU: EIN globaler Cron, findMany über fällige Nutzer
+├── tender-notification-pref.service.ts # NEU: per-user digestInterval CRUD
+├── dto/notification-pref.dto.ts # NEU
+├── tender-ingestion.service.ts # ERWEITERN: neue Tender-IDs sammeln + matchDelta aufrufen
+├── tender-saved-search.service.ts # ERWEITERN: instantAlert in create/update
+└── tenders.controller.ts # ERWEITERN: Routen für pref + instant-toggle
+
+apps/api/prisma/migrations/
+└── 2026072x000000_add_tender_notifications/ # TenderMatch, TenderNotificationPref, instantAlert
+
+apps/web/src/app/(portal)/modules/tender-radar/
+├── components/SavedSearchBar.tsx # ERWEITERN: Instant-Toggle pro Profil
+├── settings/page.tsx # ERWEITERN: Digest-Intervall-Auswahl (per-user)
+└── (lib/tender-radar-api.ts) # ERWEITERN: neue API-Funktionen
+```
+
+### Pattern A: Schema — `TenderMatch` + per-user Pref + per-profile Flag
+**What:** Drei Schema-Änderungen, alle nach dem Phase-11-Scoping-Muster (userId-Scoping im Service, KEIN `forTenant()`/RLS; `tenantId` denormalisiert mitgeführt für spätere Isolation + SMTP-Auflösung).
+
+```prisma
+// NEU — der „getroffen"-Datensatz (D-06). Ein Row je (tender × savedSearch).
+model TenderMatch {
+ id String @id @default(uuid())
+ tenderId String
+ savedSearchId String
+ userId String // denormalisiert (Phase-11-Muster: direktes where:{userId})
+ tenantId String // denormalisiert — für SMTP-Auflösung im Digest-Cron
+ matchedAt DateTime @default(now())
+ notifiedAt DateTime? // NULL = noch nicht benachrichtigt (das Eligibility-Gate)
+ notifiedChannel String? // 'digest' | 'instant' — nur Audit, NICHT Teil der Invariante
+ tender Tender @relation(fields: [tenderId], references: [id], onDelete: Cascade)
+ savedSearch TenderSavedSearch @relation(fields: [savedSearchId], references: [id], onDelete: Cascade)
+
+ @@unique([tenderId, savedSearchId]) // Upsert-Target — ein Match je Paar, idempotent
+ @@index([userId]) // Digest-Query: where userId, notifiedAt null
+ @@index([notifiedAt])
+}
+
+// NEU — Digest-Intervall ist per-USER (D-03), nicht per-Profil.
+model TenderNotificationPref {
+ id String @id @default(uuid())
+ userId String @unique // ein Pref-Row je Nutzer
+ tenantId String
+ digestInterval String @default("daily") // 'daily' | 'weekly' | 'off' (D-01)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@index([userId])
+}
+
+// ERWEITERN TenderSavedSearch (D-04): Sofort-Alert pro Profil, Default AUS.
+model TenderSavedSearch {
+ // ... bestehende Felder ...
+ instantAlert Boolean @default(false)
+ matches TenderMatch[] // Gegenrelation
+}
+```
+**When to use:** immer. `Tender` bekommt zusätzlich `triage TenderTriage[]` schon; jetzt zusätzlich `matches TenderMatch[]` als Gegenrelation.
+**Rationale `notifiedAt` (ein Feld):** Das Eligibility-Gate ist `notifiedAt IS NULL`. Sobald *irgendein* Kanal gesendet hat, ist das Feld gesetzt → das Paar ist raus. Das erfüllt D-06 („nie zweimal — weder Digest UND Sofort, noch zweimal im selben Kanal") direkt. `notifiedChannel` ist nur Audit.
+
+### Pattern B: matched-vs-notified-Invariante (NOTIFY-03 Kern)
+**What:** Die Nicht-Doppelversand-Garantie ist **ein** Prädikat: `WHERE notifiedAt IS NULL`.
+- Match-Erzeugung: `upsert` auf `@@unique([tenderId, savedSearchId])`, `update: {}` (idempotent — bestehendes `notifiedAt` bleibt unangetastet bei Re-Match).
+- Instant sendet → setzt `notifiedAt=now, channel='instant'`.
+- Digest selektiert nur `notifiedAt IS NULL` → sieht ein instant-versendetes Paar nie → kein Doppelversand.
+- Reihenfolge garantiert Korrektheit: Instant läuft synchron im Poll-Tick, Digest läuft später (täglich) → Instant setzt `notifiedAt` immer VOR dem Digest-Lauf.
+
+**Example (Match-Upsert, idempotent):**
+```typescript
+// tender-matching.service.ts
+await this.prisma.tenderMatch.upsert({
+ where: { tenderId_savedSearchId: { tenderId, savedSearchId: search.id } },
+ create: {
+ tenderId, savedSearchId: search.id,
+ userId: search.userId, tenantId: search.tenantId,
+ // notifiedAt bleibt null → wird vom nächsten Digest/Instant abgeholt
+ },
+ update: {}, // Re-Match ändert notifiedAt NICHT → keine Wieder-Benachrichtigung
+});
+```
+**Warum das sicher ist:** Selbst wenn matchDelta versehentlich ein bereits gematchtes Paar erneut sieht, bewahrt `update: {}` das gesetzte `notifiedAt`. Die Invariante hält strukturell, nicht per Konvention.
+
+### Pattern C: Rückstau-Unterdrückung = delta-only Matching (D-07)
+**What:** Matching läuft **ausschließlich** gegen die in *diesem* Tick **neu angelegten** Tender — nie gegen die volle Tabelle. Profil-Erstellung löst **keinen** Historien-Rescan aus.
+
+**Konsequenz:** Ein neu angelegtes Profil kann per Konstruktion keine Flut erzeugen — die ~2188 Bestands-Tender wurden in vergangenen Ticks eingelesen und werden nie wieder an matchDelta übergeben. Das Profil sammelt Treffer erst ab dem nächsten Poll-Tick. Damit ist D-07 („nur ab jetzt", konsistent mit Phase-10-D-01 + Day-Cursor-Gate) erfüllt, OHNE ~2188 × N „suppressed rows" schreiben zu müssen.
+
+**Wie „neu diesen Tick" ermittelt wird:** `pollDueSources` muss erweitert werden, um die IDs der **neu angelegten** Tender zu sammeln. Der bestehende `upsert` meldet nicht create-vs-update. Empfohlener Ansatz — Vorab-Check in der Schleife:
+```typescript
+// in der bestehenden Schleife von pollDueSources, VOR dem upsert:
+const existing = await this.prisma.tender.findUnique({
+ where: { dedupKey: tender.dedupKey }, select: { id: true },
+});
+// ... upsert wie gehabt ...
+const saved = await this.prisma.tender.upsert({ /* ... */ });
+if (!existing) newTenderIds.push(saved.id); // nur genuin NEUE Rows
+```
+Ein indexierter `findUnique` je Record (Dutzende/Tag bei DÖE) ist vernachlässigbar. Am Tick-Ende: `await this.matching.matchDelta(newTenderIds)`.
+
+**MVP-Vereinfachung:** Nur genuin **neue** Tender lösen Matching/Benachrichtigung aus — `contentHash`-Änderungen (Fristverlängerung etc.) werden für Phase 12 **nicht** als Re-Benachrichtigung behandelt. Das sidesteppt die „Notice aktualisiert"-Frage, die CONTEXT nicht fordert. (Offene Frage 1.)
+
+**Trade-off / bewusst akzeptiert:** Ein Profil, das nach dem Einlesen eines passenden Tenders angelegt wird, „verpasst" diesen Tender in der Benachrichtigung — er ist aber weiterhin normal in der Phase-11-Trefferliste sichtbar. Das ist exakt das gewünschte „nur ab jetzt"-Verhalten, kein Bug.
+
+### Pattern D: Digest-Scheduler — EIN globaler Cron (NOTIFY-01)
+**What:** Genau **ein** platform-weiter `@nestjs/schedule`-Cron (kein per-Tenant-Job), der nach dem Phase-10-`TenderSchedulerService`-Muster registriert wird (nicht dem `DkvSchedulerService`-`findFirst`-Muster).
+
+- Läuft z.B. täglich 07:00 Europe/Berlin (`0 7 * * *`).
+- Selektiert fällige Nutzer per **`findMany`** (nicht `findFirst`!): `digestInterval='daily'` immer, `digestInterval='weekly'` nur an einem festen Wochentag (z.B. Montag).
+- Je Nutzer: `TenderMatch WHERE userId=? AND notifiedAt IS NULL`, nach `savedSearchId` gruppiert (D-02: eine Mail, Abschnitte je Profil).
+- Sendet **eine** Mail via `TenderMailService.sendDigest`, dann `updateMany` `notifiedAt=now, channel='digest'` auf alle einbezogenen Match-IDs.
+
+```typescript
+// tender-digest.scheduler.ts — Fälligkeits-Selektion (Kern)
+const weekday = new Date().toLocaleDateString('en-US',
+ { timeZone: 'Europe/Berlin', weekday: 'short' }); // 'Mon'...
+const dueUsers = await this.prisma.tenderNotificationPref.findMany({
+ where: {
+ OR: [
+ { digestInterval: 'daily' },
+ ...(weekday === 'Mon' ? [{ digestInterval: 'weekly' }] : []),
+ ],
+ },
+});
+// KEIN findFirst — sonst nur der erste Mandant (DkvScheduler-Pitfall).
+```
+**Multi-Tenant-Sicherheit:** Der Cron ist ein Singleton-Job; er iteriert Nutzer *aller* Mandanten und löst pro Nutzer über dessen `tenantId` den SMTP-Transport auf. Kein per-Tenant-Cron, kein `findFirst`.
+
+### Pattern E: Sofort-Alerts (NOTIFY-02)
+**What:** Direkt nach `matchDelta` im selben Poll-Tick: für Profile mit `instantAlert=true` die in diesem Tick neu erzeugten (noch `notifiedAt IS NULL`) Matches je Profil zu **einer** Sammel-Mail bündeln (D-05), senden, `notifiedAt=now, channel='instant'` setzen.
+
+```typescript
+// tender-matching.service.ts (Ende von matchDelta)
+const instantSearches = savedSearches.filter((s) => s.instantAlert);
+for (const search of instantSearches) {
+ const fresh = await this.prisma.tenderMatch.findMany({
+ where: { savedSearchId: search.id, notifiedAt: null,
+ tenderId: { in: newTenderIds } },
+ include: { tender: true },
+ });
+ if (!fresh.length) continue;
+ await this.mail.sendInstant(search, fresh.map((m) => m.tender)); // eine Sammel-Mail
+ await this.prisma.tenderMatch.updateMany({
+ where: { id: { in: fresh.map((m) => m.id) } },
+ data: { notifiedAt: new Date(), notifiedChannel: 'instant' },
+ });
+}
+```
+**Wichtig:** Instant läuft synchron (DKV-Direktaufruf-Stil, kein Message-Broker im Stack). Ein Fehlschlag darf den Tick nicht crashen — catch-and-log pro Profil; bei Sendefehler `notifiedAt` NICHT setzen → Retry beim nächsten Tick/Digest.
+
+### Pattern F: Mandanten-SMTP-Versand (NOTIFY-04)
+**What:** `TenderMailService` ist ein 1:1-Klon des `DkvMailService`-Musters:
+```typescript
+const smtp = await this.settingsService.getDecryptedSmtpConfig(tenantId);
+if (!smtp) { /* log + skip; notifiedAt NICHT setzen → Retry */ return; }
+const transport = nodemailer.createTransport({
+ host: smtp.host, port: smtp.port,
+ secure: smtp.encryption === 'ssl-tls',
+ requireTLS: smtp.encryption === 'starttls',
+ auth: smtp.username ? { user: smtp.username, pass: smtp.decryptedPassword ?? '' } : undefined,
+});
+try { await transport.sendMail({ from: smtp.fromAddress, to: userEmail, subject, text, html }); }
+finally { transport.close(); } // WR-01: Pool freigeben, sonst Socket-Leck bei Retry
+```
+- `userEmail` kommt aus `User.email` (verifiziert vorhanden, `@unique`) — im Digest-Cron per `include`/join vom Pref/Match zum User laden.
+- **Templating:** schlichtes Deutsch, hardcodiert (i18n = Phase 14). Text + optional HTML. Digest sektioniert nach Profil (D-02): je Profil Überschrift + Liste (Titel, Auftraggeber, Frist, geschätzter Wert, `sourceUrl`-Link). `estimatedValue` ist ein String und zu 91,6 % null — nie ungeprüft `Number()`-coercen (Phase-11-Hinweis).
+- **Kein globaler Mailer:** `MailService`/`@nestjs-modules/mailer` bleibt unangetastet (System-Mails only, D-08).
+
+### Anti-Patterns to Avoid
+- **`findFirst` im Digest-Cron** → bedient nur den ersten Mandanten (exakt der dokumentierte `DkvSchedulerService`-v1-Gap). Immer `findMany` über alle fälligen Nutzer.
+- **Per-Tenant-Cron-Job für Digest** → unnötig; ein globaler Job iteriert Nutzer intern. (Per-Tenant-Cron ist erst in Phase 14 für Email-Alert-Inbox nötig.)
+- **Historischer Full-Table-Rescan bei Profil-Erstellung** → Rückstau-Flut (D-07-Verstoß). Delta-only Matching.
+- **`where: { tenantId }` auf `Tender`/Match-Reads by habit** → `Tender` ist global (D-03); Match-Scoping läuft über `userId`.
+- **Zwei `notifiedAt`-Spalten** → verkompliziert die Invariante; ein Feld genügt.
+- **`nodemailer`-Transport auf Modul-Startup cachen** → Admin-SMTP-Änderung greift erst nach Restart (Pitfall 3). Frischer Transport pro Send + `transport.close()`.
+
+## Don't Hand-Roll
+
+| Problem | Don't Build | Use Instead | Why |
+|---------|-------------|-------------|-----|
+| Filter-`where` aus Profil-`filters` | Neue Match-Filterlogik | `buildTenderWhere(filters as TenderQueryDto)` | Phase-11-Logik ist getestet, deckt alle FILTER-Kriterien + null-Handling ab [VERIFIED: tender-query.builder.ts] |
+| SMTP-Transport + Entschlüsselung | Eigener Mailer | `DkvMailService`-Muster + `getDecryptedSmtpConfig` | Verschlüsselung, Transport-Lifecycle, Fehler-Handling gelöst [VERIFIED: dkv-mail.service.ts] |
+| Dynamischer Cron | `@Cron()`-Dekorator (statisch) | `SchedulerRegistry.addCronJob` (Phase-10-Muster) | Intervall/Registrierung zur Laufzeit, kein Restart [VERIFIED: tender-scheduler.service.ts] |
+| Doppelversand-Dedup | Eigene Sent-Log-Tabelle + Cross-Check | Ein `notifiedAt`-Feld + `@@unique` | Die Unique-Constraint + Nullable-Feld IST das Dedup |
+| Credential-Entschlüsselung | Eigenes AES | `CalendarCryptoService` (transitiv via SettingsService) | Bereits im Versandpfad [VERIFIED: settings.service.ts:105] |
+
+**Key insight:** Diese Phase ist fast reine Verdrahtung. Jeder „neue" Baustein hat ein exaktes Vorbild im Repo (`DkvMailService`, `TenderSchedulerService`, `TenderTriageService`-Scoping, `buildTenderWhere`). Abweichungen vom Vorbild sind fast immer ein Bug.
+
+## Common Pitfalls
+
+### Pitfall 1: Multi-Tenant-Digest via `findFirst`
+**What goes wrong:** Nur der erste Mandant erhält Digests; alle anderen Nutzer bekommen nie eine Mail.
+**Why it happens:** Copy-Paste aus `DkvSchedulerService` (dort explizit als „v1 single-tenant, `findFirst()`" dokumentiert).
+**How to avoid:** Ein globaler Cron + `prisma.tenderNotificationPref.findMany(...)` über alle fälligen Nutzer; SMTP je Nutzer über dessen `tenantId`.
+**Warning signs:** Test „zwei Nutzer in zwei Mandanten, beide daily" → nur einer bekommt Mail.
+
+### Pitfall 2: Rückstau-Flut bei Profil-Erstellung
+**What goes wrong:** Neues Profil matcht sofort ~2188 Bestands-Tender → 2188 Instant-Mails oder ein Riesen-Digest.
+**Why it happens:** Matching gegen die volle Tabelle statt gegen das Tick-Delta; oder Backfill-on-create ohne Suppression.
+**How to avoid:** delta-only Matching (Pattern C); Profil-Erstellung triggert kein Matching.
+**Warning signs:** Test „Profil anlegen bei 2188 vorhandenen Tendern" → erzeugt >0 `notifiedAt=null`-Matches. Erwartung: 0.
+
+### Pitfall 3: Doppelversand Instant + Digest
+**What goes wrong:** Ein Paar wird per Instant UND im Digest verschickt.
+**Why it happens:** Digest-Query filtert nicht auf `notifiedAt IS NULL`, oder Instant setzt `notifiedAt` nicht.
+**How to avoid:** Instant setzt `notifiedAt`+`channel` sofort nach erfolgreichem Send; Digest selektiert strikt `notifiedAt IS NULL`.
+**Warning signs:** Test „Profil instant=on, user digest=daily, ein neuer Match" → genau EINE Mail insgesamt.
+
+### Pitfall 4: SMTP-Transport auf Startup gecacht
+**What goes wrong:** Admin ändert SMTP, Mails gehen weiter über alte Config bis Restart.
+**Why it happens:** `@nestjs-modules/mailer` cached Transport; oder eigener Transport im Konstruktor.
+**How to avoid:** `nodemailer.createTransport()` pro Send + `transport.close()` im `finally` (DKV-Muster).
+**Warning signs:** SMTP-Änderung wirkt erst nach Neustart.
+
+### Pitfall 5: Tender ohne Mandant, Match mit Mandant
+**What goes wrong:** `where: { tenantId }` auf `Tender`-Reads filtert globale Daten für den 2. Mandanten weg; oder Match-Reads ohne `userId`-Scoping (IDOR).
+**Why it happens:** Blindes Kopieren des Standard-Mandanten-Scopings.
+**How to avoid:** `Tender` bleibt global (D-03, kein RLS/forTenant); `TenderMatch`-Reads immer `where:{userId}` (Phase-11-Triage-Muster).
+**Warning signs:** Digest eines Nutzers enthält Matches eines anderen Nutzers/Mandanten.
+
+### Pitfall 6: Fehlender SMTP-Config → Cron-Crash
+**What goes wrong:** Ein Mandant ohne `SmtpConfig` wirft im Digest-Cron, der ganze Tick bricht ab, andere Nutzer bekommen nichts.
+**Why it happens:** `getDecryptedSmtpConfig` liefert null → `DkvMailService`-Muster wirft.
+**How to avoid:** Pro Nutzer catch-and-log; bei fehlender Config skip + `notifiedAt` NICHT setzen (Retry nächster Lauf). Nie den ganzen Cron-Lauf abbrechen.
+**Warning signs:** Ein Mandant ohne SMTP verhindert Mails aller anderen.
+
+## Code Examples
+
+### Match am Poll-Tick verdrahten (Ingestion-Erweiterung)
+```typescript
+// tender-ingestion.service.ts — am Ende von pollDueSources, nach der Fetch-Schleife:
+if (newTenderIds.length) {
+ await this.matching.matchDelta(newTenderIds); // erzeugt Matches + feuert Instant
+}
+```
+
+### Digest-Selektion + eine Mail pro Nutzer (D-02)
+```typescript
+for (const pref of dueUsers) {
+ const matches = await this.prisma.tenderMatch.findMany({
+ where: { userId: pref.userId, notifiedAt: null },
+ include: { tender: true, savedSearch: true },
+ orderBy: { savedSearch: { name: 'asc' } },
+ });
+ if (!matches.length) continue;
+ const user = await this.prisma.user.findUnique({ where: { id: pref.userId } });
+ if (!user) continue;
+ // nach Profil gruppieren → eine sektionierte Mail
+ await this.mail.sendDigest(user, pref.tenantId, groupByProfile(matches));
+ await this.prisma.tenderMatch.updateMany({
+ where: { id: { in: matches.map((m) => m.id) } },
+ data: { notifiedAt: new Date(), notifiedChannel: 'digest' },
+ });
+}
+```
+
+## State of the Art
+
+| Old Approach | Current Approach | Impact |
+|--------------|------------------|--------|
+| ARCHITECTURE.md (pre-Phase-11): `TenderSavedSearch` mit strukturierten Spalten (`keywords[]`, `notifyMode`, `digestHour`) | Phase 11 shippte `filters Json` + KEINE notify-Felder | Planner MUSS gegen die **echte** Phase-11-Schema-Realität planen: `filters` ist ein JSON-Blob, `notifyMode`/`digestHour` existieren NICHT. Digest-Intervall wandert auf per-user (`TenderNotificationPref`, D-03), Instant auf per-profile (`instantAlert`, D-04) |
+| ARCHITECTURE.md: `TenderMatch.notifiedAt` (singulär) + „last N days" Backfill | Ein `notifiedAt` bleibt korrekt; Backfill durch **delta-only** ersetzt (kein „last N days"-Rescan) | D-07 wird strukturell statt durch Suppression erfüllt |
+
+**Deprecated/outdated:**
+- ARCHITECTURE.md § „Pattern 4 … Backfill against tenders from the last N days": durch delta-only Matching überholt — kein Backfill nötig. Historische Tender bleiben in der UI sichtbar, lösen aber keine Mail aus.
+
+## Assumptions Log
+
+| # | Claim | Section | Risk if Wrong |
+|---|-------|---------|---------------|
+| A1 | Ein täglicher Digest-Cron um 07:00 Europe/Berlin (weekly montags) genügt D-01 | Pattern D | Niedrig — D-01 fordert nur daily/weekly/off, keinen per-hour-Zeitpunkt. Falls Nutzer eine feste Uhrzeit wählen wollen, wird `TenderNotificationPref` um `digestHour` erweitert. |
+| A2 | `contentHash`-Änderungen lösen in Phase 12 KEINE Re-Benachrichtigung aus (nur genuin neue Tender) | Pattern C | Mittel — falls „Notice aktualisiert"-Mails gewünscht sind, muss der Delta zusätzlich geänderte IDs führen. CONTEXT fordert es nicht. |
+| A3 | Digest-Empfänger-Adresse = `User.email` | Pattern F | Niedrig — `User.email` ist `@unique`, verifiziert vorhanden. |
+| A4 | Fehlende Mandanten-SMTP → skip+log, Match bleibt `notifiedAt=null` (Retry) | Pitfall 6 | Niedrig — akzeptables MVP-Verhalten; Alternative wäre ein „dead-letter"-Flag, für MVP unnötig. |
+| A5 | Matching läuft am Ende von `pollDueSources` (kein separater Match-Scheduler) | Pattern C | Niedrig — CONTEXT nennt beides als Discretion; der Poll-Tick ist der natürliche Delta-Auslöser. |
+
+## Open Questions
+
+1. **Re-Benachrichtigung bei Änderung (`contentHash`)?**
+ - Was wir wissen: Phase 10 erkennt Änderungen via `contentHash` (SCHEMA-02), aktualisiert aber `pollDueSources` sammelt aktuell keine geänderten IDs.
+ - Was unklar ist: Soll eine Fristverlängerung/Aufhebung eine „Notice aktualisiert"-Mail auslösen?
+ - Empfehlung: Für MVP **nein** — nur genuin neue Tender benachrichtigen (A2). Beim Discuss/Planning bestätigen.
+
+2. **Feste Digest-Uhrzeit pro Nutzer?**
+ - Was wir wissen: D-01/D-03 fordern nur Intervall (daily/weekly/off), keine Uhrzeit.
+ - Empfehlung: Ein globaler 07:00-Lauf; `digestHour` nur nachrüsten, falls gefordert (A1).
+
+3. **Was passiert mit `notifiedAt=null`-Matches, wenn digestInterval='off' UND instantAlert=false?**
+ - Was wir wissen: Solche Matches würden sich unbegrenzt ansammeln (nie versendet).
+ - Empfehlung: Akzeptabel — sie sind einfach „stiller" State; die UI zeigt Treffer ohnehin. Optional: bei „off" den Match direkt als suppressed markieren. Für MVP: nichts tun (harmlos). Beim Planning entscheiden.
+
+## Environment Availability
+
+| Dependency | Required By | Available | Version | Fallback |
+|------------|------------|-----------|---------|----------|
+| Mandanten-`SmtpConfig` (DB-Row) | NOTIFY-04 Versand | pro Mandant, optional | — | Skip+Log, Match bleibt un-notified (Pitfall 6) |
+| `nodemailer` | Versand | ✓ | `^9.0.1` | — |
+| `@nestjs/schedule` + `cron` | Digest-Cron | ✓ | `^6.1.3` / `4.4.0` | — |
+| PostgreSQL (Migration) | Schema | ✓ | 16 (Docker) | — |
+| SMTP-Relay (Mailhog lokal) | UAT/Test | lokal `localhost:1025` | — | Test-Send prüft nur Transportaufbau |
+
+**Missing dependencies with no fallback:** keine — alle Runtime-Abhängigkeiten vorhanden.
+**Missing dependencies with fallback:** Mandanten-SMTP ist Nutzer-konfiguriert; ohne Config wird sauber übersprungen.
+
+## Validation Architecture
+
+### Test Framework
+| Property | Value |
+|----------|-------|
+| Framework | Vitest 3.x (ESM) [VERIFIED: apps/api/package.json scripts.test = `vitest run`] |
+| Config file | `apps/api/vitest.config.ts` [VERIFIED: existiert] |
+| Quick run command | `pnpm --filter api test -- ` |
+| Full suite command | `pnpm --filter api test` (bzw. Root `pnpm test` = turbo) |
+
+### Phase Requirements → Test Map
+| Req ID | Behavior | Test Type | Automated Command | File Exists? |
+|--------|----------|-----------|-------------------|-------------|
+| NOTIFY-03 | Match-Upsert idempotent, `update:{}` bewahrt `notifiedAt` | unit | `pnpm --filter api test -- tender-matching.service` | ❌ Wave 0 |
+| NOTIFY-03 | delta-only: Profil-Anlage bei 2188 Tendern erzeugt 0 un-notified Matches | unit | `pnpm --filter api test -- tender-matching.service` | ❌ Wave 0 |
+| NOTIFY-03 | instant+digest → genau EINE Mail (kein Doppelversand) | unit | `pnpm --filter api test -- tender-digest.scheduler` | ❌ Wave 0 |
+| NOTIFY-01 | Digest-Fälligkeit: daily immer, weekly nur montags; `findMany` über alle Mandanten | unit | `pnpm --filter api test -- tender-digest.scheduler` | ❌ Wave 0 |
+| NOTIFY-01 | Zwei Nutzer/zwei Mandanten daily → beide erhalten je eine Mail | unit | `pnpm --filter api test -- tender-digest.scheduler` | ❌ Wave 0 |
+| NOTIFY-02 | Instant nur für `instantAlert=true`, Bündelung pro Profil/Tick | unit | `pnpm --filter api test -- tender-matching.service` | ❌ Wave 0 |
+| NOTIFY-04 | `TenderMailService` nutzt `getDecryptedSmtpConfig(tenantId)`, frischer Transport, `close()` | unit (mock nodemailer) | `pnpm --filter api test -- tender-mail.service` | ❌ Wave 0 |
+| NOTIFY-04 | Fehlende SMTP → skip, kein Crash, `notifiedAt` bleibt null | unit | `pnpm --filter api test -- tender-digest.scheduler` | ❌ Wave 0 |
+| NOTIFY-01/02 | UI: Digest-Intervall-Select + Instant-Toggle | component | `pnpm --filter web test -- SavedSearchBar` | teilweise (SavedSearchBar.test.tsx erweitern) |
+
+### Sampling Rate
+- **Per task commit:** betroffene Spec (`pnpm --filter api test -- `)
+- **Per wave merge:** `pnpm --filter api test`
+- **Phase gate:** volle Suite grün (`pnpm test`) vor `/gsd-verify-work`
+
+### Wave 0 Gaps
+- [ ] `tender-matching.service.spec.ts` — matchDelta, delta-only, Idempotenz, Instant-Dispatch (NOTIFY-02/03)
+- [ ] `tender-digest.scheduler.spec.ts` — Fälligkeit, Multi-Tenant `findMany`, kein Doppelversand (NOTIFY-01/03)
+- [ ] `tender-mail.service.spec.ts` — SMTP-Auflösung, frischer Transport, `close()`, skip bei fehlender Config (NOTIFY-04) — `nodemailer` mocken wie im DKV-Test
+- [ ] `tender-notification-pref.service.spec.ts` — per-user CRUD, Default 'daily'
+- [ ] `SavedSearchBar.test.tsx` erweitern — Instant-Toggle; Settings-Page-Test für Digest-Intervall
+
+## Security Domain
+
+### Applicable ASVS Categories
+| ASVS Category | Applies | Standard Control |
+|---------------|---------|-----------------|
+| V2 Authentication | ja | Bestehende Cookie-Auth; alle neuen Routen unter `@UseModule('tender-radar')` |
+| V4 Access Control | **ja (kritisch)** | Alle Match-/Pref-Reads `where:{userId}` (IDOR-Schutz, Phase-11-Muster); `userId`/`tenantId` NUR aus Auth-Context, nie aus Body/Query |
+| V5 Input Validation | ja | `class-validator`-DTOs für `digestInterval` (`@IsIn(['daily','weekly','off'])`) und `instantAlert` (`@IsBoolean`) |
+| V6 Cryptography | ja | SMTP-Passwort bleibt AES-256-GCM (`CalendarCryptoService`); entschlüsselt nur im Send-Scope, nie geloggt |
+| V7 Errors & Logging | ja | Keine SMTP-Credentials/Empfängerdetails in Logs (DKV-Muster T-07-10) |
+
+### Known Threat Patterns for NestJS + Mandanten-SMTP
+| Pattern | STRIDE | Standard Mitigation |
+|---------|--------|---------------------|
+| IDOR: fremde Matches/Prefs lesen/ändern | Elevation/Info Disclosure | `where:{userId}` aus Auth-Context; NotFound statt Forbidden (nicht leaken, ob Ressource existiert) |
+| Cross-Tenant-Leak: Nutzer A erhält Matches von B | Info Disclosure | `userId`-Scoping + korrekte `tenantId`-SMTP-Auflösung im Digest |
+| SMTP-Credential-Leak in Logs | Info Disclosure | Generische Fehlermeldungen, entschlüsseltes Passwort nur im Method-Scope |
+| E-Mail-Injection über Profilname/Titel in Betreff/Body | Tampering | `nodemailer` escaped Header; Body als Text/escaped HTML, keine ungeprüfte HTML-Interpolation von Tender-Titeln |
+| Cron-Crash durch einen defekten Mandanten | DoS | Pro-Nutzer catch-and-log, nie den ganzen Lauf abbrechen (Phase-10-Muster) |
+
+## Sources
+
+### Primary (HIGH confidence — Live-Code + Live-DB, direkt gelesen)
+- `apps/api/src/tenders/tender-query.builder.ts`, `tender-ingestion.service.ts`, `tender-scheduler.service.ts`, `tender-saved-search.service.ts`, `tender-triage.service.ts`, `tenders.controller.ts`, `tenders.module.ts`
+- `apps/api/src/dkv/dkv-mail.service.ts`, `dkv-scheduler.service.ts`, `dkv.module.ts`
+- `apps/api/src/settings/settings.service.ts`, `dto/smtp-config.dto.ts`; `apps/api/src/calendar/crypto.service.ts`; `apps/api/src/mail/mail.module.ts`
+- `apps/api/prisma/schema.prisma` (Tender, TenderTriage, TenderSavedSearch, SmtpConfig, User, Tenant)
+- `apps/web/src/lib/tender-radar-api.ts`, `components/SavedSearchBar.tsx`, `settings/page.tsx`
+- Live-DB (`docker compose exec db psql`): 2188 Tender, 0 TenderSavedSearch, `TenderMatch` existiert nicht
+- `apps/api/package.json` (Versionen), `apps/api/vitest.config.ts`, `.planning/config.json` (nyquist+security = true)
+
+### Secondary (MEDIUM confidence — Milestone-Research, teils durch Phase 11 überholt)
+- `.planning/research/ARCHITECTURE.md` (Pattern 3–6, TenderMatch-Vorschlag) — Schema-Details durch Phase-11-Realität korrigiert (siehe State of the Art)
+- `.planning/research/PITFALLS.md` (Multi-Tenancy-Pitfalls), `.planning/ROADMAP.md` § Phase 12, `.planning/REQUIREMENTS.md` (NOTIFY-01..04)
+
+### Tertiary (LOW confidence)
+- keine
+
+## Metadata
+
+**Confidence breakdown:**
+- Standard stack: HIGH — keine neuen Pakete, alle Versionen aus `package.json` verifiziert
+- Architecture: HIGH — jeder Baustein hat ein direkt gelesenes Repo-Vorbild; Schema gegen Live-DB abgeglichen
+- Pitfalls: HIGH — abgeleitet aus dokumentierten Repo-Kommentaren (`DkvSchedulerService`-findFirst-Gap, Pitfall-3-Transport)
+- Assumptions: MEDIUM — 5 offene Punkte (A1–A5), alle niedrig/mittleres Risiko, im Planning bestätigbar
+
+**Research date:** 2026-07-22
+**Valid until:** 2026-08-21 (stabil — reines Repo-internes Wiring, keine schnelllebigen externen Abhängigkeiten)