diff --git a/.planning/quick/260909-laa-mandantentrennung-etappe-2-bereich-tende/260909-laa-PLAN.md b/.planning/quick/260909-laa-mandantentrennung-etappe-2-bereich-tende/260909-laa-PLAN.md
new file mode 100644
index 0000000..8618f3b
--- /dev/null
+++ b/.planning/quick/260909-laa-mandantentrennung-etappe-2-bereich-tende/260909-laa-PLAN.md
@@ -0,0 +1,750 @@
+---
+phase: quick-260909-laa
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+autonomous: true
+requirements: [WINDOWS-20, ETAPPE-2-TENDERS]
+
+files_modified:
+ - apps/api/scripts/rls-scratch-check.mjs
+ - docs/mandantentrennung-etappe2-fehlerrichtung.md
+ - apps/api/src/tenders/tender-saved-search.service.ts
+ - apps/api/src/tenders/tender-saved-search.service.spec.ts
+ - apps/api/src/tenders/tender-triage.service.ts
+ - apps/api/src/tenders/tender-triage.service.spec.ts
+ - apps/api/src/tenders/tender-notification-pref.service.ts
+ - apps/api/src/tenders/tender-notification-pref.service.spec.ts
+ - apps/api/src/tenders/tender-email-config.service.ts
+ - apps/api/src/tenders/tender-email-config.service.spec.ts
+ - apps/api/src/tenders/tender-rss-feed.service.ts
+ - apps/api/src/tenders/tender-rss-feed.service.spec.ts
+ - apps/api/src/tenders/tenders.controller.ts
+ - apps/api/src/tenders/tenders.controller.spec.ts
+ - apps/api/src/tenders/tender-digest.scheduler.ts
+ - apps/api/src/tenders/tender-digest.scheduler.spec.ts
+ - apps/api/src/tenders/tender-matching.service.ts
+ - apps/api/src/tenders/tender-matching.service.spec.ts
+ - apps/api/src/tenders/tender-notifications.integration.spec.ts
+ - docs/mandantentrennung-zugriffsklassifikation.md
+
+estimate:
+ tokens: 150000
+ raw_tokens: 150000
+ tasks: 3
+ confidence: low
+
+must_haves:
+ truths:
+ - "Jeder Zugriff des Bereichs `tenders`, der auf Rechnung genau eines Mandanten eine Tabelle mit nicht-nullbarem `tenantId` beruehrt, laeuft ueber einen gebundenen Client — die fuenf Nutzer-CRUD-Dienste vollstaendig, die beiden Hintergrunddienste in ihrer Je-Treffer-Haelfte."
+ - "Die zehn Paare des plattformweiten Ausschreibungskatalogs (D-03) und die zwei Fan-out-Adapter bleiben unangetastet, und das Klassifikationsdokument weist sie als BEWUSST ungebunden aus, nicht als offene Arbeit."
+ - "Die Grenze zu WINDOWS #19 (nullbares `tenantId` bei `TenderRssFeedSource`) ist gemessen, nicht angenommen: eine plattformweite Zeile ist unter JEDEM Mandantenkontext unsichtbar und ein gebundenes Einfuegen ohne Mandant wird abgewiesen. Die Policy-Semantik wurde NICHT angefasst."
+ - "Es existiert ein `tenders`-Abschnitt der Kritikschrift, der je umgestelltem Pfad das konkrete Signal nennt UND die zusaetzliche Fehlerform dieses Bereichs abdeckt: ein Benachrichtigungsweg, der nichts liest, sendet nichts — lautlos, nutzersichtbar nur als Ausbleiben."
+ - "Dass die ausgelieferten Policies dieses Bereichs KEINE Benutzerdimension haben — ein Nutzer desselben Mandanten bleibt fuer die Datenbank sichtbar — ist gemessen und festgehalten; die anwendungsseitige `userId`-Filterung bleibt deshalb der einzige Schutz gegen Quer-Lesen zwischen Nutzern und wird nicht entfernt."
+ - "Alle sieben angefassten Testdateien koennen rot werden, wenn eine Fundstelle ungebunden bleibt — nachgewiesen ueber zwei unterscheidbare Clients, nicht behauptet."
+ - "Die uebergreifenden Haelften der beiden Hintergrunddienste sind unveraendert und als Etappe-3-Uebergabe benannt; die Umstellung hat nicht in Etappe 3 hineingegriffen."
+ - "Klassifikationsdokument und `rls-access-inventory.spec.ts` zeigen fuer alle 23 Paare des Bereichs denselben, maschinell gemessenen Stand."
+ - "743+ Tests und die Typpruefung sind gruen, das Wegwerf-Werkzeug meldet alle Pruefungen bestanden; Schema, Migrationen, alle vier Compose-Dateien und beide Beispiel-Umgebungsdateien sind unveraendert; der Schalter bleibt AUS."
+ artifacts:
+ - apps/api/scripts/rls-scratch-check.mjs
+ - docs/mandantentrennung-etappe2-fehlerrichtung.md
+ - apps/api/src/tenders/tender-saved-search.service.ts
+ - apps/api/src/tenders/tender-triage.service.ts
+ - apps/api/src/tenders/tender-notification-pref.service.ts
+ - apps/api/src/tenders/tender-email-config.service.ts
+ - apps/api/src/tenders/tender-rss-feed.service.ts
+ - apps/api/src/tenders/tenders.controller.ts
+ - apps/api/src/tenders/tender-digest.scheduler.ts
+ - apps/api/src/tenders/tender-matching.service.ts
+ - docs/mandantentrennung-zugriffsklassifikation.md
+ key_links:
+ - "gebundener Client <-> die fuenf Policies `tenant_isolation_policy` auf TenderEmailConfig/TenderNotificationPref/TenderRssFeedSource/TenderSavedSearch/TenderTriage, wortgleich aus der ausgelieferten Migration `_rls_remaining_tenant_tables` extrahiert statt im Werkzeug nachgetippt"
+ - "`extractTriageContext` <-> die zehn Steuerungs-Aufrufstellen, die den Mandanten heute wegwerfen und ihn kuenftig durchreichen muessen — die einzige Stelle, an der ein vergessener Parameter den Umbau unvollstaendig macht"
+ - "nullbares `tenantId` von TenderRssFeedSource <-> `listForUser`/`createPlatform`/`remove` — die drei Pfade, die eine Bindung nach dem Scharfschalten strukturell zerstoeren wuerde (WINDOWS #19)"
+ - "gebundener Lesezugriff in der Schleife <-> die fuenf `continue`/`return`-Stellen der beiden Benachrichtigungswege, an denen ein zu kleines Leseergebnis lautlos zu 'nichts senden' wird"
+ - "`@@unique`-Schluessel ohne Mandantendimension (userId, userId_tenderId, tenderId_savedSearchId) <-> gebundenes `upsert` auf eine unsichtbare Zeile — die Stelle, an der aus stillem Ueberschreiben ein harter Fehler wird"
+ - "`rls-access-inventory.spec.ts` <-> Stand-Spalte des Klassifikationsdokuments fuer alle 23 Paare, einschliesslich der zwoelf bewusst ungebundenen"
+---
+
+
+Der Bereich `tenders` ist der dritte Bereich der Etappe 2 — und der erste, der
+mehrheitlich NICHT aus Umbau besteht. Von 23 Paaren sind fuenf umzustellen, zehn
+duerfen nicht angefasst werden, zwei sind bewusste Fan-outs, und sechs zerfallen in
+eine uebergreifende Haelfte (Etappe 3) und eine mandantengebundene Haelfte (hier).
+
+Zweck: Dieser Bereich haelt Geschaeftsgeheimnisse einzelner Nutzer — welche
+Ausschreibungen ein Unternehmen beobachtet, welche es gespeichert, welche es
+verworfen hat, und die Postfach-Zugangsdaten, aus denen es sie speist. Ein
+Quer-Lesen ist hier kein Datenschutzmangel, sondern Wettbewerbsspionage. Dazu kommt
+eine Fehlerform, die die beiden vorherigen Bereiche nicht hatten: zwei
+Benachrichtigungswege, die bei zu kleinem Leseergebnis nicht falsch handeln, sondern
+GAR NICHT — und niemand meldet eine Warnung, die nie ankam.
+
+Ergebnis: Die Kritikschrift bekommt einen `tenders`-Abschnitt samt der neuen,
+lautlosen Fehlerform. Das Messwerkzeug bekommt die fuenf Policies dieses Bereichs
+und drei Messungen, die es bisher nirgends gab: dass die Policies keine
+Benutzerdimension haben, dass eine plattformweite Zeile ohne Mandant unter jedem
+Kontext unsichtbar ist, und wie sich ein gebundenes `upsert` auf eine unsichtbare
+Zeile verhaelt. Fuenf Nutzerdienste und die Je-Treffer-Haelften zweier
+Hintergrunddienste sind gebunden, zwoelf Paare sind nachweislich und begruendet
+NICHT gebunden, und das Klassifikationsdokument weist beides maschinell nach.
+
+Aufgabe 1 fuehrt bewusst, obwohl sie keinen Nutzernutzen liefert: sie ist der
+Durchstich durch die gesamte Kette (ausgelieferte Policy -> Rolle ohne BYPASSRLS ->
+Bindungsmuster -> die drei Sonderfaelle dieses Bereichs) und beantwortet die Fragen,
+auf denen die Umstellung ruht, mit einer Messung statt mit einer Annahme. Erst
+danach wird Dienstcode angefasst.
+
+
+
+@~/.claude/gsd-core/workflows/execute-plan.md
+@~/.claude/gsd-core/templates/summary.md
+
+
+
+@.planning/STATE.md
+@docs/mandantentrennung-zugriffsklassifikation.md
+@docs/mandantentrennung-etappe2-fehlerrichtung.md
+@apps/api/src/prisma/prisma-tenant.extension.ts
+@apps/api/src/prisma/rls-access-inventory.spec.ts
+@apps/api/scripts/rls-scratch-check.mjs
+@apps/api/src/groups/groups.service.ts
+@apps/api/src/groups/groups.service.spec.ts
+@apps/api/prisma/migrations/20260909140000_rls_remaining_tenant_tables/migration.sql
+@apps/api/src/tenders/tender-saved-search.service.ts
+@apps/api/src/tenders/tender-triage.service.ts
+@apps/api/src/tenders/tender-notification-pref.service.ts
+@apps/api/src/tenders/tender-email-config.service.ts
+@apps/api/src/tenders/tender-rss-feed.service.ts
+@apps/api/src/tenders/tenders.controller.ts
+@apps/api/src/tenders/tender-digest.scheduler.ts
+@apps/api/src/tenders/tender-matching.service.ts
+@CLAUDE.md
+
+
+
+
+Alles Folgende wurde am 2026-09-09 zur Planungszeit am lebenden Baum gemessen. Die
+Zahlen und Zeilenangaben aus dem Auftrag waren Hinweise zum Aufschlagen, keine
+Aenderungsvollmacht — jede Fundstelle wurde einzeln aufgeschlagen.
+
+**Ausgangsstand (jetzt gemessen, nicht aus einem Bericht zitiert):**
+
+- `npm --prefix apps/api run test` -> 53 Dateien, **743 Tests**, gruen, 5,19 s.
+- `npm --prefix apps/api run test -- src/tenders` -> 29 Dateien, **378 Tests**, gruen.
+- `npm --prefix apps/api run type-check` -> Rueckgabewert 0.
+- `docker inspect tessera-ctl-db-1 ...` -> 172.19.0.2. **Eine Container-Adresse ist
+ veraenderlich und wird bei der Ausfuehrung neu ermittelt, nicht von hier
+ abgeschrieben.**
+- `TESSERA_SCRATCH_ADMIN_URL="postgresql://tessera:tessera_dev@172.19.0.2:5432/postgres" node apps/api/scripts/rls-scratch-check.mjs`
+ -> "Alle 23 Pruefungen bestanden.", Rueckgabewert 0. Die Lastprobe lief mit
+ 24 Verletzungen von 40 fuer Form (ii) und 0 von 40 fuer Form (iii) — das Fundament
+ ist damit JETZT belegt.
+- `git status` sauber, HEAD `4cf7cea`.
+
+**Die 62 Rohtreffer, in die Treffer hineingesehen.** Die Bereichsuebersicht misst mit
+`grep -ro "this\.prisma\.[a-zA-Z]*"`; `[a-zA-Z]*` erlaubt auch null Zeichen. Gemessen:
+62 Rohtreffer, davon **genau einer kein Modellzugriff** —
+`tender-fingerprint-backfill.service.ts:89`, `this.prisma.$transaction`. Tatsaechliche
+Modellzugriffe: **61**. Die dokumentierte Bereichszahl 62 ist als Rohtrefferzahl
+korrekt, taugt aber wieder nicht als Arbeitsvorrat. (Beim Bereich `groups` waren es
+drei solche Treffer, hier einer — die Korrektur war also noetig und nicht
+uebertragbar.)
+
+**Befund A — dieser Bereich hat KEINE mandantengebundene Transaktion, und das ist
+die Antwort auf den Vorbehalt im Kopf der Erweiterung.**
+`grep -rn '\$transaction(\s*async' apps/api/src --include=*.ts | grep -v spec`
+liefert ausserhalb von `prisma-tenant.extension.ts` **null Treffer** — nach der
+groups-Umstellung gibt es im gesamten Quelltext keine interaktive Transaktion mehr
+ausserhalb des Hilfsmittels selbst. Die einzige Transaktion in `tenders` ist die
+Array-Form in `tender-fingerprint-backfill.service.ts` auf der plattformweiten
+Tabelle `Tender` (D-03) und damit ausserhalb jeder Mandantenbindung. Der
+Kopfkommentar von `prisma-tenant.extension.ts` verlangt woertlich, vor jedem NEUEN
+Fall mit eigener Transaktion erneut zu messen — in diesem Bereich faellt kein
+solcher Fall an. `withTenantTransaction()` wird hier deshalb NICHT gebraucht und
+darf auch nicht eingefuehrt werden: die beiden mehrschrittigen Stellen
+(`saveConfig` liest und schreibt, `createForUser` zaehlt und schreibt) sind HEUTE
+nicht atomar; sie in eine Transaktion zu heben waere eine Verhaltensaenderung
+jenseits dieses Auftrags.
+
+**Befund B — die fuenf umzustellenden Dienste, einzeln aufgeschlagen.** Alle fuenf
+sind Nutzer-CRUD; der Mandant ist an jeder Aufrufstelle bereits bekannt (siehe
+Befund C). Alle betroffenen Tabellen ausser `TenderRssFeedSource` haben ein NICHT
+nullbares `tenantId` (in `apps/api/prisma/schema.prisma` nachgesehen).
+
+| Datei | Modellzugriffe | Methoden ohne heutigen `tenantId`-Parameter |
+|---|---|---|
+| `tender-saved-search.service.ts` | 6 auf `tenderSavedSearch` | `list`, `update`, `remove` |
+| `tender-rss-feed.service.ts` | 5 auf `tenderRssFeedSource` | `listForUser`, `createPlatform`, `remove` |
+| `tender-email-config.service.ts` | 5 auf `tenderEmailConfig` | `getConfigForApi` (2 Zugriffe), `testConnection` |
+| `tender-triage.service.ts` | 3 auf `tenderTriage` | `listForUser`, `favoriteIds` |
+| `tender-notification-pref.service.ts` | 2 auf `tenderNotificationPref` | `getForUser` |
+
+**Befund C — der Mandant liegt an jeder Aufrufstelle bereits vor und wird nur
+weggeworfen.** `TendersController.extractTriageContext(req)` liefert
+`{ userId, tenantId, role }` und wirft 403, wenn eines fehlt. Zehn Aufrufstellen
+destrukturieren heute nur `{ userId }` bzw. `{ userId, role }` und werfen den
+Mandanten weg (Zeilen 178, 267, 323, 346, 385, 460, 506, 540, 555, 575 — zur
+Planungszeit gezaehlt, bei der Ausfuehrung neu aufschlagen). Kein einziger neuer
+Aufloesungsweg ist noetig; es ist ein Durchreichen, kein Umbau der Steuerung.
+
+**Befund D — WINDOWS #19 ist hier keine ferne Sorge, sondern der Grund, warum drei
+RSS-Pfade NICHT binden duerfen.** `TenderRssFeedSource.tenantId` ist nullbar; eine
+plattformweite Quelle (`userId = null`, `tenantId = null`, darunter die geseedete
+`service.bund.de`-Quelle) traegt keinen Mandanten. Die ausgelieferte Policy lautet
+`"tenantId" = current_tenant_id()` und vergleicht `NULL` nie gleich. Daraus folgt
+fuer die drei Pfade, die plattformweite Zeilen beruehren:
+
+- `listForUser` liest `{ OR: [{userId: null}, {userId}] }` — gebunden verschwaenden
+ nach dem Scharfschalten die plattformweiten Quellen fuer JEDEN Mandanten.
+- `createPlatform` schreibt `tenantId = null` — ein gebundenes Einfuegen liefe in
+ die WITH-CHECK-Wirkung derselben Policy.
+- `remove` deckt den Verwaltungsfall ueber `{userId: null}` ab; gebunden koennte
+ niemand mehr eine plattformweite Quelle entfernen. Diesen einen `deleteMany` in
+ zwei Anweisungen zu zerlegen, um die persoenliche Haelfte zu binden, wuerde genau
+ das Pruef-/Nutzungsfenster wieder oeffnen, das der Dateikopf ausdruecklich
+ vermeidet — also nicht tun.
+
+Nur `createForUser` (Zaehler + Anlage, beide ausschliesslich auf persoenlichen
+Zeilen mit gesetztem Mandanten) kann und muss binden. Die Datei endet damit im
+Stand `gemischt`. Das ist die Bestaetigung der Grenze, nicht ihr Ueberschreiten:
+die Policy-Semantik wird NICHT angefasst, keine Migration geschrieben.
+
+**Befund E — die Policies dieses Bereichs haben keine Benutzerdimension.** Alle
+fuenf lauten schlicht `"tenantId" = current_tenant_id()`
+(`20260909140000_rls_remaining_tenant_tables`, wortgleich nachgelesen). Zwei Nutzer
+DESSELBEN Mandanten sind fuereinander damit vollstaendig sichtbar. Der Schutz gegen
+Quer-Lesen zwischen Nutzern — Suchprofile, Triage-Zustand, Postfachanbindung —
+haengt ausschliesslich an der anwendungsseitigen `userId`-Filterung, die alle fuenf
+Dienste heute schon fuehren. Sie darf bei der Umstellung nicht mit dem Argument
+"macht jetzt ohnehin die Datenbank" entfallen. Dieselbe Klasse Befund wie T-JTS-02/
+T-JTS-03 im Bereich `groups`, hier aber mit hoeherem Einsatz, weil es
+Geschaeftsgeheimnisse sind. Zu messen, nicht aus dem Policy-Text zu schliessen.
+
+**Befund F — die Kehrseite der Bindung: `upsert` auf einen Schluessel ohne
+Mandantendimension.** Drei Schreibpfade nutzen `upsert` auf einem `@@unique`, das
+keinen Mandanten enthaelt: `tenderEmailConfig` (`userId @unique`),
+`tenderNotificationPref` (`userId @unique`), `tenderTriage`
+(`@@unique([userId, tenderId])`), dazu `tenderMatch`
+(`@@unique([tenderId, savedSearchId])`) in Aufgabe 3. Ist die vorhandene Zeile unter
+dem gebundenen Kontext unsichtbar (weil ihr denormalisiertes `tenantId` veraltet
+ist — genau der Fall, den der Kopf von `tender-email-config.service.ts` selbst
+benennt: "a user's tenant can in principle change"), faellt `upsert` in den
+Anlage-Zweig und laeuft in die plattformweite Eindeutigkeitsbedingung. Aus einem
+stillen Ueberschreiben wird ein harter Fehler. Das ist als Richtung besser als ein
+Datenleck, aber es muss als verstaendliche Meldung herauskommen und nicht als 500.
+`tender-saved-search.service.ts` fuehrt das Muster bereits vor (P2002 ->
+`ConflictException` mit deutschem Text) — abschreiben statt neu erfinden.
+
+**Befund G — keine Luecke der ldap-Klasse (Aufloesung ueber die Kennung allein), mit
+einer Einschraenkung.** Alle Besitzpruefungen wurden einzeln nachgesehen:
+`tenderSavedSearch.update/remove` lesen zwar ueber `id` allein, pruefen danach aber
+`existing.userId !== userId` und kollabieren Fehlen und Fremdbesitz zu derselben
+404 — das ist das gewuenschte Muster. `tenderRssFeedSource.remove` ist bereits ein
+einziger bedingter `deleteMany` mit der Besitzbedingung in der Datenbank. Triage,
+Praeferenz und Postfach sind ueber `userId` verschluesselt. Es gibt hier also
+KEINE Wiederholung des ldap-Fundes. Die eine Beobachtung, die trotzdem gehoert
+festgehalten zu werden: ein Administrator eines beliebigen Mandanten kann ueber
+`remove` eine PLATTFORMWEITE RSS-Quelle entfernen, die alle Mandanten speist. Das
+ist eine Produkt-/Zustaendigkeitsfrage im Umfeld von WINDOWS #19, KEIN Auftrag
+dieser Aufgabe — festhalten, nicht reparieren.
+
+**Befund H — die Testlage: die zweite Fehlerform, nicht die erste.**
+`grep -rn "forTenant\|prisma-tenant\|vi.mock" apps/api/src/tenders/*.spec.ts`
+liefert fuer alle sieben betroffenen Testdateien **keinen einzigen Treffer auf die
+Erweiterung**. Es gibt also keinen Identitaets-Mock wie bei `ldap` — es gibt gar
+keinen, genau wie bei `groups`. Nach der Umstellung liefe
+`forTenant(this.prisma, tenantId)` gegen einen handgeschriebenen In-Memory-Fake ohne
+`$extends`, und JEDER Test der Datei stuerzte ab: rot aus dem falschen Grund. Alle
+sieben Dateien brauchen den Zwei-Client-Nachweis aus 260909-jts (`__makeBoundClient`
+ueber DEMSELBEN Speicher, `forTenant` gemockt). Die vorhandenen Fakes sind
+wiederverwendbar und werden nicht weggeworfen.
+
+Eine achte Datei ist betroffen, aber anders: `tenders.controller.spec.ts` uebergibt
+ausschliesslich FAKE-Dienste (`makeFakeTriageService()` usw.), nie die echten. Sie
+braucht keinen Mock der Erweiterung, sondern nur nachgezogene Erwartungen an die um
+`tenantId` erweiterten Aufrufe.
+
+**Befund I — eine bestehende Schutzpruefung, die man mit einem Kommentar rot machen
+kann.** `tender-ingestion.service.spec.ts` enthaelt den Test "never calls
+forTenant()", der den QUELLTEXT von `tender-ingestion.service.ts` liest und gegen
+ein Vorkommen dieses Bezeichners prueft. In dieser einen Datei darf deshalb auch
+kein ERKLAERENDER Kommentar den Bezeichner nennen. Sie steht ohnehin auf der
+Nicht-Anfassen-Liste; hier nur festgehalten, damit niemand sie beim Nachziehen der
+Begruendungen "freundlich kommentiert" und den Lauf rot macht.
+
+**Befund J — welcher Code Leere als Abwesenheit deutet, und die neue lautlose Form
+(Vorarbeit fuer Aufgabe 1, dort auszuformulieren und zu ergaenzen, nicht
+abzuschreiben).**
+
+Sichtbare Formen (Anzeige bleibt leer, jemand merkt es):
+`tenderSavedSearchService.list` (leere Profilliste), `tenderTriageService.listForUser`
+(keine Gelesen-/Favoriten-Markierung in der Trefferliste),
+`tenderNotificationPrefService.getForUser` (**Sonderfall**: kein Treffer bedeutet hier
+nicht "leer", sondern der Vorgabewert `daily` — ein zu kleines Leseergebnis setzt
+einen Nutzer, der `off` gewaehlt hat, stillschweigend auf taeglich zurueck; die eine
+Stelle des Bereichs, an der zu wenig Lesen zu MEHR Handlung fuehrt),
+`tenderEmailConfigService.getConfigForApi` (Oberflaeche meldet "kein Postfach" fuer
+einen Nutzer, der eines hat), `tenderRssFeedSourceService.listForUser`/`remove`
+(leere Quellenliste, bzw. 404 beim Entfernen).
+
+Lautlose Formen — die zusaetzliche Fehlerform dieses Bereichs, fuenf Stellen:
+`tender-digest.scheduler.ts` `if (!candidates.length) return;` (der GESAMTE Digest
+tut fuer alle Mandanten nichts), `if (!matches.length) continue;` und
+`if (!user || !user.email) continue;` (dieser Nutzer bekommt keine Post);
+`tender-matching.service.ts` `if (!fresh.length) continue;` und
+`if (!user || !user.email) continue;` (dieser Nutzer bekommt keinen Sofort-Alarm).
+Keine dieser Stellen protokolliert etwas. Eine ausbleibende Warnung erzeugt keine
+Fehlermeldung, keinen Protokolleintrag und keine Beschwerde.
+
+Entlastung in dieselbe Richtung, ebenfalls nachgesehen statt geschlossen: weil
+`notifiedAt` nur nach erfolgreichem Versand gestempelt wird, bleiben die betroffenen
+`TenderMatch`-Zeilen auf `notifiedAt IS NULL` stehen und werden bei jedem Lauf erneut
+versucht. Es geht also nichts verloren, es kommt nur nichts an — und daraus ergibt
+sich das einzige nachpruefbare Signal dieser Fehlerform: eine wachsende Zahl von
+`TenderMatch`-Zeilen mit `notifiedAt IS NULL` bei gleichzeitig fehlendem
+Versandprotokoll. Dieses Signal gehoert in die Vorabpruefung von Etappe 4
+(`rls-preflight.mjs`), NICHT in diesen Durchlauf.
+
+Eine Laufzeitwarnung an den fuenf Stellen wurde erwogen und VERWORFEN, aus demselben
+Grund wie bei `getAllActiveConfigs` im ldap-Durchlauf: "kein Konto mit Adresse" ist
+seit WINDOWS #15 ein regulaerer Zustand, und der Digest laeuft taeglich. Eine
+Warnung waere Dauerlaerm und verloere ihr Signal.
+
+**Befund K — die Abhaengigkeit vom noch nicht umgestellten Bereich `settings`.**
+`tender-mail.service.ts` holt die SMTP-Angaben ueber
+`SettingsService.getDecryptedSmtpConfig(tenantId)`; fehlt sie, liefern beide
+Versandmethoden `false` und der Aufrufer laesst `notifiedAt` auf NULL stehen. Der
+Bereich `settings` (4 Rohtreffer) ist noch nicht umgestellt. Nach dem Scharfschalten
+faende diese ungebundene Abfrage keine SMTP-Zeile mehr — Ergebnis: kein Versand fuer
+niemanden, mit Wiederholung bei jedem Lauf. Das ist eine Reihenfolgebedingung fuer
+Etappe 4, genau wie Befund D des ldap-Durchlaufs es fuer `groups` war. Festhalten,
+nicht hier loesen.
+
+**Befund L — die zwoelf Paare, die nicht angefasst werden duerfen.** Zehn Paare der
+Klasse `keine-mandantengebundene-tabelle` (`tender-dedup.service.ts` x2,
+`tender-fingerprint-backfill.service.ts`, `tender-ingestion.service.ts` x2,
+`tender-matching.service.ts`/`tender`, `tender-scheduler.service.ts`,
+`tenders.controller.ts` x2, `tenders.module.ts`) und zwei der Klasse
+`bewusst-uebergreifend` (`adapters/email-alert.adapter.ts`,
+`adapters/rss.adapter.ts`). Stichprobenweise gegen D-03 und die Dateikoepfe
+geprueft: `tender-dedup.service.ts` und `tender-ingestion.service.ts` tragen die
+Anweisung im Kopf ausgeschrieben, `tenders.module.ts` ebenso, beide Adapter
+begruenden ihren Fan-out im Dateikopf. Bei der Ausfuehrung ist jede der zwoelf
+Zeilen einzeln gegen D-03 bzw. den Dateikopf zu pruefen, nicht gegen diese Aufzaehlung.
+
+**Gewaehltes Muster (bewusst, nicht stillschweigend):** Der Mandantenkontext wird
+weiterhin IM DIENST erzeugt (`const tenantPrisma = forTenant(this.prisma, tenantId) as any;`),
+wie in `ldap`, `groups` und `auth.service.ts`. Der offene Befund `req.tenantPrisma`
+(gesetzt in `tenant.middleware.ts` und `tenant.guard.ts`, nirgends gelesen) wird auch
+von diesem Durchlauf AUSDRUECKLICH NICHT entschieden. Die Namenskonvention
+`tenantPrisma` wird eingehalten, weil die Rohtrefferzaehlung des
+Klassifikationsdokuments an ihr haengt.
+
+**Nicht angefasst:** `apps/api/prisma/schema.prisma`, `apps/api/prisma/migrations/`,
+alle vier Compose-Dateien, `.env.example`, `.env.prod.example`. `DATABASE_URL` bleibt
+auf der Rolle `tessera` mit BYPASSRLS — das Scharfschalten ist Etappe 4. Am
+Verzeichnis (AD) wird nichts geaendert. `apps/web` wird nicht beruehrt: die
+Umstellung ist rein dienstintern, kein Vertrag einer HTTP-Route aendert sich.
+
+
+
+
+
+ Aufgabe 1: Die drei Sonderfaelle dieses Bereichs messen und die Fehlerrichtung fuer tenders schreiben
+ Der Container `tessera-ctl-db-1` laeuft; seine Adresse per `docker inspect tessera-ctl-db-1 --format '{{range $k,$v := .NetworkSettings.Networks}}{{$v.IPAddress}}{{end}}'` NEU ermitteln (zur Planungszeit 172.19.0.2 — eine Container-Adresse ist veraenderlich und darf nicht aus diesem Plan abgeschrieben werden).
+ apps/api/scripts/rls-scratch-check.mjs, docs/mandantentrennung-etappe2-fehlerrichtung.md
+
+Zuerst messen, dann die Kritik aus der Messung schreiben — nicht umgekehrt. Kein
+Dienstcode in dieser Aufgabe.
+
+TEIL 1, `apps/api/scripts/rls-scratch-check.mjs`: einen fuenften Abschnitt
+`runTendersAreaChecks(adminUrl, scratchRoleUrl, results)` nach dem Vorbild des
+vorhandenen `runGroupsAreaChecks` ergaenzen und in `main()` nach diesem, aber VOR
+`runTransactionShapeMeasurement` aufrufen — die Transaktionsmessung setzt auf den von
+`runGroupsAreaChecks` angelegten Tabellen auf und darf ihre Voraussetzung nicht
+verlieren; das ist beim Einhaengen zu pruefen, nicht anzunehmen.
+
+Die fuenf Policies werden NICHT im Werkzeug neu getippt. Sie kommen alle aus dem
+Migrationsverzeichnis, das auf `_rls_remaining_tenant_tables` endet — das vorhandene
+`readRemainingTenantTablesMigrationSql()` liest es bereits, `extractPolicySql()`
+schneidet je Tabelle heraus. Gebraucht werden `TenderEmailConfig`,
+`TenderNotificationPref`, `TenderRssFeedSource`, `TenderSavedSearch`, `TenderTriage`.
+Findet die Extraktion eine der fuenf nicht, meldet der Abschnitt eine
+FEHLGESCHLAGENE Pruefung `tenders-policies-aus-migration-gefunden` und bricht ab —
+das Werkzeug darf nicht still mit einer geratenen Policy weitermessen.
+
+Der Abschnitt legt in der Wegwerf-Datenbank schlanke Tabellen an, die genau die
+Spalten tragen, die die Policies und die Messungen brauchen. Die Nullbarkeit von
+`TenderRssFeedSource."tenantId"` und die drei Eindeutigkeitsbedingungen ohne
+Mandantendimension sind dabei KEIN Beiwerk, sondern der Gegenstand: sie muessen
+angelegt werden wie im echten Schema (`TenderEmailConfig.userId` eindeutig,
+`TenderNotificationPref.userId` eindeutig, `TenderTriage(userId, tenderId)`
+eindeutig). Danach ENABLE plus FORCE ROW LEVEL SECURITY, die fuenf extrahierten
+Policies, die Rechtevergabe an die Wegwerf-Rolle und Testzeilen: je Mandant
+(TENANT-A, TENANT-B) je eine Zeile pro Tabelle, in `TenderSavedSearch` fuer TENANT-A
+ZWEI Zeilen von ZWEI verschiedenen Nutzern, und in `TenderRssFeedSource` zusaetzlich
+eine plattformweite Zeile ohne Mandanten und ohne Besitzer.
+
+Gemessen wird unter der Rolle ohne BYPASSRLS ueber das vorhandene
+`forTenantQuery`-Hilfsmittel, mit diesen Kennungen:
+
+- `tendersavedsearch-gebunden-nur-eigener-mandant` — der gebundene SELECT unter
+ TENANT-A liefert die Zeilen von A und keine von B.
+- `tendersavedsearch-ungebunden-null-zeilen` — DERSELBE SELECT ohne vorher gesetzten
+ Kontext liefert null Zeilen. Das ist die Belegzeile, die den ganzen Abschnitt der
+ Kritikschrift traegt; sie muss an der echten, ausgelieferten Policy haengen.
+- `tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar` — der gebundene
+ SELECT unter TENANT-A liefert AUCH die Zeile des zweiten Nutzers. Diese Pruefung
+ gilt als bestanden, wenn die fremde Zeile sichtbar ist: sie belegt Befund E,
+ naemlich dass die Policy keine Benutzerdimension hat. Der Meldetext sagt das
+ ausdruecklich UND nennt die Folge — die anwendungsseitige `userId`-Filterung
+ bleibt der einzige Schutz gegen Quer-Lesen zwischen Nutzern und darf nicht
+ entfernt werden. Ohne diesen Zusatz koennte eine bestandene Pruefung mit "ist
+ abgesichert" verwechselt werden.
+- `tenderemailconfig-gebunden-nur-eigener-mandant`,
+ `tendertriage-gebunden-nur-eigener-mandant`,
+ `tendernotificationpref-gebunden-nur-eigener-mandant` — je eine Pruefung nach
+ demselben Muster wie die erste.
+- `tenderrssfeed-plattformzeile-unter-jedem-mandanten-unsichtbar` — der gebundene
+ SELECT liefert unter TENANT-A UND unter TENANT-B jeweils NICHT die plattformweite
+ Zeile ohne Mandanten. Bestanden, wenn sie unter beiden Kontexten fehlt. Der
+ Meldetext benennt WINDOWS #19 und die Folge: `listForUser` darf nicht gebunden
+ werden, solange die Policy-Semantik unveraendert ist.
+- `tenderrssfeed-gebundenes-einfuegen-ohne-mandant-abgelehnt` — ein gebundenes
+ INSERT unter TENANT-A mit `tenantId = NULL` wird abgewiesen; die Abweisung ist das
+ bestandene Ergebnis. Der Meldetext nennt die Folge: `createPlatform` darf nicht
+ gebunden werden.
+- `tendertriage-einfuegen-auf-unsichtbare-zeile-verletzt-eindeutigkeit` — unter
+ TENANT-A ein INSERT fuer ein Paar (`userId`, `tenderId`), dessen Zeile existiert,
+ aber zu TENANT-B gehoert und daher unsichtbar ist. Bestanden, wenn der Fehler eine
+ Verletzung der Eindeutigkeitsbedingung ist (nicht eine Policy-Abweisung). Der
+ Meldetext benennt Befund F und die Folge fuer Aufgabe 2: aus einem stillen
+ Ueberschreiben wird ein harter Fehler, der als verstaendliche Meldung
+ herauskommen muss.
+
+Das Werkzeug raeumt weiterhin ausschliesslich seine fest verdrahtete
+Wegwerf-Datenbank ab und bekommt keine steuerbaren Namen (T-EOR-07 bleibt gueltig).
+Kein bestehender Abschnitt wird veraendert; alle 23 bisherigen Pruefungen muessen
+unveraendert weiterlaufen.
+
+TEIL 2, Beleg statt Behauptung fuer Befund A: nachmessen, dass dieser Bereich keine
+mandantengebundene Transaktion enthaelt — mit
+`grep -rn '\$transaction(' apps/api/src/tenders --include=*.ts | grep -v spec`. Das
+Ergebnis (Zahl der Treffer, betroffene Datei, Form) wird in der Kritikschrift
+festgehalten, samt der Feststellung, dass der im Kopf von
+`prisma-tenant.extension.ts` verlangte erneute Test fuer diesen Bereich damit
+beantwortet ist: kein neuer Fall, `withTenantTransaction()` wird nicht gebraucht.
+Faellt das Ergebnis anders aus als in Befund A beschrieben, gilt die MESSUNG, und
+die Abweichung wird ausgeschrieben, bevor Aufgabe 2 beginnt.
+
+TEIL 3, `docs/mandantentrennung-etappe2-fehlerrichtung.md` um einen Abschnitt
+`## Bereich tenders` ERWEITERN, nicht ein zweites Dokument anlegen. Die Leitfrage
+aus Abschnitt (a) gilt unveraendert weiter und wird nicht wiederholt; der neue
+Abschnitt verweist darauf und haelt im Kopf fest, dass er den Bereich `tenders` zum
+Zeitpunkt seiner Umstellung beschreibt (Quick-Task 260909-laa).
+
+Inhalt, in ganzen Saetzen auf Deutsch, mit derselben Gliederung wie der
+groups-Abschnitt:
+
+(t1) Die Messung — die TATSAECHLICH beobachtete Ausgabe des Laufs, hineinkopiert,
+nicht nacherzaehlt, mit Datum und der bei der Ausfuehrung ermittelten Adresse. Die
+Belegzeile ausdruecklich benennen.
+
+(t2) Signaltabelle je umgestelltem Pfad: Pfad, Verhalten bei zu wenig Ergebnis,
+konkretes Signal mit Ort. Es muessen alle in Aufgabe 2 und 3 umgestellten Pfade
+vorkommen, inklusive des Sonderfalls `getForUser` (Vorgabewert `daily` statt leer)
+und der drei RSS-Pfade, die bewusst ungebunden bleiben und nach dem Scharfschalten
+eine leere Liste bzw. eine 404 liefern.
+
+(t3) Welcher Code Leere als Abwesenheit deutet — getrennt nach der SICHTBAREN und
+der LAUTLOSEN Form. Die lautlose Form ist der Kern dieses Abschnitts und bekommt
+eigenen Raum: fuenf namentlich benannte Stellen, die Feststellung, dass keine davon
+etwas protokolliert, die Entlastung ueber das offen bleibende `notifiedAt` samt dem
+daraus folgenden einzigen nachpruefbaren Signal (wachsende Zahl unbenachrichtigter
+Treffer ohne Versandprotokoll), und die begruendete Verwerfung einer
+Laufzeitwarnung.
+
+(t4) Was dieser Durchlauf bewusst nicht loest: WINDOWS #19 samt der drei davon
+betroffenen RSS-Pfade (mit dem Messergebnis als Beleg), die uebergreifenden
+Haelften der beiden Hintergrunddienste als Etappe-3-Uebergabe, die Abhaengigkeit
+vom noch nicht umgestellten Bereich `settings` (Befund K) als Reihenfolgebedingung
+fuer Etappe 4, die offene Architekturfrage `req.tenantPrisma`, und die Beobachtung
+aus Befund G, dass ein Administrator eines beliebigen Mandanten eine plattformweite
+RSS-Quelle entfernen kann.
+
+(t5) Was dieser Durchlauf bewusst NICHT anfasst: die zwoelf Paare des
+plattformweiten Katalogs und der beiden Fan-out-Adapter, mit der Feststellung, dass
+sie geprueft und deliberat ungebunden sind — nicht uebersehen. Der Hinweis aus
+Befund I gehoert hierher: in `tender-ingestion.service.ts` darf auch kein
+erklaerender Kommentar den von der dortigen Schutzpruefung gesuchten Bezeichner
+nennen.
+
+
+ DB_IP=$(docker inspect tessera-ctl-db-1 --format '{{range $k,$v := .NetworkSettings.Networks}}{{$v.IPAddress}}{{end}}') && TESSERA_SCRATCH_ADMIN_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/postgres" node apps/api/scripts/rls-scratch-check.mjs && npm --prefix apps/api run test && npm --prefix apps/api run type-check && test -z "$(git diff --name-only HEAD -- apps/api/prisma docker-compose.yml docker-compose.dev.yml docker-compose.prod.yml docker-compose.ci.yml .env.example .env.prod.example)"
+
+ Das Wegwerf-Werkzeug meldet alle Pruefungen bestanden (23 bisherige plus die neuen des tenders-Abschnitts) mit Rueckgabewert 0; `npm --prefix apps/api run test` meldet weiterhin 743 Tests gruen und die Typpruefung ist sauber; `docs/mandantentrennung-etappe2-fehlerrichtung.md` traegt einen Abschnitt `## Bereich tenders` mit der tatsaechlich beobachteten Ausgabe, einer Signaltabelle, dem eigenen Unterabschnitt zur lautlosen Fehlerform mit fuenf namentlich benannten Stellen, und den beiden Abschnitten zu dem, was bewusst offen bzw. unangetastet bleibt; Schema, Migrationen, Compose- und Beispiel-Umgebungsdateien sind unveraendert.
+
+
+
+ Aufgabe 2: Die fuenf Nutzer-CRUD-Dienste binden und den Mandanten durch die Steuerung reichen
+ apps/api/src/tenders/tender-saved-search.service.ts, apps/api/src/tenders/tender-saved-search.service.spec.ts, apps/api/src/tenders/tender-triage.service.ts, apps/api/src/tenders/tender-triage.service.spec.ts, apps/api/src/tenders/tender-notification-pref.service.ts, apps/api/src/tenders/tender-notification-pref.service.spec.ts, apps/api/src/tenders/tender-email-config.service.ts, apps/api/src/tenders/tender-email-config.service.spec.ts, apps/api/src/tenders/tender-rss-feed.service.ts, apps/api/src/tenders/tender-rss-feed.service.spec.ts, apps/api/src/tenders/tenders.controller.ts, apps/api/src/tenders/tenders.controller.spec.ts
+
+Der Nachweis kommt VOR der Umstellung, sonst beweist er nichts (Befund H). Je
+Dienst zuerst der Zwei-Client-Nachweis nach dem Muster aus
+`apps/api/src/groups/groups.service.spec.ts`, dann der Umbau.
+
+- Der vorhandene In-Memory-Fake jeder Testdatei bekommt `__makeBoundClient(tenantId)`:
+ einen je Modell protokollierenden Wrapper um DIESELBEN Maps, sodass ein Aufruf
+ ueber den ungebundenen Fake und ein Aufruf ueber den gebundenen Client
+ unterscheidbar sind. `forTenant` wird per `vi.mock('../prisma/prisma-tenant.extension', ...)`
+ darauf gelenkt. Eine reine Identitaet (`(p) => p`) genuegt NICHT — sie ist genau
+ der ldap-Fehler, bei dem der Test in keiner Richtung etwas merkt.
+- Je Dienst mindestens ein Test der Form "Methode X bindet ueber forTenant() an den
+ uebergebenen Mandanten" (`expect(forTenant).toHaveBeenCalledWith(prisma, 't1')`
+ PLUS der Nachweis, dass der Modellzugriff auf dem GEBUNDENEN Client stattfand,
+ ueber das Protokoll des Wrappers) — fuer jede umgestellte Methode, nicht nur eine
+ Stichprobe.
+- Fuer `tender-rss-feed.service.ts` zusaetzlich der Gegentest: `listForUser`,
+ `createPlatform` und `remove` binden NICHT (`expect(forTenant).not.toHaveBeenCalled()`),
+ und `listForUser` liefert weiterhin die plattformweite Zeile ohne Besitzer mit.
+- Fuer die drei `upsert`-Pfade (`tenderEmailConfig`, `tenderNotificationPref`,
+ `tenderTriage`) je ein Test, dass eine Eindeutigkeitsverletzung (P2002) als
+ verstaendliche deutsche Meldung herauskommt und nicht als roher Fehler.
+- Alle bestehenden Besitz-/IDOR-Tests jeder Datei bleiben unveraendert bestehen und
+ gruen — die anwendungsseitige `userId`-Filterung wird durch die Bindung NICHT
+ ersetzt (Befund E).
+- Falsifizieren, nicht behaupten: nach dem Umbau probeweise EINE Bindung
+ zurueckbauen und belegen, dass mindestens ein Test dadurch rot wird. Das Ergebnis
+ gehoert in den Bericht; der Rueckbau wird danach rueckgaengig gemacht.
+
+
+Bindungsregel, die ueber jede einzelne Fundstelle entscheidet: gebunden wird genau
+dann, wenn die beruehrte Zeilenmenge garantiert ein nicht-nullbares `tenantId`
+traegt, das dem Mandanten des Aufrufers entspricht. Grundlage ist die Messung aus
+Aufgabe 1, nicht dieser Text — weicht die Messung ab, gilt die Messung, und die
+Abweichung wird im Bericht ausgeschrieben.
+
+Muster durchgehend wie in `groups`/`ldap`:
+`const tenantPrisma = forTenant(this.prisma, tenantId) as any;`, Name `tenantPrisma`
+beibehalten (die Rohtrefferzaehlung des Klassifikationsdokuments haengt daran). Der
+gebundene Client wird je Methode einmal erzeugt, nicht je Zugriff.
+
+VOLLSTAENDIG BINDEN, alle Zugriffe:
+
+- `tender-saved-search.service.ts` — `list`, `create`, `update` (Lesepruefung UND
+ Schreibzugriff), `remove` (Lesepruefung UND Loeschung). `list`, `update` und
+ `remove` bekommen `tenantId` als zusaetzlichen Parameter.
+- `tender-triage.service.ts` — `setTriage` (hat `tenantId` bereits), `listForUser`
+ und `favoriteIds` bekommen `tenantId`.
+- `tender-notification-pref.service.ts` — `setForUser` (hat `tenantId` bereits),
+ `getForUser` bekommt `tenantId`.
+- `tender-email-config.service.ts` — `saveConfig` (hat `tenantId` ueber `ctx`),
+ `getConfigForApi` und `testConnection` bekommen `tenantId`. Beide Lesezugriffe in
+ `getConfigForApi` binden. Die Sicherheitszusagen des Dateikopfs bleiben
+ unangetastet: die sichere Feldauswahl gilt weiter, der rohe Lesezugriff bleibt
+ methodenlokal, entschluesselte Zugangsdaten werden nicht protokolliert und nicht
+ zurueckgegeben.
+
+TEILWEISE BINDEN — `tender-rss-feed.service.ts`:
+
+- `createForUser` bindet BEIDES, den Zaehler und die Anlage.
+- `listForUser`, `createPlatform` und `remove` bleiben UNGEBUNDEN. Jede der drei
+ bekommt einen kurzen Kommentar, der WINDOWS #19 nennt und die konkrete Folge einer
+ Bindung benennt (plattformweite Quellen verschwaenden fuer jeden Mandanten; das
+ Einfuegen ohne Mandanten wuerde abgewiesen; plattformweite Quellen liessen sich
+ nicht mehr entfernen). Den einen bedingten `deleteMany` in zwei Anweisungen zu
+ zerlegen ist ausdruecklich NICHT erlaubt — das oeffnete das Pruef-/Nutzungsfenster
+ wieder, das der Dateikopf vermeidet. Die Policy-Semantik wird NICHT angefasst,
+ keine Migration geschrieben.
+
+FEHLERBEHANDLUNG (Befund F, getragen von der Messung aus Aufgabe 1): die drei
+`upsert`-Pfade auf Eindeutigkeitsbedingungen ohne Mandantendimension bekommen eine
+Behandlung des Prisma-Fehlercodes P2002, die eine verstaendliche deutsche Meldung
+liefert statt eines rohen Fehlers. Muster wortgleich aus
+`tender-saved-search.service.ts` uebernehmen (dort bereits vorhanden), nicht neu
+erfinden. Der Text nennt die Ursache in Alltagssprache; keine Fachbegriffe, keine
+Fehlercodes im Text.
+
+STEUERUNG, `tenders.controller.ts`: die zehn Aufrufstellen, die heute nur
+`{ userId }` bzw. `{ userId, role }` destrukturieren, reichen `tenantId` mit durch.
+`extractTriageContext` liefert ihn bereits und wird NICHT veraendert; es entsteht
+kein neuer Aufloesungsweg und keine neue Quelle fuer Mandant oder Nutzer. Die
+Reihenfolge der Routen bleibt unangetastet (statische Routen vor `@Get(':id')` —
+sonst 404-Verschattung, die kein Test dieser Ebene faengt).
+
+`tenders.controller.spec.ts`: die Erwartungen an die Fake-Dienste um den neuen
+`tenantId`-Parameter nachziehen. Die Datei braucht KEINEN Mock der Erweiterung
+(sie uebergibt ausschliesslich Fake-Dienste, nie die echten).
+
+NICHT ANFASSEN in dieser Aufgabe: `tender-digest.scheduler.ts`,
+`tender-matching.service.ts` (das ist Aufgabe 3), die zwoelf Paare aus Befund L,
+Schema, Migrationen, Compose-Dateien, Umgebungsdateien, `apps/web`. Keine neue
+Transaktion einfuehren (Befund A).
+
+
+ npm --prefix apps/api run test && npm --prefix apps/api run type-check && test -z "$(git diff --name-only HEAD -- apps/api/prisma docker-compose.yml docker-compose.dev.yml docker-compose.prod.yml docker-compose.ci.yml .env.example .env.prod.example apps/web)"
+
+ Alle 743 bisherigen Tests plus die neuen Bindungsnachweise sind gruen und die Typpruefung ist sauber; die vier Dienste mit nicht-nullbarem Mandanten laufen vollstaendig ueber `forTenant()`, `tender-rss-feed.service.ts` bindet `createForUser` und begruendet die drei ungebundenen Pfade im Code mit WINDOWS #19; alle bestehenden Besitz-/IDOR-Tests sind unveraendert gruen; die Schutzpruefung "never calls forTenant()" in `tender-ingestion.service.spec.ts` ist weiterhin gruen; ein probeweiser Rueckbau EINER Bindung macht mindestens einen Test rot und das ist im Bericht festgehalten; Schema, Migrationen, Compose-, Umgebungsdateien und `apps/web` sind unveraendert.
+
+
+
+ Aufgabe 3: Die Je-Treffer-Haelften der beiden Hintergrunddienste binden und beide Dokumente schliessen
+ apps/api/src/tenders/tender-digest.scheduler.ts, apps/api/src/tenders/tender-digest.scheduler.spec.ts, apps/api/src/tenders/tender-matching.service.ts, apps/api/src/tenders/tender-matching.service.spec.ts, apps/api/src/tenders/tender-notifications.integration.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md, docs/mandantentrennung-etappe2-fehlerrichtung.md
+
+Wie in Aufgabe 2: erst der Zwei-Client-Nachweis, dann der Umbau. Beide Testdateien
+und die gemeinsame Integrationsdatei haben heute keinen Mock der Erweiterung und
+wuerden sonst aus dem falschen Grund rot (Befund H).
+
+- Je Datei ein Test, dass die UEBERGREIFENDE Abfrage NICHT bindet
+ (`tenderMatch.findMany` der Kandidatenliste im Digest, `tenderSavedSearch.findMany`
+ in der Sofortmeldung) und dass die Zugriffe INNERHALB der Schleife auf dem
+ gebundenen Client laufen, mit dem Mandanten der jeweiligen Zeile.
+- Ein Test mit ZWEI Mandanten in einem Lauf, der belegt, dass je Durchlauf der
+ Schleife mit dem Mandanten DIESER Zeile gebunden wird und nicht einmal global mit
+ dem ersten.
+- Ein Test der lautlosen Fehlerform: liefert der gebundene Lesezugriff auf den
+ Benutzer nichts, wird nichts versendet UND `notifiedAt` bleibt NULL (der Treffer
+ bleibt also wiederholbar). Das ist die Zusage, auf der die Entlastung in der
+ Kritikschrift beruht — sie muss von einem Test getragen werden, nicht von einer
+ Behauptung.
+- Falsifizieren wie in Aufgabe 2: eine Bindung probeweise zurueckbauen, roten Test
+ belegen, zuruecknehmen, Ergebnis in den Bericht.
+
+
+Die Trennlinie zwischen Etappe 2 und Etappe 3 wird hier gezogen und im Code
+ausgeschrieben. Etappe 3 (Systemkontext fuer Hintergrundlaeufe) wird NICHT
+vorweggenommen.
+
+`tender-digest.scheduler.ts`:
+- Die Kandidatenabfrage (`tenderMatch.findMany` mit `notifiedAt: null`, `distinct`)
+ bleibt UNGEBUNDEN — sie ist der bewusste Fan-out ueber alle Mandanten. Ein
+ Kommentar benennt sie als Etappe-3-Uebergabe.
+- Damit die Schleife ueberhaupt binden KANN, braucht sie einen Mandanten. Die
+ Kandidatenabfrage waehlt deshalb zusaetzlich das denormalisierte `tenantId` der
+ Treffer-Zeile aus. Dass diese Erweiterung zusammen mit `distinct` das erwartete
+ Ergebnis liefert, ist am Fake der Testdatei UND an der Prisma-Typpruefung
+ nachzuweisen, nicht anzunehmen. Der Sonderfall, dass ein Nutzer Treffer unter zwei
+ verschiedenen Mandanten haben koennte (denormalisierter Wert, Mandantenwechsel),
+ wird nicht geloest, sondern im Kommentar und in der Kritikschrift benannt.
+- Innerhalb der Schleife binden: die Praeferenz-Abfrage, die Treffer-Abfrage und die
+ Benutzer-Abfrage, alle an den Mandanten der Kandidatenzeile. Der Schreibzugriff,
+ der `notifiedAt` stempelt, bindet ebenfalls.
+- Der Aufruf des Mailversands bleibt unveraendert; welcher Mandant die SMTP-Angaben
+ bestimmt, wird nicht geaendert.
+
+`tender-matching.service.ts`:
+- Die Profil-Abfrage (`tenderSavedSearch.findMany` ohne Filter) bleibt UNGEBUNDEN,
+ mit Kommentar als Etappe-3-Uebergabe.
+- Der Lesezugriff auf den plattformweiten Katalog bleibt UNGEBUNDEN (D-03), mit
+ Kommentar.
+- Innerhalb der Profilschleife binden, jeweils an `tenantId` des Profils bzw. des
+ Treffers: die Anlage der Treffer, die Abfrage der noch nicht benachrichtigten
+ Treffer, die Benutzer-Abfrage und der Schreibzugriff, der `notifiedAt` stempelt.
+ Der gebundene Client wird EINMAL je Profil erzeugt, nicht je Treffer — sonst
+ entsteht pro Zeile eine eigene Transaktion.
+- Die vorhandene Fehlerbehandlung je Profil (ein defektes Profil darf den Lauf nicht
+ abbrechen) bleibt unveraendert.
+
+MASCHINELLE ABSICHERUNG UND DOKUMENTE:
+
+- `apps/api/src/prisma/rls-access-inventory.spec.ts` laufen lassen und AUS SEINER
+ AUSGABE den Stand je Paar ablesen. Der gemessene Wert gewinnt; er wird nicht aus
+ diesem Plan abgeschrieben. Erwartungsgemaess entstehen fuer diesen Bereich
+ gemischte Staende (Dateien, in denen dasselbe Modell gebunden UND ungebunden
+ vorkommt) — das ist der korrekte Ausdruck der `beides`-Klasse und kein Mangel.
+ Zeigt die Pruefung ein bisher unbekanntes Paar oder eine Erkennungsluecke, wird
+ sie geschlossen wie in 260909-jts (dort war es der Transaktionsparameter), bevor
+ das Dokument nachgezogen wird.
+- `docs/mandantentrennung-zugriffsklassifikation.md` nachziehen: die Stand-Spalte
+ aller 23 tenders-Paare auf den gemessenen Wert; die Bereichszeile `tenders` in der
+ Uebersicht mit den dort dokumentierten Zaehlbefehlen NEU messen (nicht rechnen)
+ und die Summenzeile mitfuehren; die Klassenverteilung pruefen und nur aendern,
+ wenn die Pruefung tatsaechlich eine andere Paarzahl meldet. Der Abschnitt "Der
+ Hintergrunddienst als Falle" bekommt fuer die beiden tenders-Dateien einen
+ Nachtrag im Stil des ldap-Eintrags: Je-Treffer-Haelfte geschlossen, uebergreifende
+ Haelfte ausdruecklich an Etappe 3 uebergeben. Der urspruengliche Text bleibt
+ lesbar stehen, es wird nachgetragen und nicht neu geschrieben.
+- Die zwoelf Paare aus Befund L einzeln gegen D-03 bzw. den jeweiligen Dateikopf
+ pruefen und ihre Begruendungsspalte so schaerfen, dass sie als BEWUSST ungebunden
+ lesbar ist und nicht als offener Rest. `tender-ingestion.service.ts` selbst wird
+ dabei NICHT bearbeitet (Befund I).
+- `docs/mandantentrennung-etappe2-fehlerrichtung.md`: den in Aufgabe 1 geschriebenen
+ Abschnitt um das ergaenzen, was erst jetzt tatsaechlich vorliegt — welche Haelften
+ geschlossen sind, was an Etappe 3 uebergeben ist, und der Nachtrag zur
+ Reihenfolgebedingung gegenueber dem Bereich `settings` (Befund K).
+
+
+ DB_IP=$(docker inspect tessera-ctl-db-1 --format '{{range $k,$v := .NetworkSettings.Networks}}{{$v.IPAddress}}{{end}}') && npm --prefix apps/api run test && npm --prefix apps/api run type-check && TESSERA_SCRATCH_ADMIN_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/postgres" node apps/api/scripts/rls-scratch-check.mjs && test -z "$(git diff --name-only HEAD -- apps/api/prisma docker-compose.yml docker-compose.dev.yml docker-compose.prod.yml docker-compose.ci.yml .env.example .env.prod.example apps/web)"
+
+ Alle Tests (743 plus die neuen) und die Typpruefung sind gruen, das Wegwerf-Werkzeug meldet alle Pruefungen bestanden; `rls-access-inventory.spec.ts` und das Klassifikationsdokument stimmen fuer alle 23 tenders-Paare ueberein, jede Zeile traegt einen gemessenen Stand; die zwoelf bewusst ungebundenen Paare sind als bewusst lesbar und `tender-ingestion.service.ts` ist unveraendert; beide Hintergrunddienste binden je Schleifendurchlauf an den Mandanten der jeweiligen Zeile und lassen ihre uebergreifende Abfrage kommentiert ungebunden; beide Dokumente sind geschlossen; Schema, Migrationen, Compose-, Umgebungsdateien und `apps/web` sind unveraendert.
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| Browser -> API | Sitzungsnachweis (JWT) und Modul-Wachter; `userId`/`tenantId`/`role` kommen ausschliesslich aus `extractTriageContext`, nie aus Rumpf oder Abfragezeichenkette |
+| API -> PostgreSQL | Row-Level-Security. Der Schalter ist AUS (Rolle `tessera` mit BYPASSRLS); die Policies wirken heute nicht, sind aber ausgeliefert |
+| Planer -> PostgreSQL | Zwei Cron-Laeufe ohne Anfragekontext (Digest, Sofortmeldung) — kein Mandant aus einer Sitzung, nur aus der gelesenen Zeile |
+| API -> fremdes Postfach / fremder RSS-Host | Ausgehende Verbindungen mit gespeicherten, verschluesselten Zugangsdaten bzw. mit vom Nutzer gesetzten URLs |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
+|-----------|----------|-----------|----------|-------------|-----------------|
+| T-LAA-01 | Information Disclosure | Suchprofile, Triage-Zustand, Benachrichtigungseinstellung, Postfachanbindung — Quer-Lesen zwischen MANDANTEN | high | mitigate | Aufgabe 2 bindet alle Lesepfade der vier Dienste mit nicht-nullbarem Mandanten an `forTenant()`; Aufgabe 1 misst an der ausgelieferten Policy unter einer Rolle ohne BYPASSRLS, dass gebunden nur die eigene Zeile und ungebunden gar keine sichtbar ist |
+| T-LAA-02 | Information Disclosure | dieselben Daten — Quer-Lesen zwischen NUTZERN desselben Mandanten | high | mitigate | Die Policies haben keine Benutzerdimension; Aufgabe 1 misst das ausdruecklich (`tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar`). Die anwendungsseitige `userId`-Filterung bleibt der einzige Schutz und wird in Aufgabe 2 nicht entfernt; alle bestehenden IDOR-Tests bleiben gruen |
+| T-LAA-03 | Tampering | Schreibpfade der fuenf Dienste — Anlegen/Aendern/Loeschen auf fremde Rechnung | high | mitigate | Gebundene Schreibzugriffe; die Migration verzichtet bewusst auf eine getrennte WITH-CHECK-Klausel, die USING-Bedingung gilt damit auch fuer neue Zeilen. Zusaetzlich bleiben die Besitzpruefungen im Dienst bestehen |
+| T-LAA-04 | Information Disclosure | gespeicherte Postfach-Zugangsdaten (`encryptedInboxCreds`) | high | mitigate | Aufgabe 2 laesst die sichere Feldauswahl unveraendert, bindet den rohen Lesezugriff ebenfalls und haelt entschluesselte Zugangsdaten methodenlokal — keine Protokollierung, keine Rueckgabe. Bestehende Zusagen des Dateikopfs werden nicht aufgeweicht |
+| T-LAA-05 | Denial of Service | Benachrichtigungswege: ein zu kleines Leseergebnis sendet nichts, lautlos | high | mitigate | Aufgabe 1 schreibt die fuenf Stellen namentlich in die Kritikschrift samt dem einzigen nachpruefbaren Signal; Aufgabe 3 sichert per Test zu, dass `notifiedAt` bei ausbleibendem Versand NULL bleibt und der Treffer damit wiederholbar ist. Eine Laufzeitwarnung wurde erwogen und begruendet verworfen |
+| T-LAA-06 | Denial of Service | WINDOWS #19 — plattformweite RSS-Quellen (`tenantId` nullbar) | medium | transfer | Aufgabe 1 misst die Grenze (plattformweite Zeile unter jedem Kontext unsichtbar, gebundenes Einfuegen ohne Mandant abgewiesen); Aufgabe 2 bindet die drei betroffenen Pfade deshalb NICHT und begruendet es im Code. Die Policy-Semantik gehoert zu Etappe 3 und wird hier nicht angefasst |
+| T-LAA-07 | Denial of Service | gebundenes `upsert` auf einen Eindeutigkeitsschluessel ohne Mandantendimension bei veraltetem denormalisiertem Mandanten | medium | mitigate | Aufgabe 1 misst das Verhalten; Aufgabe 2 uebersetzt die Eindeutigkeitsverletzung nach dem bereits vorhandenen Muster in eine verstaendliche deutsche Meldung statt eines rohen Fehlers |
+| T-LAA-08 | Tampering | ein Administrator eines beliebigen Mandanten kann eine plattformweite RSS-Quelle entfernen, die alle Mandanten speist | low | accept | Produkt-/Zustaendigkeitsfrage im Umfeld von WINDOWS #19, kein Auftrag dieses Durchlaufs. In Aufgabe 1 als Beobachtung in der Kritikschrift festgehalten, nicht repariert |
+| T-LAA-09 | Elevation of Privilege | vorzeitiges Scharfschalten der Datenbankrolle im Rahmen dieses Durchlaufs | high | mitigate | `DATABASE_URL`, alle vier Compose-Dateien und beide Beispiel-Umgebungsdateien bleiben unveraendert; jede Aufgabe prueft das maschinell ueber ein `git diff --name-only`-Gate im ``-Block |
+| T-LAA-SC | Tampering | Lieferkette (npm) | low | accept | Dieser Durchlauf installiert kein Paket — kein `npm install`, keine neue Abhaengigkeit. Das Paket-Legitimitaets-Gate faellt damit nicht an; wird waehrend der Ausfuehrung doch eine Installation noetig, ist das ein Anlass zum Anhalten und Nachfragen, nicht zum Nachziehen |
+
+
+
+1. `npm --prefix apps/api run test` — 743 bisherige Tests plus die neu
+ hinzugekommenen Bindungsnachweise, alle gruen, keine ausgelassene Datei.
+2. `npm --prefix apps/api run type-check` — Rueckgabewert 0.
+3. `DB_IP=$(docker inspect tessera-ctl-db-1 --format '{{range $k,$v := .NetworkSettings.Networks}}{{$v.IPAddress}}{{end}}')` und
+ `TESSERA_SCRATCH_ADMIN_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/postgres" node apps/api/scripts/rls-scratch-check.mjs`
+ — alle Pruefungen bestanden (23 bisherige plus die neuen), Rueckgabewert 0.
+4. `git diff --name-only HEAD -- apps/api/prisma docker-compose.yml docker-compose.dev.yml docker-compose.prod.yml docker-compose.ci.yml .env.example .env.prod.example apps/web`
+ — leer. Schema, Migrationen, Compose, Umgebungsdateien und das Web bleiben
+ unberuehrt, der Schalter bleibt aus.
+5. `rls-access-inventory.spec.ts` und `docs/mandantentrennung-zugriffsklassifikation.md`
+ stimmen fuer alle 23 tenders-Paare ueberein — nachgewiesen dadurch, dass die
+ Pruefung gruen ist, nicht durch Nachzaehlen von Hand.
+6. Der Rueckbau-Nachweis aus Aufgabe 2 und Aufgabe 3 ist im Bericht festgehalten:
+ welche Bindung probeweise entfernt wurde, welcher Test dadurch rot wurde.
+7. Ein lokal fehlender Mailserver (`ENOTFOUND mailhog`) ist umgebungsbedingt und
+ kein Mangel — nicht "reparieren".
+
+
+
+- Die fuenf Nutzer-CRUD-Dienste sind nach der Bindungsregel umgestellt: vier
+ vollstaendig, `tender-rss-feed.service.ts` teilweise mit im Code begruendeter
+ Grenze zu WINDOWS #19.
+- Die Je-Treffer-Haelften der beiden Hintergrunddienste binden an den Mandanten der
+ jeweils gelesenen Zeile; ihre uebergreifenden Abfragen sind unveraendert und als
+ Etappe-3-Uebergabe kommentiert.
+- Die zwoelf Paare des plattformweiten Katalogs und der beiden Fan-out-Adapter sind
+ unveraendert und im Klassifikationsdokument als bewusst ungebunden lesbar.
+- `docs/mandantentrennung-etappe2-fehlerrichtung.md` traegt einen `tenders`-Abschnitt
+ mit gemessener Belegzeile, Signaltabelle und einem eigenen Unterabschnitt zur
+ lautlosen Fehlerform.
+- Alle sieben umgestellten Testdateien koennen bei einer vergessenen Bindung rot
+ werden; das ist durch Rueckbau belegt, nicht behauptet.
+- Alle vier Verifikationsschritte oben sind gruen.
+
+
+