diff --git a/apps/api/scripts/rls-scratch-check.mjs b/apps/api/scripts/rls-scratch-check.mjs
index a0965ca..6484894 100644
--- a/apps/api/scripts/rls-scratch-check.mjs
+++ b/apps/api/scripts/rls-scratch-check.mjs
@@ -3776,6 +3776,581 @@ async function runAuthAreaChecks(adminUrl, scratchRoleUrl, results) {
}
}
+/**
+ * Aufgabe 1 (260911-gwh) — misst die acht im Plan genannten Verhaltensweisen
+ * des Bereichs `favorites` unter der Rolle ohne BYPASSRLS, an der Regel
+ * WORTGLEICH aus der ausgelieferten Migration
+ * `20260909140000_rls_remaining_tenant_tables` geschnitten — NICHT dem
+ * Werkzeug nachgetippt (vgl. runCalendarAreaChecks/runDashboardAreaChecks).
+ *
+ * Legt die Wegwerf-Tabelle "FavoriteLink" mit SAEMTLICHEN skalaren Spalten
+ * des Modells an (readSchemaModelScalarFieldNames('FavoriteLink') filtert
+ * das Relationsfeld `widgetInstance` heraus, sonst misst Pruefung 2 die
+ * falsche Menge) und traegt DEN Fremdschluessel auf die von
+ * runDashboardAreaChecks() bereits angelegte Tabelle "WidgetInstance"
+ * (Befund C, WINDOWS #27: eine Relation ist fuer die Bestandsaufnahme
+ * unsichtbar — hier deshalb ausdruecklich mitgebaut und gemessen, nicht nur
+ * behauptet). Muss deshalb NACH runDashboardAreaChecks() laufen. Setzt auf
+ * keiner Tabelle eines spaeteren Abschnitts auf: er ist ein Blatt in der
+ * Aufrufkette, muss NACH runAuthAreaChecks() und VOR
+ * runTransactionShapeMeasurement() laufen (siehe Aufrufkette in main()).
+ */
+async function runFavoritesAreaChecks(adminUrl, scratchRoleUrl, results) {
+ const widenMigrationSql = readRlsWidenMigrationSql();
+ const widenHasOwnFavoriteLinkPolicy =
+ widenMigrationSql && Boolean(extractPolicySql(widenMigrationSql, 'FavoriteLink'));
+ report(
+ results,
+ 'favoritelink-regelstand-eindeutig',
+ !widenHasOwnFavoriteLinkPolicy,
+ widenHasOwnFavoriteLinkPolicy
+ ? 'die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt eine EIGENE Regel fuer "FavoriteLink" — der Regelstand ist nicht mehr eindeutig auf 20260909140000_rls_remaining_tenant_tables zurueckzufuehren, Messung abgebrochen statt die abgeloeste Regel weiterzumessen'
+ : 'die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt KEINE eigene Regel fuer "FavoriteLink" — der Stand aus 20260909140000_rls_remaining_tenant_tables ist weiterhin der ausgelieferte, aktuelle Regelstand',
+ );
+ if (widenHasOwnFavoriteLinkPolicy) {
+ return;
+ }
+
+ const remainingMigrationSql = readRemainingTenantTablesMigrationSql();
+ const favoriteLinkPolicy = remainingMigrationSql
+ ? extractPolicySql(remainingMigrationSql, 'FavoriteLink')
+ : null;
+
+ if (!favoriteLinkPolicy) {
+ report(
+ results,
+ 'favoritelink-policy-aus-migration-gefunden',
+ false,
+ 'CREATE POLICY fuer "FavoriteLink" nicht in der ausgelieferten *_rls_remaining_tenant_tables-Migration gefunden',
+ );
+ return;
+ }
+
+ // Befund C: vorher ueber die Wartungsrolle MESSEN, welche "WidgetInstance"-
+ // Zeilen stehen — nicht annehmen. runDashboardAreaChecks() legt diese
+ // Tabelle bereits mit drei Zeilen an (widget-a1/user-a1/TENANT-A,
+ // widget-a2/user-a2/TENANT-A, widget-b1/user-b1/TENANT-B).
+ const widgetInstanceRows = await withAdminPrisma(
+ urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
+ async (db) => {
+ const rows = await db.$queryRaw`SELECT id, "userId", "tenantId" FROM "WidgetInstance" ORDER BY id`;
+ return rows;
+ },
+ );
+
+ await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), async (db) => {
+ // updatedAt bekommt DEFAULT CURRENT_TIMESTAMP als vermerkte Abweichung
+ // (Prisma setzt den Wert clientseitig, das schmale INSERT unten braucht
+ // trotzdem einen Wert) — Form von 260911-e2s/fh9.
+ await db.$executeRawUnsafe(`
+ CREATE TABLE "FavoriteLink" (
+ id text PRIMARY KEY,
+ "userId" text NOT NULL,
+ "tenantId" text NOT NULL,
+ "widgetId" text NOT NULL,
+ title text NOT NULL,
+ url text NOT NULL,
+ "iconUrl" text,
+ position integer NOT NULL DEFAULT 0,
+ "createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT "FavoriteLink_widgetId_fkey" FOREIGN KEY ("widgetId") REFERENCES "WidgetInstance"("id") ON DELETE CASCADE
+ );
+ `);
+ await db.$executeRawUnsafe(`ALTER TABLE "FavoriteLink" ENABLE ROW LEVEL SECURITY;`);
+ await db.$executeRawUnsafe(`ALTER TABLE "FavoriteLink" FORCE ROW LEVEL SECURITY;`);
+ await db.$executeRawUnsafe(favoriteLinkPolicy);
+ await db.$executeRawUnsafe(
+ `GRANT SELECT, INSERT, UPDATE, DELETE ON "FavoriteLink" TO ${SCRATCH_ROLE_NAME}`,
+ );
+
+ // fav-a1-1/fav-a1-2 gehoeren user-a1 (TENANT-A, widget-a1); fav-a2-1
+ // gehoert dem KOLLEGEN user-a2 (TENANT-A, widget-a2, Befund G-Form);
+ // fav-b1-1 liegt unter TENANT-B (widget-b1). fav-a1-1 traegt eine
+ // iconUrl, fav-a1-2 nicht (Pitfall 3 des Bereichs).
+ await db.$executeRawUnsafe(`
+ INSERT INTO "FavoriteLink" (id, "userId", "tenantId", "widgetId", title, url, "iconUrl", position) VALUES
+ ('fav-a1-1', 'user-a1', 'TENANT-A', 'widget-a1', 'Favorit A1-1', 'https://example.invalid/a1-1', 'https://icons.invalid/a1-1.png', 0),
+ ('fav-a1-2', 'user-a1', 'TENANT-A', 'widget-a1', 'Favorit A1-2', 'https://example.invalid/a1-2', NULL, 1),
+ ('fav-a2-1', 'user-a2', 'TENANT-A', 'widget-a2', 'Favorit A2-1', 'https://example.invalid/a2-1', NULL, 0),
+ ('fav-b1-1', 'user-b1', 'TENANT-B', 'widget-b1', 'Favorit B1-1', 'https://example.invalid/b1-1', NULL, 0);
+ `);
+ });
+
+ // Pruefung 2 zuerst — faellt sie durch, sind die Client-Messungen (3-8)
+ // wertlos, deshalb steht sie vor ihnen und die Funktion bricht ab, wenn
+ // sie fehlschlaegt (Lehre aus Pruefung 8 im Bereich `calendar`).
+ const schemaFields = readSchemaModelScalarFieldNames('FavoriteLink');
+ const tableColumns = await withAdminPrisma(
+ urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
+ async (db) => {
+ const rows = await db.$queryRaw`
+ SELECT column_name FROM information_schema.columns
+ WHERE table_schema = 'public' AND table_name = 'FavoriteLink'
+ `;
+ return rows.map((r) => r.column_name).sort();
+ },
+ );
+ const schemaFieldsSorted = [...schemaFields].sort();
+ const columnsMatch =
+ schemaFieldsSorted.length > 0 &&
+ schemaFieldsSorted.length === tableColumns.length &&
+ schemaFieldsSorted.every((f, i) => f === tableColumns[i]);
+ report(
+ results,
+ 'favoritelink-wegwerftabelle-deckt-alle-spalten-des-generierten-clients',
+ columnsMatch,
+ `Schema-Felder aus schema.prisma (model FavoriteLink, skalare Felder ohne Relation, ${schemaFieldsSorted.length}): ${JSON.stringify(schemaFieldsSorted)}; Spalten der Wegwerf-Tabelle (${tableColumns.length}): ${JSON.stringify(tableColumns)}; gemessene "WidgetInstance"-Zeilen (Befund C, von runDashboardAreaChecks angelegt): ${JSON.stringify(widgetInstanceRows)}`,
+ );
+ if (!columnsMatch) {
+ return;
+ }
+
+ const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
+ try {
+ // 3: favoritelink-liste-generierter-client-ungebunden-liefert-leere-liste
+ // — die tragende Belegzeile dieses Abschnitts.
+ const unboundList = await prisma.favoriteLink.findMany({
+ where: { userId: 'user-a1', widgetId: 'widget-a1' },
+ orderBy: [{ position: 'asc' }, { title: 'asc' }],
+ });
+ report(
+ results,
+ 'favoritelink-liste-generierter-client-ungebunden-liefert-leere-liste',
+ unboundList.length === 0,
+ `ungebundenes prisma.favoriteLink.findMany({ where: { userId: 'user-a1', widgetId: 'widget-a1' }, orderBy: [{ position: 'asc' }, { title: 'asc' }] }) (die Form von list) liefert ${unboundList.length} Zeile(n), obwohl 2 tatsaechlich vorhanden sind — das ist der Wert, aus dem favorites-widget.tsx "Noch keine Favoriten." macht`,
+ );
+
+ // 4: favoritelink-liste-generierter-client-gebunden-eigener-mandant-liefert-eigene-zeilen
+ // — zwei Aussagen in einer Messung: die eigenen Zeilen kommen, UND die
+ // Regel kennt keine Benutzerdimension (die Zeile des Kollegen ist ueber
+ // ein gebundenes findMany auf DESSEN widgetId ebenfalls sichtbar).
+ const bound = buildInlineExtendedClient(prisma, 'TENANT-A');
+ const boundOwnList = await bound.favoriteLink.findMany({
+ where: { userId: 'user-a1', widgetId: 'widget-a1' },
+ orderBy: [{ position: 'asc' }, { title: 'asc' }],
+ });
+ const ownListOk =
+ boundOwnList.length === 2 &&
+ boundOwnList.every((r) => r.userId === 'user-a1' && r.widgetId === 'widget-a1');
+ const boundColleagueList = await bound.favoriteLink.findMany({ where: { widgetId: 'widget-a2' } });
+ const colleagueVisible =
+ boundColleagueList.length === 1 && boundColleagueList[0].userId === 'user-a2';
+ report(
+ results,
+ 'favoritelink-liste-generierter-client-gebunden-eigener-mandant-liefert-eigene-zeilen',
+ ownListOk && colleagueVisible,
+ `bound.favoriteLink.findMany unter TENANT-A liefert fuer (userId='user-a1', widgetId='widget-a1') ${boundOwnList.length} Zeile(n): ${JSON.stringify(boundOwnList.map((r) => r.id))} — die Zeile von user-a2 fehlt (anwendungsseitige Benutzerfilterung); ein gebundenes findMany({ where: { widgetId: 'widget-a2' } }) unter DEMSELBEN Mandanten liefert dagegen ${boundColleagueList.length} Zeile(n) des Kollegen user-a2 (${JSON.stringify(boundColleagueList.map((r) => r.id))}) — die Regel auf "FavoriteLink" kennt keine Benutzerdimension (dieselbe Lehre wie calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar), die anwendungsseitige userId-Filterung bleibt deshalb der einzige Schutz gegen Quer-Lesen zwischen Nutzern DESSELBEN Mandanten (Etappe-3-Entscheidung (2))`,
+ );
+
+ // 5: favoritelink-besitzpruefung-generierter-client-gebunden-fremder-mandant-liefert-null
+ // — die Datenbankseite der Vorpruefung in update/remove/getIconBytes (T-GWH-02).
+ const boundB = buildInlineExtendedClient(prisma, 'TENANT-B');
+ const foreignFind = await boundB.favoriteLink.findUnique({ where: { id: 'fav-a1-1' } });
+ report(
+ results,
+ 'favoritelink-besitzpruefung-generierter-client-gebunden-fremder-mandant-liefert-null',
+ foreignFind === null,
+ `bound.favoriteLink.findUnique({ where: { id: 'fav-a1-1' } }) unter TENANT-B (die Zeile gehoert TENANT-A) liefert ${JSON.stringify(foreignFind)} — das ist die Datenbankseite der Vorpruefung in update/remove/getIconBytes (T-GWH-02): fremde Zeile -> findUnique liefert null -> NotFoundException`,
+ );
+
+ // 6: favoritelink-gebundenes-loeschen-ueber-kennung-allein-fremder-mandant-scheitert-laut
+ // — der generierte Client meldet null getroffene Zeilen bei delete anders
+ // als Roh-SQL (dkv Befund G in der Client-Form): Konstruktorname/code
+ // woertlich, das Ergebnis wird nicht vorweggenommen.
+ let foreignDeleteThrew = false;
+ let foreignDeleteDetail = '';
+ try {
+ await boundB.favoriteLink.delete({ where: { id: 'fav-a1-1' } });
+ foreignDeleteDetail =
+ 'bound.favoriteLink.delete unter TENANT-B auf die unter TENANT-A liegende Zeile fav-a1-1 ist NICHT fehlgeschlagen';
+ } catch (err) {
+ foreignDeleteThrew = true;
+ const ctor = err?.constructor?.name ?? 'unbekannt';
+ foreignDeleteDetail = `bound.favoriteLink.delete unter TENANT-B auf die unter TENANT-A liegende, fuer TENANT-B unsichtbare Zeile fav-a1-1 wirft ${ctor}${err?.code ? ` (code ${err.code})` : ''}: ${(err.message ?? '').toString().trim()}`;
+ }
+ const stillThereAfterForeignDelete = await withAdminPrisma(
+ urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
+ async (db) => {
+ const rows = await db.$queryRaw`SELECT id FROM "FavoriteLink" WHERE id = 'fav-a1-1'`;
+ return rows.length === 1;
+ },
+ );
+ report(
+ results,
+ 'favoritelink-gebundenes-loeschen-ueber-kennung-allein-fremder-mandant-scheitert-laut',
+ foreignDeleteThrew && stillThereAfterForeignDelete,
+ `${foreignDeleteDetail} — die Wartungsrolle liest die Zeile danach noch: ${stillThereAfterForeignDelete}`,
+ );
+
+ // 7: favoritelink-fremdschluessel-prueft-am-zeilenschutz-vorbei — MISST,
+ // ob der Fremdschluessel auf "WidgetInstance" die Zeilenschutz-Regel
+ // dieser Tabelle umgeht (dokumentiertes PostgreSQL-Verhalten:
+ // referentielle Integritaet prueft AN der Regel vorbei). Das Ergebnis
+ // steuert Aufgabe 2 (Befund F), es wird NICHT vorweggenommen. Zweite
+ // Haelfte derselben Pruefung: ein gebundenes create mit einer
+ // WIRKLICH fehlenden widgetId MUSS an der FK-Verletzung scheitern — der
+ // Unterschied zwischen beiden Antworten ist das Existenzorakel (T-GWH-05).
+ const widgetB1UnderA = await bound.widgetInstance.findUnique({
+ where: { id: 'widget-b1' },
+ select: { userId: true },
+ });
+ let foreignWidgetCreateSucceeded = false;
+ let foreignWidgetCreateDetail = '';
+ try {
+ const created = await bound.favoriteLink.create({
+ data: {
+ id: 'fav-a1-fremdes-widget',
+ userId: 'user-a1',
+ tenantId: 'TENANT-A',
+ widgetId: 'widget-b1',
+ title: 'Fremdes Widget',
+ url: 'https://example.invalid/fremd',
+ position: 0,
+ },
+ });
+ foreignWidgetCreateSucceeded = Boolean(created);
+ foreignWidgetCreateDetail = `bound.favoriteLink.create unter TENANT-A mit widgetId='widget-b1' (gehoert TENANT-B, unter TENANT-A per gebundenem widgetInstance.findUnique unsichtbar: ${JSON.stringify(widgetB1UnderA)}) GELINGT (id=${created?.id}) — der Fremdschluessel prueft am Zeilenschutz VORBEI (dokumentiertes PostgreSQL-Verhalten)`;
+ // Die Zeile ist ein Messartefakt, nicht Teil des Bestands fuer die
+ // folgenden Pruefungen — ueber die Wartungsrolle wieder entfernen.
+ await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), (db) =>
+ db.$executeRawUnsafe(`DELETE FROM "FavoriteLink" WHERE id = 'fav-a1-fremdes-widget'`),
+ );
+ } catch (err) {
+ const ctor = err?.constructor?.name ?? 'unbekannt';
+ foreignWidgetCreateDetail = `bound.favoriteLink.create unter TENANT-A mit widgetId='widget-b1' (unter TENANT-A per gebundenem widgetInstance.findUnique unsichtbar: ${JSON.stringify(widgetB1UnderA)}) scheitert mit ${ctor}${err?.code ? ` (code ${err.code})` : ''}: ${(err.message ?? '').toString().trim()}`;
+ }
+ let missingWidgetCreateRejected = false;
+ let missingWidgetCreateDetail = '';
+ try {
+ await bound.favoriteLink.create({
+ data: {
+ id: 'fav-a1-widget-fehlt',
+ userId: 'user-a1',
+ tenantId: 'TENANT-A',
+ widgetId: 'widget-gibt-es-nicht',
+ title: 'Widget fehlt',
+ url: 'https://example.invalid/fehlt',
+ position: 0,
+ },
+ });
+ missingWidgetCreateDetail =
+ 'bound.favoriteLink.create unter TENANT-A mit widgetId="widget-gibt-es-nicht" ist NICHT fehlgeschlagen';
+ } catch (err) {
+ missingWidgetCreateRejected = true;
+ const ctor = err?.constructor?.name ?? 'unbekannt';
+ missingWidgetCreateDetail = `bound.favoriteLink.create unter TENANT-A mit widgetId="widget-gibt-es-nicht" scheitert mit ${ctor}${err?.code ? ` (code ${err.code})` : ''}: ${(err.message ?? '').toString().trim()} — die FK-Verletzung, das Gegenstueck zum Gelingen oben`;
+ }
+ report(
+ results,
+ 'favoritelink-fremdschluessel-prueft-am-zeilenschutz-vorbei',
+ missingWidgetCreateRejected,
+ `${foreignWidgetCreateDetail}; ${missingWidgetCreateDetail} — der Unterschied zwischen beiden Antworten ("gibt es nicht" scheitert, "liegt bei fremdem Mandanten" gelingt${foreignWidgetCreateSucceeded ? '' : ' NICHT, gemessen statt angenommen'}) ist das Existenzorakel (T-GWH-05); das Ergebnis der ersten Haelfte (Gelingen: ${foreignWidgetCreateSucceeded}) steuert, ob Aufgabe 2 einen Besitzriegel in create() baut`,
+ );
+
+ // 8: favoritelink-gebundenes-anlegen-eigener-mandant-gelingt
+ let ownCreateSucceeded = false;
+ let ownCreateDetail = '';
+ try {
+ const created = await bound.favoriteLink.create({
+ data: {
+ id: 'fav-a1-neu',
+ userId: 'user-a1',
+ tenantId: 'TENANT-A',
+ widgetId: 'widget-a1',
+ title: 'Neu angelegter Favorit',
+ url: 'https://example.invalid/neu',
+ position: 2,
+ },
+ });
+ const readBack = await withAdminPrisma(
+ urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
+ async (db) =>
+ db.$queryRaw`SELECT "tenantId", "createdAt", "updatedAt" FROM "FavoriteLink" WHERE id = 'fav-a1-neu'`,
+ );
+ ownCreateSucceeded =
+ readBack.length === 1 &&
+ readBack[0].tenantId === 'TENANT-A' &&
+ readBack[0].createdAt != null &&
+ readBack[0].updatedAt != null;
+ ownCreateDetail = `bound.favoriteLink.create unter TENANT-A mit widgetId='widget-a1' gelingt (id=${created.id}); die Wartungsrolle liest danach tenantId=${JSON.stringify(readBack[0]?.tenantId)}, createdAt=${JSON.stringify(readBack[0]?.createdAt)}, updatedAt=${JSON.stringify(readBack[0]?.updatedAt)} — bestaetigt nebenbei, dass die Wegwerf-Tabelle die clientseitig erzeugten Werte annimmt`;
+ } catch (err) {
+ ownCreateDetail = `bound.favoriteLink.create unter TENANT-A mit widgetId='widget-a1' ist fehlgeschlagen: ${err.message}`;
+ }
+ report(
+ results,
+ 'favoritelink-gebundenes-anlegen-eigener-mandant-gelingt',
+ ownCreateSucceeded,
+ ownCreateDetail,
+ );
+ } finally {
+ await prisma.$disconnect();
+ }
+}
+
+/**
+ * Aufgabe 1 (260911-gwh) — misst die neun im Plan genannten Verhaltensweisen
+ * des Bereichs `settings` unter der Rolle ohne BYPASSRLS, an der Regel
+ * WORTGLEICH aus der ausgelieferten Migration
+ * `20260909140000_rls_remaining_tenant_tables` geschnitten. Legt die
+ * Wegwerf-Tabelle "SmtpConfig" mit SAEMTLICHEN skalaren Spalten des Modells
+ * an UND mit dem Eindeutigkeitsindex `SmtpConfig_tenantId_key` WORTGLEICH
+ * aus `20260629130000_add_missing_tables` — ohne diesen Index misst
+ * Pruefung 8 nichts (der Konfliktweg braucht den physischen Index, nicht
+ * nur die Regel). Ein Blatt wie `runFavoritesAreaChecks`: muss NACH
+ * runAuthAreaChecks() und VOR runTransactionShapeMeasurement() laufen.
+ */
+async function runSettingsAreaChecks(adminUrl, scratchRoleUrl, results) {
+ const widenMigrationSql = readRlsWidenMigrationSql();
+ const widenHasOwnSmtpConfigPolicy =
+ widenMigrationSql && Boolean(extractPolicySql(widenMigrationSql, 'SmtpConfig'));
+ report(
+ results,
+ 'smtpconfig-regelstand-eindeutig',
+ !widenHasOwnSmtpConfigPolicy,
+ widenHasOwnSmtpConfigPolicy
+ ? 'die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt eine EIGENE Regel fuer "SmtpConfig" — der Regelstand ist nicht mehr eindeutig auf 20260909140000_rls_remaining_tenant_tables zurueckzufuehren, Messung abgebrochen statt die abgeloeste Regel weiterzumessen'
+ : 'die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt KEINE eigene Regel fuer "SmtpConfig" — der Stand aus 20260909140000_rls_remaining_tenant_tables ist weiterhin der ausgelieferte, aktuelle Regelstand',
+ );
+ if (widenHasOwnSmtpConfigPolicy) {
+ return;
+ }
+
+ const remainingMigrationSql = readRemainingTenantTablesMigrationSql();
+ const smtpConfigPolicy = remainingMigrationSql
+ ? extractPolicySql(remainingMigrationSql, 'SmtpConfig')
+ : null;
+
+ if (!smtpConfigPolicy) {
+ report(
+ results,
+ 'smtpconfig-policy-aus-migration-gefunden',
+ false,
+ 'CREATE POLICY fuer "SmtpConfig" nicht in der ausgelieferten *_rls_remaining_tenant_tables-Migration gefunden',
+ );
+ return;
+ }
+
+ await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), async (db) => {
+ await db.$executeRawUnsafe(`
+ CREATE TABLE "SmtpConfig" (
+ id text PRIMARY KEY,
+ "tenantId" text NOT NULL,
+ host text NOT NULL,
+ port integer NOT NULL DEFAULT 587,
+ encryption text NOT NULL DEFAULT 'starttls',
+ username text,
+ "encryptedPassword" text,
+ "fromAddress" text NOT NULL,
+ "createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
+ );
+ `);
+ // WORTGLEICH aus 20260629130000_add_missing_tables — ohne diesen Index
+ // misst Pruefung 8 nichts.
+ await db.$executeRawUnsafe(
+ `CREATE UNIQUE INDEX "SmtpConfig_tenantId_key" ON "SmtpConfig"("tenantId");`,
+ );
+ await db.$executeRawUnsafe(`ALTER TABLE "SmtpConfig" ENABLE ROW LEVEL SECURITY;`);
+ await db.$executeRawUnsafe(`ALTER TABLE "SmtpConfig" FORCE ROW LEVEL SECURITY;`);
+ await db.$executeRawUnsafe(smtpConfigPolicy);
+ await db.$executeRawUnsafe(
+ `GRANT SELECT, INSERT, UPDATE, DELETE ON "SmtpConfig" TO ${SCRATCH_ROLE_NAME}`,
+ );
+ await db.$executeRawUnsafe(`
+ INSERT INTO "SmtpConfig" (id, "tenantId", host, "encryptedPassword", "fromAddress") VALUES
+ ('smtp-a', 'TENANT-A', 'smtp-a.example.invalid', 'enc(a-passwort-platzhalter)', 'a@example.invalid'),
+ ('smtp-b', 'TENANT-B', 'smtp-b.example.invalid', 'enc(b-passwort-platzhalter)', 'b@example.invalid');
+ `);
+ });
+
+ // Pruefung 2 zuerst — dieselbe Reihenfolgeregel wie bei `favorites`.
+ const schemaFields = readSchemaModelScalarFieldNames('SmtpConfig');
+ const tableColumns = await withAdminPrisma(
+ urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
+ async (db) => {
+ const rows = await db.$queryRaw`
+ SELECT column_name FROM information_schema.columns
+ WHERE table_schema = 'public' AND table_name = 'SmtpConfig'
+ `;
+ return rows.map((r) => r.column_name).sort();
+ },
+ );
+ const schemaFieldsSorted = [...schemaFields].sort();
+ const columnsMatch =
+ schemaFieldsSorted.length > 0 &&
+ schemaFieldsSorted.length === tableColumns.length &&
+ schemaFieldsSorted.every((f, i) => f === tableColumns[i]);
+ const indexRows = await withAdminPrisma(
+ urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
+ async (db) => {
+ const rows = await db.$queryRaw`
+ SELECT indexdef FROM pg_indexes WHERE tablename = 'SmtpConfig' AND indexname = 'SmtpConfig_tenantId_key'
+ `;
+ return rows;
+ },
+ );
+ const indexOk = indexRows.length === 1;
+ report(
+ results,
+ 'smtpconfig-wegwerftabelle-deckt-alle-spalten-des-generierten-clients',
+ columnsMatch && indexOk,
+ `Schema-Felder aus schema.prisma (model SmtpConfig, skalare Felder ohne Relation, ${schemaFieldsSorted.length}): ${JSON.stringify(schemaFieldsSorted)}; Spalten der Wegwerf-Tabelle (${tableColumns.length}): ${JSON.stringify(tableColumns)}; Eindeutigkeitsindex "SmtpConfig_tenantId_key" ueber pg_indexes: ${JSON.stringify(indexRows.map((r) => r.indexdef))}`,
+ );
+ if (!columnsMatch || !indexOk) {
+ return;
+ }
+
+ const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
+ try {
+ // 3: smtpconfig-startpfad-generierter-client-ungebunden-liefert-null —
+ // die Form von loadAnySmtpConfigForStartupTransport(), ohne jede Bedingung.
+ const unboundStartup = await prisma.smtpConfig.findFirst();
+ report(
+ results,
+ 'smtpconfig-startpfad-generierter-client-ungebunden-liefert-null',
+ unboundStartup === null,
+ `ungebundenes prisma.smtpConfig.findFirst() (die Form von loadAnySmtpConfigForStartupTransport) liefert ${JSON.stringify(unboundStartup)}, obwohl 2 Zeilen existieren — das ist der Wert, mit dem mail.module.ts nach dem Scharfschalten auf Umgebungsvariablen und zuletzt localhost:1025 zurueckfaellt, ein falscher Transport statt einer Meldung`,
+ );
+
+ // 4: smtpconfig-startpfad-ueber-wartungsrolle-zieht-beliebige-zeile —
+ // die HEUTIGE Lage: dieselbe Abfrage ueber die Wartungsrolle liefert
+ // eine beliebige, aber vorhandene Zeile.
+ const adminStartupRow = await withAdminPrisma(
+ urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
+ async (db) => {
+ const rows = await db.$queryRaw`SELECT "tenantId" FROM "SmtpConfig" LIMIT 1`;
+ return rows[0];
+ },
+ );
+ report(
+ results,
+ 'smtpconfig-startpfad-ueber-wartungsrolle-zieht-beliebige-zeile',
+ Boolean(adminStartupRow),
+ `dieselbe Abfrage (SELECT ... LIMIT 1 ohne jede Bedingung) ueber die Wartungsrolle liefert genau EINE Zeile, tenantId=${JSON.stringify(adminStartupRow?.tenantId)} — nichts in der Abfrage bestimmt, WELCHER Mandant gezogen wird, und dessen Server und Absender tragen ab Start alle Kennwort-Zuruecksetzungs-Mails ALLER Mandanten (T-GWH-03)`,
+ );
+
+ // 5: smtpconfig-versandpfad-generierter-client-ungebunden-liefert-null —
+ // Befund K, der Versandpfad.
+ const unboundSendPath = await prisma.smtpConfig.findUnique({ where: { tenantId: 'TENANT-A' } });
+ report(
+ results,
+ 'smtpconfig-versandpfad-generierter-client-ungebunden-liefert-null',
+ unboundSendPath === null,
+ `ungebundenes prisma.smtpConfig.findUnique({ where: { tenantId: 'TENANT-A' } }) (die Form von getDecryptedSmtpConfig) liefert ${JSON.stringify(unboundSendPath)}, waehrend die Wartungsrolle die Zeile liest — Befund K: tender-mail.service.ts protokolliert "No SMTP configuration" und ueberspringt, dkv-mail.service.ts wirft; kein Versand fuer niemanden`,
+ );
+
+ // 6: smtpconfig-versandpfad-generierter-client-gebunden-eigener-mandant-liefert-zugangsdaten
+ const boundA = buildInlineExtendedClient(prisma, 'TENANT-A');
+ const boundOwn = await boundA.smtpConfig.findUnique({ where: { tenantId: 'TENANT-A' } });
+ report(
+ results,
+ 'smtpconfig-versandpfad-generierter-client-gebunden-eigener-mandant-liefert-zugangsdaten',
+ Boolean(boundOwn) &&
+ boundOwn.encryptedPassword === 'enc(a-passwort-platzhalter)' &&
+ boundOwn.host === 'smtp-a.example.invalid' &&
+ boundOwn.fromAddress === 'a@example.invalid',
+ `gebunden unter TENANT-A liefert findUnique({ where: { tenantId: 'TENANT-A' } }): host=${JSON.stringify(boundOwn?.host)}, fromAddress=${JSON.stringify(boundOwn?.fromAddress)}, encryptedPassword=${JSON.stringify(boundOwn?.encryptedPassword)}`,
+ );
+
+ // 7: smtpconfig-versandpfad-generierter-client-gebunden-fremder-mandant-liefert-null
+ // — T-GWH-01.
+ const boundB = buildInlineExtendedClient(prisma, 'TENANT-B');
+ const foreignSend = await boundB.smtpConfig.findUnique({ where: { tenantId: 'TENANT-A' } });
+ report(
+ results,
+ 'smtpconfig-versandpfad-generierter-client-gebunden-fremder-mandant-liefert-null',
+ foreignSend === null,
+ `gebunden unter TENANT-B liefert findUnique({ where: { tenantId: 'TENANT-A' } }) (gehoert TENANT-A): ${JSON.stringify(foreignSend)} — die verschluesselten Zugangsdaten von A sind fuer B unsichtbar (T-GWH-01)`,
+ );
+
+ // 8: smtpconfig-ungebundenes-upsert-auf-unsichtbare-zeile-scheitert-laut
+ // — die Form von saveSmtpConfig, UNGEBUNDEN. Konstruktorname und code
+ // woertlich, das Ergebnis wird NICHT vorweggenommen; die Belegausgabe
+ // haelt daneben, was 260910-krx fuer DashboardLayout gemessen hat.
+ let conflictThrew = false;
+ let conflictCtor = 'unbekannt';
+ let conflictCode;
+ let conflictMessage = '';
+ try {
+ await prisma.smtpConfig.upsert({
+ where: { tenantId: 'TENANT-A' },
+ create: {
+ id: 'smtp-a-neu',
+ tenantId: 'TENANT-A',
+ host: 'smtp-a-neu.example.invalid',
+ fromAddress: 'a@example.invalid',
+ },
+ update: { host: 'smtp-a-neu.example.invalid' },
+ });
+ } catch (err) {
+ conflictThrew = true;
+ conflictCtor = err?.constructor?.name ?? 'unbekannt';
+ conflictCode = err?.code;
+ conflictMessage = (err.message ?? '').toString().trim();
+ }
+ const hostAfterConflict = await withAdminPrisma(
+ urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
+ async (db) => {
+ const rows = await db.$queryRaw`SELECT host FROM "SmtpConfig" WHERE id = 'smtp-a'`;
+ return rows[0]?.host;
+ },
+ );
+ report(
+ results,
+ 'smtpconfig-ungebundenes-upsert-auf-unsichtbare-zeile-scheitert-laut',
+ conflictThrew && hostAfterConflict === 'smtp-a.example.invalid',
+ `ungebundenes prisma.smtpConfig.upsert({ where: { tenantId: 'TENANT-A' }, ... }) (die Form von saveSmtpConfig) wirft ${conflictCtor}${conflictCode ? ` (code ${conflictCode})` : ''}: ${conflictMessage} — zum Vergleich: 260910-krx mass fuer DashboardLayout unter dieser Form PrismaClientUnknownRequestError; die Wartungsrolle liest danach weiterhin host=${JSON.stringify(hostAfterConflict)}`,
+ );
+
+ // 9: smtpconfig-gebundenes-upsert-eigener-mandant-aktualisiert
+ let boundUpsertSucceeded = false;
+ let boundUpsertDetail = '';
+ try {
+ await boundA.smtpConfig.upsert({
+ where: { tenantId: 'TENANT-A' },
+ create: {
+ id: 'smtp-a-neu-2',
+ tenantId: 'TENANT-A',
+ host: 'smtp-a-gebunden-neu.example.invalid',
+ fromAddress: 'a@example.invalid',
+ },
+ update: { host: 'smtp-a-gebunden-neu.example.invalid' },
+ });
+ const afterBoundUpsert = await withAdminPrisma(
+ urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
+ async (db) => {
+ const rows = await db.$queryRaw`SELECT id, host, "updatedAt" FROM "SmtpConfig" WHERE "tenantId" = 'TENANT-A'`;
+ return rows[0];
+ },
+ );
+ const bUnchanged = await withAdminPrisma(
+ urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
+ async (db) => {
+ const rows = await db.$queryRaw`SELECT host FROM "SmtpConfig" WHERE id = 'smtp-b'`;
+ return rows[0]?.host;
+ },
+ );
+ boundUpsertSucceeded =
+ afterBoundUpsert?.id === 'smtp-a' &&
+ afterBoundUpsert?.host === 'smtp-a-gebunden-neu.example.invalid' &&
+ afterBoundUpsert?.updatedAt != null &&
+ bUnchanged === 'smtp-b.example.invalid';
+ boundUpsertDetail = `gebundenes upsert unter TENANT-A trifft die eigene Zeile (id=${afterBoundUpsert?.id}), die Wartungsrolle liest danach host=${JSON.stringify(afterBoundUpsert?.host)}, updatedAt=${JSON.stringify(afterBoundUpsert?.updatedAt)}; smtp-b bleibt unveraendert: ${JSON.stringify(bUnchanged)}`;
+ } catch (err) {
+ boundUpsertDetail = `gebundenes upsert unter TENANT-A ist fehlgeschlagen: ${err.message}`;
+ }
+ report(
+ results,
+ 'smtpconfig-gebundenes-upsert-eigener-mandant-aktualisiert',
+ boundUpsertSucceeded,
+ boundUpsertDetail,
+ );
+ } finally {
+ await prisma.$disconnect();
+ }
+}
+
/**
* Aufgabe 1 (260909-jts), TEIL 2 — misst, welche der drei Transaktionsformen
* den Mandantenkontext auf DERSELBEN Verbindung ueber alle Teilschritte
@@ -4015,6 +4590,8 @@ async function main() {
await runCalendarAreaChecks(adminUrl, scratchRoleUrlString, results);
await runTenantAreaChecks(adminUrl, scratchRoleUrlString, results);
await runAuthAreaChecks(adminUrl, scratchRoleUrlString, results);
+ await runFavoritesAreaChecks(adminUrl, scratchRoleUrlString, results);
+ await runSettingsAreaChecks(adminUrl, scratchRoleUrlString, results);
await runTransactionShapeMeasurement(scratchRoleUrlString, results);
await runConcurrencyProbe(scratchRoleUrlString, results);
} finally {
diff --git a/docs/mandantentrennung-etappe2-fehlerrichtung.md b/docs/mandantentrennung-etappe2-fehlerrichtung.md
index d9628f1..ce45caf 100644
--- a/docs/mandantentrennung-etappe2-fehlerrichtung.md
+++ b/docs/mandantentrennung-etappe2-fehlerrichtung.md
@@ -632,6 +632,14 @@ Warnung wäre Dauerlärm und verlöre ihr Signal.
(dieselbe Entlastung wie in (t3)). Das ist eine Reihenfolgebedingung für
Etappe 4, genau wie Befund D des `ldap`-Durchlaufs es für `groups` war —
hier festgehalten, nicht gelöst.
+
+ **Nachtrag (260911-gwh):** `getDecryptedSmtpConfig(tenantId)` läuft seit
+ Aufgabe 2 dieses Laufs über `forTenant()` (GENAU EIN Klient `tenantPrisma`
+ je Aufruf). Die Reihenfolgebedingung ist damit ERFÜLLT — siehe
+ `docs/mandantentrennung-zugriffsklassifikation.md`, Bestandsaufnahme-Zeile
+ `settings.service.ts`/`smtpConfig`, und den Hintergrunddienst-Abschnitt
+ dort. Die Etappe-4-Vorabprüfung muss diese Bedingung ab jetzt NICHT mehr
+ führen.
- **Die offene Architekturfrage `req.tenantPrisma`.** Auch der Bereich
`tenders` entscheidet sie nicht — er bindet dienst-intern, wie `ldap`
und `groups` es vormachen.
@@ -943,6 +951,19 @@ keine Zeile mehr — Ergebnis: kein Versand für niemanden, mit Wiederholung
bei jedem Lauf (die Datei bleibt lokal verfügbar, D-16). Reihenfolgebedingung
für Etappe 4, hier festgehalten, nicht gelöst.
+**Nachtrag (260911-gwh):** der `settings`-Teil dieser Übergabe ist erfüllt —
+`getDecryptedSmtpConfig(tenantId)` läuft seit Aufgabe 2 dieses Laufs über
+`forTenant()`, siehe die Bestandsaufnahme-Zeile
+`settings.service.ts`/`smtpConfig` in
+`docs/mandantentrennung-zugriffsklassifikation.md` und (s4)(b). Erneut
+gemessen: `dkv.seed.ts` ruft weiterhin
+`ModuleRegistryService.seedModule()` (`module-registry.service.ts:206`,
+`this.prisma.module.upsert`) — UNGEBUNDEN, aber bewusst und unverändert seit
+260910-exd, weil `Module` der plattformweite Modulkatalog ohne `tenantId`-
+Spalte ist (Befund E, `keine-mandantengebundene-tabelle`); "gebunden seit
+260910-exd" trifft auf diesen Zugriff NICHT zu, gemessen statt aus dem Plan
+abgeschrieben.
+
**Die offene Architekturfrage `req.tenantPrisma`.** Auch der Bereich `dkv`
entscheidet sie nicht — er bindet dienst-intern, wie `ldap`, `groups` und
`tenders` es vormachen.
@@ -2674,6 +2695,427 @@ Benutzers, unveraendert in diesem Plan.
`ldap.service.spec.ts` verweist — wird in Aufgabe 2 ersetzt, hier nur als
Befund F genannt.
+## Bereich favorites
+
+Dieser Abschnitt erweitert die Kritikschrift um den Bereich `favorites`
+(Quick-Task 260911-gwh, zusammen mit `settings` der LETZTE Lauf von Etappe 2)
+und beschreibt ihn zum Zeitpunkt seiner Umstellung. Die Leitfrage aus
+Abschnitt (a) gilt unverändert weiter. Anders als jeder Bereich davor hängt
+`FavoriteLink` über einen Fremdschlüssel an einer ZWEITEN mandantengebundenen
+Tabelle (`WidgetInstance`), deren Zeilenschutz-Regel der Fremdschlüssel auf
+Datenbankebene umgeht — eine Ausprägung von WINDOWS #27, hier zum ersten Mal
+ausdrücklich mitgebaut statt nur benannt.
+
+### (f1) Die Messung
+
+Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen weiteren
+Abschnitt (`runFavoritesAreaChecks`) erweitert, mit der Policy für
+`FavoriteLink` (aus der ausgelieferten Migration
+`20260909140000_rls_remaining_tenant_tables`) WORTGLEICH extrahiert, nicht im
+Werkzeug nachgetippt. Tatsächlich beobachtete Ausgabe dieses Laufs
+(2026-09-11, gegen `tessera-ctl-db-1`, Adresse `172.19.0.2`):
+
+```
+favoritelink-regelstand-eindeutig: bestanden — die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt KEINE eigene Regel fuer "FavoriteLink" — der Stand aus 20260909140000_rls_remaining_tenant_tables ist weiterhin der ausgelieferte, aktuelle Regelstand
+favoritelink-wegwerftabelle-deckt-alle-spalten-des-generierten-clients: bestanden — Schema-Felder aus schema.prisma (model FavoriteLink, skalare Felder ohne Relation, 10): ["createdAt","iconUrl","id","position","tenantId","title","updatedAt","url","userId","widgetId"]; Spalten der Wegwerf-Tabelle (10): [dieselben zehn]; gemessene "WidgetInstance"-Zeilen (Befund C, von runDashboardAreaChecks angelegt): [{"id":"widget-a1","userId":"user-a1","tenantId":"TENANT-A"},{"id":"widget-a2","userId":"user-a2","tenantId":"TENANT-A"},{"id":"widget-b1","userId":"user-b1","tenantId":"TENANT-B"}]
+favoritelink-liste-generierter-client-ungebunden-liefert-leere-liste: bestanden — ungebundenes prisma.favoriteLink.findMany({ where: { userId: 'user-a1', widgetId: 'widget-a1' }, orderBy: [{ position: 'asc' }, { title: 'asc' }] }) (die Form von list) liefert 0 Zeile(n), obwohl 2 tatsaechlich vorhanden sind — das ist der Wert, aus dem favorites-widget.tsx "Noch keine Favoriten." macht
+favoritelink-liste-generierter-client-gebunden-eigener-mandant-liefert-eigene-zeilen: bestanden — bound.favoriteLink.findMany unter TENANT-A liefert fuer (userId='user-a1', widgetId='widget-a1') 2 Zeile(n): ["fav-a1-1","fav-a1-2"] — die Zeile von user-a2 fehlt (anwendungsseitige Benutzerfilterung); ein gebundenes findMany({ where: { widgetId: 'widget-a2' } }) unter DEMSELBEN Mandanten liefert dagegen 1 Zeile(n) des Kollegen user-a2 (["fav-a2-1"]) — die Regel auf "FavoriteLink" kennt keine Benutzerdimension
+favoritelink-besitzpruefung-generierter-client-gebunden-fremder-mandant-liefert-null: bestanden — bound.favoriteLink.findUnique({ where: { id: 'fav-a1-1' } }) unter TENANT-B (die Zeile gehoert TENANT-A) liefert null
+favoritelink-gebundenes-loeschen-ueber-kennung-allein-fremder-mandant-scheitert-laut: bestanden — bound.favoriteLink.delete unter TENANT-B auf die unter TENANT-A liegende Zeile fav-a1-1 wirft PrismaClientKnownRequestError (code P2025): No record was found for a delete. — die Wartungsrolle liest die Zeile danach noch: true
+favoritelink-fremdschluessel-prueft-am-zeilenschutz-vorbei: bestanden — bound.favoriteLink.create unter TENANT-A mit widgetId='widget-b1' (gehoert TENANT-B, unter TENANT-A per gebundenem widgetInstance.findUnique unsichtbar: null) GELINGT (id=fav-a1-fremdes-widget) — der Fremdschluessel prueft am Zeilenschutz VORBEI (dokumentiertes PostgreSQL-Verhalten); bound.favoriteLink.create unter TENANT-A mit widgetId="widget-gibt-es-nicht" scheitert mit PrismaClientKnownRequestError (code P2003): Foreign key constraint violated — der Unterschied zwischen beiden Antworten ist das Existenzorakel (T-GWH-05)
+favoritelink-gebundenes-anlegen-eigener-mandant-gelingt: bestanden — bound.favoriteLink.create unter TENANT-A mit widgetId='widget-a1' gelingt (id=fav-a1-neu); die Wartungsrolle liest danach tenantId="TENANT-A", createdAt und updatedAt gesetzt
+Alle 137 Pruefungen bestanden.
+```
+
+Die tragende Belegzeile ist `favoritelink-liste-generierter-client-ungebunden-liefert-leere-liste`: der
+IDENTISCHE `findMany`, den `list` heute stellt, liefert UNGEBUNDEN `[]`,
+während die Wartungsrolle zwei Zeilen sieht — das ist der Wert, aus dem
+`favorites-widget.tsx` `Noch keine Favoriten.` macht (siehe (f3)).
+
+Der Fremdschlüssel ist als mitgebaute Relation gemessen, nicht nur behauptet
+(Befund C, WINDOWS #27): die Wegwerf-Tabelle `"FavoriteLink"` trägt
+`FOREIGN KEY ("widgetId") REFERENCES "WidgetInstance"("id") ON DELETE CASCADE`
+auf die von `runDashboardAreaChecks` bereits angelegte Tabelle; vorher wurde
+über die Wartungsrolle gemessen, welche `WidgetInstance`-Zeilen tatsächlich
+stehen (drei, siehe Belegausgabe von Prüfung 2), statt sie anzunehmen.
+
+Das Ergebnis von Prüfung 7 (`favoritelink-fremdschluessel-prueft-am-zeilenschutz-vorbei`)
+ist zweigeteilt und trägt eine Entscheidung für Aufgabe 2: ein gebundenes
+`create` unter TENANT-A mit `widgetId` eines TENANT-B-Widgets GELINGT — der
+Fremdschlüssel prüft an der Zeilenschutz-Regel von `WidgetInstance` VORBEI
+(dokumentiertes PostgreSQL-Verhalten: referentielle Integrität umgeht Row
+Security). Derselbe Aufruf mit einer wirklich fehlenden `widgetId` scheitert
+dagegen laut mit einer FK-Verletzung (Code P2003). Der Unterschied zwischen
+beiden Antworten — "existiert nicht" (500/Fehler) vs. "gehört einem fremden
+Mandanten" (gelingt) — ist ein Existenzorakel über Mandantengrenzen
+(T-GWH-05, medium). Aufgabe 2 baut deshalb einen anwendungsseitigen
+Besitzriegel in `create`, der beide Fälle auf dieselbe Antwort
+(`Widget not found`) abbildet.
+
+### (f2) Signaltabelle je umzustellendem Pfad
+
+| Pfad | Verhalten HEUTE nach dem Scharfschalten (ohne diesen Plan) | Konkretes Signal | Frontend lässt es durch? |
+|---|---|---|---|
+| `list` (`GET /favorites?widgetId=`) | Ungebundener `findMany` liefert `[]` statt der eigenen Zeilen | `200 []` | Ja — `fetchFavorites` (`favorites-api.ts:22-29`) gibt `[]` durch, siehe (f3) |
+| `create` (`POST /favorites`) | Ungebundenes `create` schreibt eine Zeile, die unter der Mandantenkennung des Aufrufers physisch korrekt liegt, aber der Icon-Suchpfad ist unverändert; der Riegel aus Aufgabe 2 prüft VOR dem Schreiben, ob `widgetId` existiert und dem Aufrufer gehört | `NotFoundException('Widget not found')`, 404, wenn das Widget nicht dem Aufrufer gehört | Ja — `createFavorite` (`favorites-api.ts:33-46`) wirft `Failed to create favorite` bei `!res.ok` |
+| `update` (`PATCH /favorites/:id`) | Ungebundener `findUnique` liefert `null` statt der eigenen Zeile, Vorprüfung greift bereits heute (userId-Vergleich) | `NotFoundException('FavoriteLink not found')`, 404 | Ja — `updateFavorite` wirft `Failed to update favorite` |
+| `remove` (`DELETE /favorites/:id`) | Dieselbe Form wie `update` | `NotFoundException('FavoriteLink not found')`, 404 | Ja — `deleteFavorite` wirft `Failed to delete favorite` |
+| `getIconBytes` (`GET /favorites/:id/icon`) | Dieselbe Vorprüfung wie `update`/`remove` | `NotFoundException('FavoriteLink not found')`, 404 | Ja — `
`-Ladefehler, vom Widget nicht gesondert behandelt |
+
+### (f3) Welcher Code Leere als Abwesenheit deutet
+
+Die `list`-Kette, alle Glieder namentlich: `list` liefert `[]` (die Belegzeile
+`favoritelink-liste-generierter-client-ungebunden-liefert-leere-liste`) →
+`GET /favorites?widgetId=` antwortet `200 []` → `fetchFavorites`
+(`favorites-api.ts:22-29`) prüft nur `!res.ok` (bei `200` erfüllt das nicht)
+und gibt `[]` durch → `favorites-widget.tsx:212-213` zeigt
+`t('favorites.empty')` = **`Noch keine Favoriten.`** (`de.json`, Zeile 219).
+"Zeile unsichtbar" und "nie einen gespeichert" sind für das Frontend
+derselbe Wert `[]` — das ist NICHT die Familie eines lauten Fehlers, sondern
+dieselbe Familie wie WINDOWS #23/#25/#26/#28 (module-registry, dashboard,
+calendar, auth).
+
+`update`/`remove`/`getIconBytes` deuten Leere dagegen LAUT: die
+Vorprüfung (`findUnique` → `null` oder fremder `userId`) wirft
+`NotFoundException('FavoriteLink not found')`, 404 — `favorites-api.ts`
+übersetzt das in `Failed to update/delete favorite`, das Widget setzt
+`t('favorites.error')` im `catch`. Diese Richtung ist harmlos, weil ein zu
+kleines Ergebnis dort bereits heute einen Fehler auslöst, der nicht mit dem
+Scharfschalten neu entsteht.
+
+### (f4) Was dieser Durchlauf bewusst nicht löst
+
+- **(a) Die fehlende Benutzerdimension der Regel.** Prüfung 4
+ (`favoritelink-liste-generierter-client-gebunden-eigener-mandant-liefert-eigene-zeilen`)
+ zeigt zweigeteilt: die eigenen Zeilen kommen korrekt, UND ein gebundenes
+ `findMany` auf die `widgetId` eines Kollegen DESSELBEN Mandanten liefert
+ dessen Zeile ebenfalls — die Regel auf `FavoriteLink` kennt keine
+ Benutzerdimension (dieselbe Lehre wie bei `CalendarSource`,
+ `DashboardLayout`, `WidgetInstance`). Die anwendungsseitige
+ `userId`-Filterung bleibt bestehen und ist bis zur Etappe-3-Entscheidung
+ (2) der einzige Schutz gegen Quer-Lesen zwischen Nutzern desselben
+ Mandanten.
+- **(b) Das Frontend.** Die `list`-Kette aus (f3) wird nicht geändert;
+ Ledger-Eintrag in Aufgabe 3.
+- **(c) Die Mandantenquelle — warum der dashboard-Präzedenzfall und nicht
+ der auth-Präzedenzfall.** `favorites.controller.ts` `extractContext` liest
+ `req.tenantId ?? req.user?.tenantId` — WORTGLEICH mit
+ `dashboard.controller.ts`, unter dessen Bindung `WidgetInstance` liegt.
+ `FavoriteLink` hängt über `widgetId` an `WidgetInstance`; würde
+ `favorites` stattdessen an das Claim binden (wie `auth` für
+ Selbstbedienung), während `dashboard` an der Guard-Kennung bleibt, lägen
+ Widget und Link unter einem `x-tenant-id`-Wechsel eines SUPER_ADMIN in
+ verschiedenen Mandanten. `favorites-api.ts` sendet die Kopfzeile heute
+ nicht (`grep -rn "x-tenant-id" apps/web/src`: nur die vier
+ Marktplatz-Stellen) — die Entscheidung hängt an der Bauform, nicht am
+ heutigen Aufrufer.
+- **(d) Die Etappe-4-Vorabprüfung.** Für einen bekannten Nutzer/Widget die
+ Favoritenzahl über die Wartungsrolle und über den gebundenen `findMany`
+ daneben halten — dieselbe Form wie bei `dashboard`/`calendar`.
+
+### (f5) Was dieser Durchlauf bewusst nicht anfasst
+
+- Der Icon-Proxy (`icon-discovery.service.ts`, Befund G) — erreicht die
+ Datenbank NICHT und nimmt KEINE Client-URL: `getIconBytes` holt nur die
+ GESPEICHERTE `iconUrl` einer Zeile, die der Aufrufer besitzt (T-QFIP-01);
+ `discoverFavoriteIconUrl` nimmt die Nutzer-URL nur für den
+ SSRF-gesicherten Abruf (T-08-05). Kein Mandantenbezug — unverändert, in
+ der Testdatei eine Attrappe.
+- Die DTOs (`create-favorite.dto.ts`, `update-favorite.dto.ts`) — nur
+ gelesen, kein Mandantenfeld.
+- `dashboard.controller.ts` — nur gelesen (Präzedenzfall für (f4)(c)).
+- Das Frontend (`favorites-widget.tsx`, `favorites-api.ts`) — nur
+ beschrieben, nicht geändert.
+- Schema und Migrationen.
+
+## Bereich settings
+
+Dieser Abschnitt erweitert die Kritikschrift um den Bereich `settings`
+(Quick-Task 260911-gwh) und beschreibt ihn zum Zeitpunkt seiner Umstellung.
+Anders als jeder Bereich davor trägt dieser Bereich die Reihenfolgebedingung
+Befund K aus den Läufen `tenders` und `dkv`: `getDecryptedSmtpConfig(tenantId)`
+ist der einzige Versandpfad für Ausschreibungs- und DKV-Mails. Zusätzlich
+trägt der Startpfad des Mailmoduls den SECHSTEN Fall der
+Hintergrunddienst-Falle — anders als bei `dkv` (WINDOWS #21) verdeckt hier
+eine Rückfallkette das Verstummen mit einem falschen Transport statt
+schlichter Leere.
+
+### (s1) Die Messung
+
+Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen weiteren
+Abschnitt (`runSettingsAreaChecks`) erweitert, mit der Policy für
+`SmtpConfig` (aus der ausgelieferten Migration
+`20260909140000_rls_remaining_tenant_tables`) WORTGLEICH extrahiert. Die
+Wegwerf-Tabelle trägt zusätzlich den Eindeutigkeitsindex
+`SmtpConfig_tenantId_key` WORTGLEICH aus `20260629130000_add_missing_tables`
+— ohne ihn misst Prüfung 8 nichts. Tatsächlich beobachtete Ausgabe dieses
+Laufs (2026-09-11, gegen `tessera-ctl-db-1`, Adresse `172.19.0.2`):
+
+```
+smtpconfig-regelstand-eindeutig: bestanden — die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt KEINE eigene Regel fuer "SmtpConfig" — der Stand aus 20260909140000_rls_remaining_tenant_tables ist weiterhin der ausgelieferte, aktuelle Regelstand
+smtpconfig-wegwerftabelle-deckt-alle-spalten-des-generierten-clients: bestanden — Schema-Felder aus schema.prisma (model SmtpConfig, skalare Felder ohne Relation, 10): ["createdAt","encryptedPassword","encryption","fromAddress","host","id","port","tenantId","updatedAt","username"]; Spalten der Wegwerf-Tabelle (10): [dieselben zehn]; Eindeutigkeitsindex "SmtpConfig_tenantId_key" ueber pg_indexes: ["CREATE UNIQUE INDEX \"SmtpConfig_tenantId_key\" ON public.\"SmtpConfig\" USING btree (\"tenantId\")"]
+smtpconfig-startpfad-generierter-client-ungebunden-liefert-null: bestanden — ungebundenes prisma.smtpConfig.findFirst() (die Form von loadAnySmtpConfigForStartupTransport) liefert null, obwohl 2 Zeilen existieren — das ist der Wert, mit dem mail.module.ts nach dem Scharfschalten auf Umgebungsvariablen und zuletzt localhost:1025 zurueckfaellt, ein falscher Transport statt einer Meldung
+smtpconfig-startpfad-ueber-wartungsrolle-zieht-beliebige-zeile: bestanden — dieselbe Abfrage ueber die Wartungsrolle liefert genau EINE Zeile, tenantId="TENANT-A" — nichts in der Abfrage bestimmt, WELCHER Mandant gezogen wird
+smtpconfig-versandpfad-generierter-client-ungebunden-liefert-null: bestanden — ungebundenes prisma.smtpConfig.findUnique({ where: { tenantId: 'TENANT-A' } }) (die Form von getDecryptedSmtpConfig) liefert null, waehrend die Wartungsrolle die Zeile liest
+smtpconfig-versandpfad-generierter-client-gebunden-eigener-mandant-liefert-zugangsdaten: bestanden — gebunden unter TENANT-A liefert findUnique: host="smtp-a.example.invalid", fromAddress="a@example.invalid", encryptedPassword="enc(a-passwort-platzhalter)"
+smtpconfig-versandpfad-generierter-client-gebunden-fremder-mandant-liefert-null: bestanden — gebunden unter TENANT-B liefert findUnique({ where: { tenantId: 'TENANT-A' } }): null
+smtpconfig-ungebundenes-upsert-auf-unsichtbare-zeile-scheitert-laut: bestanden — ungebundenes prisma.smtpConfig.upsert(...) (die Form von saveSmtpConfig) wirft PrismaClientUnknownRequestError: ConnectorError ... SQLSTATE 42501, "new row violates row-level security policy for table \"SmtpConfig\"" — die Wartungsrolle liest danach weiterhin host="smtp-a.example.invalid"
+smtpconfig-gebundenes-upsert-eigener-mandant-aktualisiert: bestanden — gebundenes upsert unter TENANT-A trifft die eigene Zeile (id=smtp-a), die Wartungsrolle liest danach den neuen Host, updatedAt gesetzt; smtp-b bleibt unveraendert
+Alle 137 Pruefungen bestanden.
+```
+
+Die tragenden Belegzeilen sind drei: `smtpconfig-startpfad-generierter-client-ungebunden-liefert-null`
+für den Startpfad, `smtpconfig-versandpfad-generierter-client-ungebunden-liefert-null`
+für Befund K, und `smtpconfig-ungebundenes-upsert-auf-unsichtbare-zeile-scheitert-laut`
+für den Speicherkonflikt. Prüfung 8 wirft — gemessen, nicht angenommen —
+**`PrismaClientUnknownRequestError`**, dieselbe Fehlerklasse, die 260910-krx
+für `DashboardLayout` gemessen hat (nicht `PrismaClientKnownRequestError`
+mit `P2002`, die Form der Bereiche `tenders`/`user`): die Regel weist den
+Schreibzugriff ab, bevor eine Eindeutigkeit überhaupt geprüft wird. Die
+zugrundeliegende PostgreSQL-Meldung (SQLSTATE `42501`, "new row violates
+row-level security policy") ist in der Belegausgabe wörtlich enthalten.
+
+### (s2) Signaltabelle je Pfad
+
+| Pfad | Verhalten HEUTE nach dem Scharfschalten (ohne diesen Plan) | Signal | Frontend |
+|---|---|---|---|
+| `getSmtpConfig` (`GET /settings/smtp`) | Ungebundener `findUnique` liefert `null` statt der eigenen Zeile | Controller gibt `null` → NestJS sendet `200` mit LEEREM Rumpf | Ja — verschluckt, siehe (s3) |
+| `saveSmtpConfig` (`PUT /settings/smtp`) | Ungebundenes `upsert` scheitert am Eindeutigkeitsindex, weil die physisch vorhandene Zeile unsichtbar ist | `PrismaClientUnknownRequestError`, 500 | Nein — kein spezifischer Übersetzungspfad im Controller, roher 500 |
+| `getDecryptedSmtpConfig` — Aufrufer `tender-mail.service.ts` (`resolveTransport`) | Ungebundener `findUnique` liefert `null` | `logger.warn('No SMTP configuration ... skipping tender mail send (will retry next run)')`, KEIN Wurf, Digest übersprungen | — (Hintergrunddienst, kein Frontend-Pfad) |
+| `getDecryptedSmtpConfig` — Aufrufer `dkv-mail.service.ts` (`sendExportEmail`) | Dieselbe Form | `throw new Error('No SMTP configuration found for tenant ...')` | — (Hintergrunddienst) |
+| `testSmtpConfig` (`POST /settings/smtp/test`) | Rückgriff auf `getDecryptedSmtpConfig` liefert `null`, `password`/`username` bleiben `undefined` (falls DTO leer) | Verbindungstest scheitert am Postfach, `{ success: false }` — irreführend, zeigt auf das Postfach statt auf die Datenbank | Ja — Testergebnis wird angezeigt |
+| Startpfad `loadAnySmtpConfigForStartupTransport()` (`mail.module.ts`, beim Start) | Ungebundenes `findFirst()` liefert `null` statt einer beliebigen Zeile; die Rückfallkette von `mail.module.ts` greift (Priorität 2-4, zuletzt `localhost:1025`) — EIGENE Spalte, siehe (s4)(a) | Kein Fehler, ein FALSCHER, aber vorhandener Transport; `MailService` fängt jeden Transportfehler (T-02-12), Controller antwortet `200` | Ja — doppelt verdeckt |
+
+### (s3) Welcher Code Leere als Abwesenheit deutet
+
+Die `getSmtpConfig`-Kette, alle Glieder namentlich: `getSmtpConfig` liefert
+`null` (Belegzeile `smtpconfig-versandpfad-generierter-client-ungebunden-liefert-null`
+zeigt dieselbe Form für den Versandpfad) → `settings.controller.ts` gibt
+`null` zurück, ohne zu werfen → NestJS' `ExpressAdapter.reply` sendet bei
+`isNil(body)` einen LEEREN Rumpf mit Status `200` (dieselbe Adapter-Kette wie
+in (h3), 260911-fh9) → `fetchSmtp` (`settings-api.ts:51-57`) prüft nur
+`res.status === 404` (nicht erfüllt) und `!res.ok` (bei `200` nicht erfüllt),
+dann `res.json()` auf den leeren Rumpf → wirft → `smtp-settings-form.tsx:76-78`
+`.catch(() => { /* Silent fail */ })` → leeres Formular: "SMTP nicht
+eingerichtet", während die Zugangsdaten physisch noch da sind. "Nicht
+eingerichtet" und "Zeile unsichtbar" sind für das Frontend derselbe Zustand
+— dieselbe Familie wie WINDOWS #23/#25/#26/#28.
+
+Trägt der Administrator die Zugangsdaten unter diesem Eindruck neu ein, läuft
+`saveSmtpConfig` als `upsert({ where: { tenantId } })`: unter der
+UNGEBUNDENEN Form ist die Zeile unsichtbar, der Upsert versucht ein `INSERT`
+und scheitert am Eindeutigkeitsindex `SmtpConfig_tenantId_key` —
+`PrismaClientUnknownRequestError` (Prüfung 8, gemessen statt vorweggenommen;
+dieselbe Fehlerklasse wie die `dashboard`-Lehre aus 260910-krx, NICHT `P2002`).
+
+Die beiden Versandpfade reagieren unterschiedlich auf `null`
+(`getDecryptedSmtpConfig`): `tender-mail.service.ts` protokolliert eine
+Warnung und überspringt den Versand (Wiederholung beim nächsten Lauf),
+`dkv-mail.service.ts` wirft einen Fehler, der die aufrufende Pipeline
+abbricht — beide Formen sind bereits vor diesem Lauf so verdrahtet, ändern
+sich hier nicht.
+
+### (s4) Was dieser Durchlauf bewusst nicht löst
+
+**(a) Der Startpfad — der sechste Fall der Hintergrunddienst-Falle,
+ausgeschrieben statt still getroffen.** `mail.module.ts`'s
+`MailerModule.forRootAsync({ useFactory: async ... })` ruft
+`SettingsService.loadAnySmtpConfigForStartupTransport()`
+(vormals `getStartupSmtpConfig()`) BEIM START, vor jedem Anfragekontext.
+Zwei Zustände, beide gehören benannt:
+
+- **Heute** ist die Abfrage bereits FALSCH, nicht nur ungenau: bei mehreren
+ Mandanten trägt der SMTP-Server und die Absenderadresse EINES beliebigen
+ Mandanten die Kennwort-Zurücksetzungs- und Willkommensmails ALLER
+ Mandanten (T-GWH-03, Nutzung fremder Zugangsdaten, nicht nur Sichtbarkeit).
+- **Nach dem Scharfschalten** liefert `findFirst()` `null` →
+ `mail.module.ts` fällt auf Priorität 2 (`MAIL_*`), 3 (`TESSERA_SMTP_*`),
+ zuletzt 4 (`localhost:1025`, Mailhog) zurück → `MailService` fängt jeden
+ Transportfehler (`mail.service.ts:69-81`, T-02-12) und der Controller
+ antwortet `200`. Das Verstummen ist damit DOPPELT verdeckt: erst durch die
+ Rückfallkette (ein falscher, aber vorhandener Transport statt Leere), dann
+ durch das absichtliche Verschlucken im Versand.
+
+Drei geprüfte Formen: **(a) An einen konkret aufgelösten Mandanten binden**
+— nicht möglich, `useFactory` hat beim Start keinen Anfragekontext.
+**(b) Umbau auf Transport je Versand** — abgelehnt als Funktion für DIESEN
+Lauf, mit Grund: die Vorlage steht bereits in `DkvMailService`/
+`TenderMailService` (Transport je Versand aus
+`getDecryptedSmtpConfig(tenantId)`), `MailService` müsste dafür nur den
+Mandanten entgegennehmen, den `requestPasswordReset` aus der Funktionszeile
+bereits hat — ein Umbau des Mailmoduls, kein Bindungsumbau, NICHT dieser
+Auftrag (siehe ``). **(c) Als benannte Altlast
+weiterführen, mit Markierung** — GEWÄHLT: Methode umbenannt
+(`loadAnySmtpConfigForStartupTransport()`, dkv-Präzedenzfall — ein Name, den
+niemand für einen Anfrageweg hält), Kopfkommentar mit beiden Zuständen,
+Modulkommentar in `mail.module.ts`, eigener Ledger-Eintrag.
+
+**Die Unsymmetrie zu BEIDEN Präzedenzfällen:** `getAllActiveConfigs` (ldap)
+ist heute korrekt und verstummt erst später; `loadAnyActiveConfigForScheduler`
+(dkv, WINDOWS #21) ist heute bereits falsch und verstummt zusätzlich später,
+ABER mit einer Protokollzeile ("no active config found — cron job not
+registered"). Der Mail-Startpfad ist heute bereits falsch UND verstummt
+später OHNE Protokollzeile, weil die Rückfallkette ihn überdeckt — das ist
+eine DRITTE Ausprägung, keine der beiden Vorlagen deckt sie vollständig.
+
+**Entscheidung: EIGENER Ledger-Eintrag statt Anschluss an #21.** Andere
+Datei (`mail.module.ts`/`settings.service.ts` statt
+`dkv-scheduler.service.ts`), andere Reparatur (Transport je Versand statt
+Mehrmandanten-Planung), andere Verdeckungsform (Rückfallkette statt bloßer
+Leere) — drei eigenständige Unterschiede, kein Wiederholungsfall von #21.
+
+**(b) Befund K ist erfüllt.** Die Reihenfolgebedingung aus (t4) Befund K und
+(d4) Übergaben-Absatz — `getDecryptedSmtpConfig(tenantId)` müsse gebunden
+sein, bevor Etappe 4 scharfschaltet — ist mit Aufgabe 2 dieses Laufs
+ERFÜLLT: die Methode läuft seither über GENAU EINEN Klienten `tenantPrisma`.
+Beide Stellen bekommen in Aufgabe 3 einen Nachtrag (siehe (t4), (d4) unten in
+diesem Dokument sowie den Hintergrunddienst-Abschnitt in
+`docs/mandantentrennung-zugriffsklassifikation.md`). Die Etappe-4-Vorabprüfung
+muss diese Bedingung ab jetzt NICHT mehr führen.
+
+**(c) Das Frontend.** Die `getSmtpConfig`-Kette aus (s3) wird nicht
+geändert; Ledger-Eintrag in Aufgabe 3 (Familie #28 — dieselbe
+200-leerer-Rumpf-Kette wie `auth`).
+
+**(d) Die Mandantenquelle `req.tenantId` im Controller.** Bewusst NICHT auf
+das Claim umgestellt (anders als die Selbstbedienungs-Begründung aus
+`auth`): `settings.controller.ts` ist eine ADMIN-Konfigurationsseite, für
+die `req.tenantId` (per `x-tenant-id` für SUPER_ADMIN umschaltbar) die
+RICHTIGE Quelle ist (D-10) — ein SUPER_ADMIN konfiguriert damit gezielt die
+SMTP-Zugangsdaten eines ANDEREN Mandanten. Der Controller bleibt deshalb
+unverändert und steht nicht in der Erlaubnisliste.
+
+**(e) Die Etappe-4-Vorabprüfung.** Für einen bekannten Mandanten die
+`SmtpConfig`-Zeile über die Wartungsrolle lesen und den gebundenen
+`findUnique` daneben halten — dieselbe Form wie bei `dashboard`/`calendar`.
+
+### (s5) Was dieser Durchlauf bewusst nicht anfasst
+
+- `settings.controller.ts` — nur gelesen (siehe (s4)(d)).
+- `apps/api/src/settings/dto/smtp-config.dto.ts` — nur gelesen, kein
+ Mandantenfeld.
+- `tender-mail.service.ts` (`resolveTransport`) — nur gelesen, ruft
+ weiterhin `getDecryptedSmtpConfig(tenantId)` unverändert auf.
+- `dkv-mail.service.ts` (`sendExportEmail`) — dieselbe Form.
+- `mail.service.ts` — nur gelesen; `mail.module.ts` wird für die
+ Umbenennung des Startpfad-Aufrufs angefasst (Aufgabe 2), hier nur
+ angekündigt.
+- Das Frontend (`smtp-settings-form.tsx`, `settings-api.ts`) — nur
+ beschrieben, nicht geändert.
+- Schema und Migrationen.
+- `nodemailer` — kein echter Transport in irgendeinem Test dieses Laufs
+ (lokal gibt es keinen `mailhog`); in der neuen Testdatei per
+ `vi.mock('nodemailer')` ersetzt.
+
+## Etappe 2 — Abschluss
+
+Etappe 2 der Mandantentrennung ist mit diesem Lauf (260911-gwh) vollständig:
+jede klassifizierte Fundstelle in `apps/api/src` ist entweder gebunden oder
+mit geschriebenem Grund an der Stelle ungebunden. Die Zahlen unten sind aus
+den eigenen Messanweisungen des Klassifikationsdokuments abgeleitet
+(Übersichtszeilen, Summenzeile, Klassen-Verteilung), nicht neu geschätzt.
+
+**Läufe.** Zwölf Bereichs-/Regel-Läufe von 260909-ipc bis 260911-gwh (gezählt
+aus `.planning/STATE.md`, Reihenfolge): `ldap` (260909-ipc), `groups`
+(260909-jts), `tenders` (260909-laa), `dkv` (260909-mir), `user`
+(260910-das), `module-registry` (260910-exd), `dashboard` (260910-krx), das
+Regelschluss-Plan `tenders`/`SearchProvider` (260910-jab), `tenant`
+(260911-e2s), `calendar` (260911-cwh), `auth` (260911-fh9), `favorites`/
+`settings` (260911-gwh, dieser Lauf).
+
+**Summenzeile der Übersichtstabelle, vorher/nachher.** Zum Kopf des
+Klassifikationsdokuments (Stand 260909-eor): 227 Rohtreffer über 59
+Datei-Modell-Paare. Nach Aufgabe 3 dieses Laufs (siehe Summenzeile in
+`docs/mandantentrennung-zugriffsklassifikation.md`, DERIVIERT aus den
+Bereichszeilen, nicht abgeschrieben): siehe dortige Summenzeile für die
+Endzahl.
+
+**Klassen-Verteilung.** Siehe `docs/mandantentrennung-zugriffsklassifikation.md`,
+Abschnitt "Klassen-Verteilung", Stand 260911-gwh (Aufgabe 3) für die
+Paarzahl und die Verteilung nach den vier Klassen.
+
+**Bewusst ungebundene Reste je Bereich, mit Grund:**
+
+- `tenders`: der D-03-Katalog (platform-global, `Tender`/`TenderSource`/
+ `TenderSourcePollConfig`), die zwei Fan-out-Adapter (E-Mail/RSS, ein Tick
+ pro Postfach bzw. Feed über alle Mandanten), die übergreifenden Hälften
+ der beiden Hintergrunddienste (Etappe-3-Übergabe), `createPlatform`/
+ `remove` in `tender-rss-feed.service.ts` (WINDOWS #24).
+- `ldap`: `getAllActiveConfigs`/`onApplicationBootstrap` (Befund B),
+ `resolveEmailForWrite` (plattformweit eindeutiger Schlüssel, T-IPC-04).
+- `dkv`: der Planer-Startpfad `loadAnyActiveConfigForScheduler()`
+ (WINDOWS #21).
+- `user`: `findByUsername` (plattformweit eindeutiger Schlüssel), die
+ Erstanlage-Prüfung und beide `tenant`-Zugriffe in `admin-seed.service.ts`,
+ der Schleifentreiber `tenant.findMany` der Plattform-Administratorsicht.
+- `module-registry`/`dashboard`: der Modulkatalog (`Module`, keine Regel
+ heute — Befund E).
+- `auth`: die drei Anmeldefunktionen (`validateUser`,
+ `requestPasswordReset`, `resetPassword`) über die SECURITY-DEFINER-Wege.
+- `tenant`: die Mandantentabelle selbst (`Tenant`, keine eigene
+ `tenantId`-Spalte, keine Regel in irgendeiner Migration).
+- `settings`: der sechste Fall der Hintergrunddienst-Falle,
+ `loadAnySmtpConfigForStartupTransport()` (dieser Lauf, siehe (s4)(a)).
+
+**Offene Ledger-Einträge dieser Etappe** (Nummern siehe `.planning/WINDOWS.md`,
+Kopfzähler geprüft): #18 (Schalter aus), #20 (Verbindungsfehler, an dieselbe
+Bedingung gebunden wie #18), #21 (dkv-Planer-Startpfad), #22
+(plattformweite Eindeutigkeit von `username`/`email`), #23
+(module-registry, unterscheidbares Signal fehlt), #24 (plattformweite
+RSS-Verwaltung unter der Anwendungsrolle), #25 (dashboard, beweisvernichtende
+Fehlerrichtung), #26 (calendar, verschluckte Leere), #27
+(Relationszugriffe für die Bestandsaufnahme unsichtbar), #28 (auth,
+verschluckte Leere), plus die drei neuen Einträge dieses Laufs (Startpfad
+des Mailmoduls, verschluckte Leere `favorites`, verschluckte Leere
+`settings` — Nummern siehe Aufgabe 3 dieses Plans).
+
+**Prüfungen und Tests.** Werkzeug: siehe die Zeile `Alle N Prüfungen
+bestanden.` des letzten Laufs von `rls-scratch-check.mjs` in dieser Aufgabe.
+Tests: siehe die letzte Testausgabe von `npm --prefix apps/api run test` in
+dieser Aufgabe.
+
+**Was für Etappe 3 bleibt:**
+
+- Der Anmeldeweg unter je Mandant eindeutigen Namen (`username`/`email`,
+ Etappe-3-Entscheidung (1)) — siehe (h4)(a).
+- Die Benutzerdimension der Regeln (Etappe-3-Entscheidung (2)) — siehe
+ (k4)/(f4)(a) und die übrigen Bereiche mit derselben Beobachtung.
+- Die Modulkatalog-Regel für `Module` (Befund E, `module-registry`) — sobald
+ eine Regel eingeführt wird, müssen die heute bewusst ungebundenen
+ Katalogzugriffe nachgezogen werden.
+- Die Kennzeichnung der `bewusst-uebergreifend`-Stellen (Systemkontext,
+ siehe "Die drei Klassen" im Klassifikationsdokument).
+- Der Mandantenwechsel im Ausschreibungs-Digest ((t4), der Sonderfall eines
+ Nutzers mit Treffern unter zwei verschiedenen Mandanten).
+
+**Was Etappe 4 (`rls-preflight.mjs`) VOR dem Scharfschalten prüfen muss** —
+die in den Bereichsabschnitten benannten Vorabprüfungen, als Liste (OHNE die
+jetzt erfüllte Befund-K-Bedingung, die entfällt):
+
+- `ldap`: das Verstummen von `getAllActiveConfigs` (Befund B).
+- `dkv`: das Verstummen des Planer-Startpfads (WINDOWS #21).
+- `tenders`: wachsende Zahl von `TenderMatch`-Zeilen mit `notifiedAt IS NULL`
+ ohne Versandprotokoll (t3).
+- `module-registry`: aktive Aktivierungszeilen vorhanden, aber die
+ Auflösung liefert für einen bekannten Administrator eine leere Menge
+ (WINDOWS #23).
+- `dashboard`: eine physisch vorhandene `DashboardLayout`-Zeile für einen
+ bekannten Benutzer, aber der gebundene Lesezugriff liefert `null`
+ (WINDOWS #25).
+- `calendar`: physisch vorhandene `CalendarSource`-Zeilen je Mandant über
+ die Wartungsrolle zählen und mit der gebundenen Zählung vergleichen
+ (k4)(e).
+- `auth`: einen bekannten Benutzer über die Wartungsrolle lesen und den
+ gebundenen `findUnique` unter seinem Claim-Mandanten daneben halten
+ (WINDOWS #28).
+- `favorites`: für einen bekannten Nutzer/Widget die Favoritenzahl über die
+ Wartungsrolle und über den gebundenen `findMany` daneben halten (f4)(d).
+- `settings`: für einen bekannten Mandanten die `SmtpConfig`-Zeile über die
+ Wartungsrolle lesen und den gebundenen `findUnique` daneben halten
+ (s4)(e).
+- Das Verstummen des Mail-Startpfads (dieser Lauf, (s4)(a)) — EIGENES
+ Signal, NICHT an #21 angeschlossen.
+
## Verweis
Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang