feat(quick-260910-krx): Fehlerrichtung fuer Bereich dashboard gemessen

Aufgabe 1 — misst die umgekehrte Fehlerrichtung des Bereichs dashboard an
den Regeln nach Migration 20260910120000, VOR der Umstellung:

- rls-scratch-check.mjs bekommt einen neunten Abschnitt
  (runDashboardAreaChecks) mit 13 neuen, namentlich benannten Pruefungen
  gegen die aus der ausgelieferten Migration geschnittenen Regeln fuer
  DashboardLayout, WidgetInstance und SearchProvider. Alle 87 Pruefungen
  bestehen (74 bisherige + 13 neue).
- Die Konfliktmessung (Befund K) ist gemessen, nicht angenommen: ein
  gebundenes INSERT ... ON CONFLICT auf eine unter dem Mandanten
  unsichtbare Zeile scheitert laut mit SQLSTATE 42501. Zusaetzlich am
  echten generierten Prisma Client gemessen: prisma.dashboardLayout.upsert()
  wirft PrismaClientUnknownRequestError (nicht P2002) — das tenders-Muster
  laesst sich deshalb nicht woertlich uebernehmen.
- Die widerlegte Praemisse zu SearchProvider (WINDOWS #19) ist in diesem
  Durchlauf eigenstaendig nachgeprueft, mit benannter Suchreichweite.
- docs/mandantentrennung-etappe2-fehlerrichtung.md bekommt den Abschnitt
  "## Bereich dashboard" (w1)-(w5) samt der beweisvernichtenden Schleife
  (leeres Dashboard -> Neuaufbau -> automatisches Zurueckschreiben ->
  ueberschriebene Anordnung) und einen Nachtrag im Abschnitt
  "## Bereich module-registry" zur geerbten Bindungsentlastung.

Baseline gehalten: 839 Tests / 56 Dateien gruen, Typpruefung sauber.
Schalter bleibt aus.
This commit is contained in:
2026-09-11 08:46:46 +02:00
parent 89fb02797a
commit 6744918a01
2 changed files with 598 additions and 0 deletions
@@ -1611,6 +1611,21 @@ nichts gefunden" unterscheidet, mit der Vorabprüfung für Etappe 4 und der
begründeten Verwerfung einer Laufzeitwarnung — siehe (m3) oben und
`.planning/WINDOWS.md`.
**Nachtrag (260910-krx):** der oben in Befund E festgehaltene Befund zur
Reihenfolge — der Bereich `dashboard` erbt die Bindung der
Modul-Zugriffsauflösung, ohne dass eine Datei unter
`apps/api/src/module-registry` dafür angefasst werden muss — ist mit
Quick-Task 260910-krx EINGELÖST und NACHGEPRÜFT: `dashboard.service.ts`
ruft `ModuleAccessService.getAccessibleModuleIds` für den Widget-Modulfilter
in `getWidgets` unverändert auf, diese Auflösung bindet seit 260910-exd
bereits über `forTenant()`, und der Filter ist damit gebunden. Nachgeprüft
mit `grep -n "const tenantPrisma = forTenant" apps/api/src/module-registry/
module-access.service.ts` (ein Treffer) und mit einem Wachhund-Testfall in
`dashboard.service.spec.ts`, der den Modulkatalog aus dem
Bindungsprotokoll heraushält. Der Widget-Modulfilter wurde von 260910-krx
NICHT ein zweites Mal gebunden — keine Datei unter
`apps/api/src/module-registry` ist Teil dieses Plans.
## Regelschluss T-JTS-02, T-JTS-03 und WINDOWS #19
Dieser Abschnitt weicht bewusst von der geplanten Reihenfolge ab (260910-jab,
@@ -1727,6 +1742,264 @@ Tabellen, inklusive der NEUEN Stelle aus Befund F:
Regeln sind heute wirkungslos; das Wegwerf-Werkzeug und die Regelliste der
lebenden Datenbank sind die einzigen Zeugen dafür, dass sie greifen.
## Bereich dashboard
Dieser Abschnitt erweitert die Kritikschrift um den Bereich `dashboard`
(Quick-Task 260910-krx), den achten Bereich der Etappe und den einzigen
Dienst, der ausschließlich hält, was ein Nutzer sich selbst eingerichtet
hat: die Anordnung seiner Widgets und seine eigenen Suchmaschinen. Die
umgekehrte Fehlerrichtung sieht hier nicht wie ein Fehler aus, sondern wie
ein Zurücksetzen — und sie ist in diesem Bereich beweisvernichtend, siehe
(w3).
### (w1) Die Messung
Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen neunten
Abschnitt (`runDashboardAreaChecks`) erweitert, unmittelbar nach
`runModuleRegistryAreaChecks` und vor `runTransactionShapeMeasurement`
aufgerufen. Er legt die beiden Wegwerf-Tabellen `DashboardLayout` und
`WidgetInstance` selbst neu an und benutzt die von
`runSearchProviderAreaChecks` bereits angelegte Tabelle `SearchProvider`
WEITER (eigene Kennungen, keine zweite Anlage) — alle drei Policies mit
`extractPolicySql()` WORTGLEICH aus der ausgelieferten Migration
`20260909140000_rls_remaining_tenant_tables` geschnitten, **gemessen am
Regelstand NACH der Migration `20260910120000_rls_widen_membership_grant_and_platform_read`**
(die drei Regeln dieses Bereichs sind von jener Migration unverändert
gelassen worden, siehe deren Abschnitt (4) — die Messung gilt trotzdem dem
aktuellen, lebenden Regelstand, nicht einem veralteten). Tatsächlich
beobachtete Ausgabe dieses Laufs (2026-09-11, gegen `tessera-ctl-db-1`,
Adresse `172.19.0.2`, nur die dreizehn neuen Zeilen dieses Abschnitts sowie
die abschließende Summenzeile):
```
dashboardlayout-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 2 Zeile(n): ["layout-a1","layout-a2"]
dashboardlayout-fremder-nutzer-desselben-mandanten-gebunden-sichtbar: bestanden — forTenant(TENANT-A) liefert die Anordnung von 'user-a2' (anderer Benutzer, gleicher Mandant) mit: true — die Regel auf "DashboardLayout" kennt keine Benutzerdimension, die anwendungsseitige Pruefung ueber die Benutzerkennung bleibt deshalb der einzige Schutz gegen Quer-Lesen zwischen Nutzern DESSELBEN Mandanten
dashboardlayout-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "DashboardLayout" liefert 0 Zeile(n), tatsaechlich vorhanden sind 4
widgetinstance-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "WidgetInstance" liefert 0 Zeile(n), tatsaechlich vorhanden sind 3
widgetinstance-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 2 Zeile(n): ["widget-a1","widget-a2"]
widgetinstance-fremder-nutzer-desselben-mandanten-gebunden-sichtbar: bestanden — forTenant(TENANT-A) liefert das Widget von 'user-a2' (anderer Benutzer, gleicher Mandant) mit: true — dieselbe fehlende Benutzerdimension wie bei "DashboardLayout" und "SearchProvider" (Befund G), der dritte der drei Faelle dieses Bereichs
dashboardlayout-gebundener-konfliktschreibvorgang-auf-unsichtbare-zeile-scheitert-laut: bestanden — gebundenes INSERT ... ON CONFLICT ("userId") DO UPDATE unter TENANT-A auf die unter TENANT-B physisch vorhandene, unsichtbare Zeile (user-conflict) scheitert LAUT mit SQLSTATE 42501: ERROR: new row violates row-level security policy (USING expression) for table "DashboardLayout"
widgetinstance-gebundenes-einfuegen-fremder-mandant-abgelehnt: bestanden — gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B abgewiesen mit SQLSTATE 42501 (ERROR: new row violates row-level security policy for table "WidgetInstance")
widgetinstance-gebundenes-loeschen-fremder-zeile-trifft-keine-zeile: bestanden — gebundenes DELETE unter TENANT-A ueber die Kennung 'widget-b1' (gehoert TENANT-B) trifft 0 Zeile(n) — die vorgeschaltete Besitzpruefung im Anwendungscode bleibt deshalb der einzige Schutz vor dem Scharfschalten
searchprovider-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "SearchProvider" liefert 0 Zeile(n), tatsaechlich vorhanden sind 4
searchprovider-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 2 Zeile(n) aus den neu hinzugefuegten: ["search-a1","search-a2"]
searchprovider-fremder-nutzer-desselben-mandanten-gebunden-sichtbar: bestanden — forTenant(TENANT-A) liefert die Suchmaschine von 'user-a2' (anderer Benutzer, gleicher Mandant) mit: true — dieselbe fehlende Benutzerdimension wie bei "DashboardLayout" und "WidgetInstance" (Befund G)
searchprovider-gebundenes-einfuegen-ohne-mandant-abgelehnt: bestanden — gebundenes INSERT unter TENANT-A mit tenantId=NULL abgewiesen mit SQLSTATE 42501 (ERROR: new row violates row-level security policy for table "SearchProvider")
Alle 87 Pruefungen bestanden.
```
Dreizehn neue Prüfungen, nicht zwölf wie in der Aufzählung des Plans
namentlich vorgezeichnet — die dreizehnte
(`widgetinstance-fremder-nutzer-desselben-mandanten-gebunden-sichtbar`)
wurde ergänzt, weil Befund G des Plans ausdrücklich alle DREI Tabellen
dieses Bereichs als ohne Benutzerdimension benennt, der Plan aber nur für
`DashboardLayout` und `SearchProvider` einen entsprechenden Testfall
vorzeichnete. Die Gesamtzahl der Werkzeugprüfungen steigt damit von 74 auf
87 (74 + 13).
**Die tragende Belegzeile ist `dashboardlayout-ungebunden-null-zeilen`:**
der IDENTISCHE `SELECT "tenantId" FROM "DashboardLayout"` ohne vorheriges
`set_config` liefert **0 Zeilen**, nicht die 4 tatsächlich vorhandenen —
an der echten, ausgelieferten Policy gemessen. `widgetinstance-ungebunden-
null-zeilen` misst dieselbe Unsichtbarkeit für die Widget-Tabelle.
**Die Konfliktmessung (Befund K, Prüfung 5) hat ein Ergebnis, nicht eine
Vermutung.** Ein gebundenes `INSERT ... ON CONFLICT ("userId") DO UPDATE`
unter TENANT-A, das auf die unter TENANT-B physisch vorhandene, unter
TENANT-A unsichtbare Zeile trifft, scheitert LAUT mit SQLSTATE `42501`
("new row violates row-level security policy (USING expression) for table
\"DashboardLayout\""), nicht mit einem Eindeutigkeitsfehler (`23505`) und
nicht mit einem stillen Erfolg. Zusätzlich am ECHTEN, generierten Prisma
Client gemessen (nicht nur an rohem SQL), weil `saveLayout` in Wahrheit
`prisma.dashboardLayout.upsert()` aufruft, nicht `$executeRaw`: gegen eine
eigens dafür angelegte Wegwerf-Datenbank mit vollständigem Spaltensatz
(`id`, `userId`, `tenantId`, `layouts`, `createdAt`, `updatedAt`) und einer
eigenen Wegwerf-Rolle ohne `BYPASSRLS` liefert derselbe Konfliktfall über
`tenantPrisma.dashboardLayout.upsert({ where: { userId }, update, create })`
einen **`PrismaClientUnknownRequestError`** — nicht den bekannten
`PrismaClientKnownRequestError` mit `.code === 'P2002'`, den der Bereich
`tenders` für seinen Eindeutigkeitsfall abfängt. `.code` und `.meta` sind
bei diesem Fehlertyp `undefined`; die einzige verlässliche Information
steht im rohen `.message`-Text, der den PostgreSQL-Fehler eingebettet
enthält (`code: "42501"`, `message: "new row violates row-level security
policy (USING expression) for table \"DashboardLayout\""`). **Das ist die
zentrale Abweichung von der Annahme, das `tenders`-P2002-Muster ließe sich
wörtlich übernehmen** — es lässt sich nicht, weil dieser Fehler eine andere
Prisma-Fehlerklasse ist. Aufgabe 2 fängt deshalb
`Prisma.PrismaClientUnknownRequestError` ab (Prüfung auf die Fehlerklasse,
nicht auf `.code`) und übersetzt ihn in eine verständliche deutsche
Meldung — siehe (w4) für die Grenze dieser Behandlung.
**Die eigenständige Nachprüfung der widerlegten Prämisse (Befund F,
WINDOWS #19).** Nachgeprüft mit derselben Anweisung wie zur Planungszeit,
diesmal gegen den aktuellen Quelltext (2026-09-11):
`grep -rn "searchProvider\|SearchProvider" apps packages prisma
--include=*.ts --include=*.mjs --include=*.js --include=*.sql
--include=*.json` (ohne `node_modules`, `dist/`, `.next/`). Ergebnis
unverändert: der einzige Schreibweg ist
`dashboard.service.ts:241` (`addSearchProvider` → `create`) mit
`tenantId: string` als PFLICHTPARAMETER der aufrufenden Methode; keine
Seed-Datei (`apps/api/prisma/` enthält ausschließlich `migrations` und
`schema.prisma`), kein Skript, kein weiterer Schreibweg. Reichweite der
Suche, wie zur Planungszeit benannt: sie findet keinen Schreibweg über
einen dynamisch gebildeten Modellnamen und deckt keine manuelle
Datenbankänderung ab — die Aussage lautet deshalb "kein Anwendungspfad
erzeugt eine mandantenlose Zeile", nicht "es kann keine geben". Zusätzlich
datenbankseitig verteidigt: `searchprovider-gebundenes-einfuegen-ohne-
mandant-abgelehnt` weist ein gebundenes Einfügen mit `tenantId = NULL`
laut mit SQLSTATE `42501` ab, weil die Regel ohne eigene `WITH CHECK`-
Klausel ihre `USING`-Klausel dafür wiederverwendet.
### (w2) Signaltabelle je umgestelltem Pfad
| Pfad | Verhalten bei zu wenig Ergebnis | Konkretes Signal, Ort |
|---|---|---|
| `DashboardService.getLayout` | Der gebundene Lesezugriff liefert `null` statt der vorhandenen Zeile — identisch zum heutigen "noch keine Anordnung gespeichert" | Der Rückgabepunkt liefert die Vorgabeanordnung `{ lg: [], md: [], sm: [], xs: [], xxs: [] }`, Status 200, kein Fehler. Sichtbar im Browser als leeres Dashboard — siehe (w3) für die Folgekette |
| `DashboardService.saveLayout` | Der gebundene Schreibzugriff trifft im `update`-Zweig die vorhandene, aber unter dem laufenden Mandanten unsichtbare Zeile über den plattformweit eindeutigen Schlüssel `userId` — Konfliktmessung, siehe (w1) | `PUT /dashboard/layout` liefert nach Aufgabe 2 eine verständliche deutsche Konfliktmeldung statt eines rohen 500ers — siehe (w4) für die Grenze |
| `DashboardService.getWidgets`, Widget-Lesezugriff | Der gebundene Lesezugriff liefert eine leere Liste statt der platzierten Widgets | Der Nutzer sieht ein Dashboard ohne jedes Widget, Status 200, kein Fehler |
| `DashboardService.addWidget` | Der gebundene Schreibzugriff schlägt fehl bzw. legt die Zeile unter einer Mandantenkennung an, die der Sitzungsnachweis liefert — kein Leere-Fall in diese Richtung | `POST /dashboard/widgets` liefert einen Fehler statt eines neuen Widgets, falls der Mandant fehlt (`ForbiddenException` bereits im Controller) |
| `DashboardService.updateWidgetConfig`/`removeWidget`, Besitzprüfung | Die gebundene `findUnique`-Abfrage liefert `null` statt der eigenen Zeile — ununterscheidbar vom echten "gehört jemand anderem" | `NotFoundException` — dieselbe Meldung wie beim echten Besitzverstoß, siehe (w4) |
| `DashboardService.getSearchProviders` | Der gebundene Lesezugriff auf `custom` liefert eine leere Liste statt der eigenen Suchmaschinen — die drei Vorgaben aus der Konstante bleiben unberührt und werden IMMER vorangestellt | Die eigenen Suchmaschinen verschwinden aus der Auswahlliste des Such-Widgets, die Leiste funktioniert weiter — siehe (w3), Befund J |
| `DashboardService.addSearchProvider` | Kein Leere-Fall — der Schreibzugriff verlangt die Mandantenkennung als Pflichtparameter | — |
| `DashboardService.removeSearchProvider`, Besitzprüfung | Wie bei Widgets: `null` statt der eigenen Zeile | `NotFoundException` — dieselbe Meldung wie beim echten Besitzverstoß |
### (w3) Welcher Code Leere als Abwesenheit deutet
**Backend, eine Stelle:** `DashboardService.getLayout` —
`if (!record) return { lg: [], md: [], sm: [], xs: [], xxs: [] };`. Kein
Datensatz bedeutet hier nicht "Fehler", sondern "Vorgabeanordnung" — dieselbe
Deutung wie bei `getWidgets` (leere Liste) und `getSearchProviders` (nur die
drei Konstanten).
**Frontend, drei Stellen, zur Ausführungszeit an den beiden Dateien erneut
nachgeprüft (Befund I/J), nicht aus dem Plan abgeschrieben — dieser Plan
ändert an KEINER der beiden Dateien etwas:**
1. `apps/web/src/lib/stores/dashboard-store.ts`, `loadDashboard`: setzt
`layouts` und `widgets` genau auf das, was `api.fetchLayout()` und
`api.fetchWidgets()` liefern. Der `catch`-Zweig
(`error: 'Failed to load dashboard'`) feuert nur bei einem Netzwerk-
oder Statusfehler — eine erfolgreiche, leere Antwort setzt keinen
Fehlerzustand.
2. `apps/web/src/lib/stores/dashboard-store.ts`, `setEditMode`:
`if (prev && !mode && get().isDirty) { get().saveLayout(); }` — der
Neuaufbau wird beim bloßen VERLASSEN des Bearbeitungsmodus automatisch
zurückgeschrieben, ohne dass jemand auf "Speichern" klickt.
3. `apps/web/src/components/dashboard/widgets/search-widget.tsx`: die
Rückfallprüfung `if (!cancelled && data.length > 0)` greift NIE, weil
`getSearchProviders` die drei Vorgaben immer voranstellt — `data` ist
nie leer, selbst wenn `custom` (die eigenen Suchmaschinen) nach dem
Scharfschalten leer geblieben wäre. Der `.catch()`-Rückfallzweig auf
`DEFAULT_PROVIDERS` feuert deshalb ebenfalls nie in diesem Fall.
**Die beweisvernichtende Schleife, als Schleife beschrieben (Befund I):**
leeres Dashboard (Stelle 1) → der Nutzer hält das für einen Fehler des
Widget-Systems oder für verlorene Einstellungen, baut seine Anordnung neu
auf, `addWidget` legt echte neue `WidgetInstance`-Zeilen an (keine
Eindeutigkeitsbedingung über `(userId, widgetType)`, Dubletten häufen sich
also bei wiederholtem Neuaufbau an) → beim Verlassen des Bearbeitungsmodus
schreibt Stelle 2 den Neuaufbau AUTOMATISCH zurück, ohne dass der Nutzer
"Speichern" geklickt hat → die `layouts`-Spalte der ursprünglichen Zeile
ist überschrieben, die einzige Aufzeichnung der ursprünglichen Anordnung
ist verloren, bevor irgendjemand die Ursache untersuchen konnte. Der
Nutzer hat dabei eine fertige, FALSCHE Erklärung zur Hand ("das
Widget-System spinnt", "meine Einstellungen sind weg") und meldet deshalb
keinen Fehler — derselbe Mechanismus, der auch in `module-registry` (m3)
und `ldap` beschrieben ist, hier aber mit einer zusätzlichen, aktiven
Zerstörungshandlung (das automatische Zurückschreiben), die es in keinem
der beiden anderen Bereiche gibt.
**Das Verhalten der Suchleiste (Befund J), präzise statt "still"
formuliert:** verschwinden die eigenen Suchmaschinen des Nutzers aus
`custom`, bleibt die Auswahlliste (`<select>`) dennoch gefüllt — mit den
drei Vorgaben. Sichtbar wird das nur, wenn der Nutzer VORHER eine eigene
Suchmaschine ausgewählt hatte: React setzt `value={selectedProviderId}`
auf dem `<select>`, aber `selectedProviderId` referenziert nach dem
Verschwinden keinen vorhandenen `<option>`-Wert mehr — der Browser zeigt in
diesem Fall (kein `<option>` mit passendem `value`) den ERSTEN Eintrag der
Liste an, also "Google", OHNE dass der interne React-State
`selectedProviderId` sich ändert oder irgendeine Meldung erscheint. Klickt
der Nutzer danach Suchen, greift `handleSearch`:
`providers.find((p) => p.id === selectedProviderId) ?? providers[0]` — der
`find` schlägt fehl (die eigene Suchmaschine ist nicht mehr in `providers`),
der Rückfall auf `providers[0]` (Google) greift, und eine Anfrage, die für
ein internes Werkzeug gedacht war, geht an eine externe Suchmaschine. Für
einen Nutzer, der die Anzeige "Google" im Dropdown nicht bewusst als
Abweichung von seiner eigenen Auswahl liest, bleibt das unbemerkt.
### (w4) Was dieser Durchlauf bewusst nicht löst
- **Die plattformweite Eindeutigkeit von `DashboardLayout.userId`
(Befund K).** `userId` trägt `@unique` ohne Mandantenanteil — strukturell
dieselbe Kette wie WINDOWS #22 im Bereich `user`: unsichtbare Zeile,
falsches "frei", harter Eindeutigkeitsfehler. Anders als bei #22 ist der
Fehler hier aber GEMESSEN statt angenommen (siehe (w1), Prüfung 5) und
wird in Aufgabe 2 BEDINGT auf das gemessene Ergebnis behandelt: eine
verständliche deutsche Meldung statt eines rohen 500ers, indem
`Prisma.PrismaClientUnknownRequestError` abgefangen wird — NICHT
`.code === 'P2002'` wie im Bereich `tenders`, weil der gemessene Fehler
eine andere Prisma-Fehlerklasse ist. Die ehrliche Reparatur wäre eine
Schemaänderung (Eindeutigkeit mit Mandantendimension) und ist als
Produktentscheidung für Etappe 3 vorgemerkt, wie #22 es für denselben
Fall bereits tut.
- **Die fehlende Unterscheidbarkeit von "noch keine Anordnung gespeichert"
und "Anordnung nicht sichtbar".** Beide liefern identisch die
Vorgabeanordnung, Status 200, keinen Protokolleintrag. Die konkrete
Vorabprüfung für Etappe 4 (`rls-preflight.mjs`): eine physisch
vorhandene `DashboardLayout`-Zeile für einen bekannten Benutzer, aber der
gebundene Lesezugriff für dessen Mandanten liefert `null` — das
unterscheidet den echten Erstbenutzer-Fall (keine Zeile vorhanden) vom
Trennungsfehler (Zeile vorhanden, aber unsichtbar).
- **Eine Laufzeitwarnung an `getLayout`/`getWidgets`/`getSearchProviders`
wurde erwogen und VERWORFEN**, mit derselben Begründung wie bei
`getAllActiveConfigs` im Bereich `ldap`: eine leere Anordnung, eine leere
Widget-Liste oder keine eigene Suchmaschine sind auf einer frischen
Installation oder für einen neuen Benutzer der NORMALZUSTAND — eine
Warnung an dieser Stelle wäre Dauerlärm und verlöre ihr Signal, bevor sie
gebraucht wird. Das gilt hier NOCH ausgeprägter als in `ldap`, weil
praktisch jeder neue Benutzer diesen Zustand beim ersten Login durchläuft.
- Der offene Ledger-Eintrag zur beweisvernichtenden Schleife (angelegt in
Aufgabe 3, siehe `.planning/WINDOWS.md`) ist an dieselbe Bedingung
gebunden wie #18 — er wird erst
nach dem Scharfschalten beobachtbar.
### (w5) Was dieser Durchlauf bewusst nicht anfasst
- **Das Frontend** — geprüft (Befund I/J, (w3) oben) und bewusst gelassen,
keine Datei dieses Plans. `dashboard-store.ts` und `search-widget.tsx`
werden NICHT geändert.
- **Der Bereich `favorites`.** `FavoriteLink` hängt per Fremdschlüssel an
`WidgetInstance` (`apps/api/prisma/schema.prisma`,
`favoriteLinks FavoriteLink[]` auf `WidgetInstance`), ist aber ein
eigener Bereich mit eigener Umstellung (`apps/api/src/favorites/
favorites.service.ts`, ungebunden, sieben Rohtreffer laut
Klassifikationsdokument) — nicht Teil dieses Plans.
- **Der Bereich `module-registry`** samt der geerbten Entlastung aus
Befund E: `dashboard.service.ts` ruft
`ModuleAccessService.getAccessibleModuleIds` für den Widget-Modulfilter
auf, und diese Auflösung bindet bereits seit 260910-exd
(`const tenantPrisma = forTenant(this.prisma, tenantId)` in
`module-access.service.ts`). Der Filter ist damit gebunden, OHNE dass
dieser Plan eine Datei unter `apps/api/src/module-registry` anfasst —
nachgeprüft mit `grep -n "const tenantPrisma = forTenant" apps/api/src/
module-registry/module-access.service.ts`, ein Treffer. Der eine
Katalogzugriff in `dashboard.service.ts` (`getWidgets`, `this.prisma.
module`) bleibt bewusst ungebunden — siehe die Begründung in der
Klassifikationstabelle, die Messung und Bedingung trennt.
- **Die Ungenauigkeit in `apps/api/src/groups/groups.service.ts` (Befund
L), zur Ausführungszeit gegengelesen und WÖRTLICH bestätigt, NICHT
behoben.** Der Kopfkommentar um Zeile 24 begründet die dortige
Join-Filterung mit "nach dem Ownership-Check-Muster aus
DashboardService.removeWidget". Das trifft in zwei Punkten nicht zu:
`removeWidget` filtert NICHT zusätzlich in der Datenbankabfrage, sondern
vergleicht NACH dem Laden im JavaScript (`widget.userId !== userId`),
und dieser Vergleich läuft über die BENUTZER-, nicht die
Mandantenkennung. Die Datei `apps/api/src/groups/groups.service.ts`
wird von diesem Plan NICHT angefasst — diese Feststellung steht hier als
Richtigstellung, nicht als Änderung an der fremden Datei.
- Schema und Migrationen — geprüft und bewusst gelassen, keine
Schemaänderung in dieser Etappe.
## Verweis
Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang