Files
tessera-ctl/.planning/quick/260911-cwh-mandantentrennung-etappe-2-bereich-calen/260911-cwh-PLAN.md
T

77 KiB

phase, plan, type, wave, depends_on, autonomous, requirements, files_modified, estimate, must_haves
phase plan type wave depends_on autonomous requirements files_modified estimate must_haves
quick-260911-cwh 01 execute 1
true
WINDOWS-18
ETAPPE-2-CALENDAR
apps/api/scripts/rls-scratch-check.mjs
docs/mandantentrennung-etappe2-fehlerrichtung.md
apps/api/src/calendar/calendar.service.spec.ts
apps/api/src/calendar/calendar.service.ts
apps/api/src/calendar/calendar.controller.ts
docs/mandantentrennung-zugriffsklassifikation.md
.planning/WINDOWS.md
tokens raw_tokens tasks confidence
190000 190000 3 low
truths artifacts key_links
Alle zwoelf Datenbankzugriffe dieses Bereichs laufen gebunden unter dem woertlichen Namen `tenantPrisma`, genau ein gebundener Klient je Methode mit Datenbankzugriff. Es gibt in diesem Bereich KEINEN begruendet ungebundenen Zugriff — jede Zeile von `CalendarSource` traegt eine Pflicht-Mandantenkennung, und kein Pfad liest ueber Mandanten hinweg.
Lesen und Schreiben derselben Zeile sind nirgends auf gebunden und ungebunden aufgeteilt: die drei Besitzpruefungen (`updateSource`, `deleteSource`, `testConnection`) fuehren Nachschlagen UND Schreiben ueber DENSELBEN gebundenen Klienten, und die beiden Synchronstatus-Rueckschreibungen innerhalb der Ereignisaggregation laufen ueber denselben Klienten wie das Laden der Quellen. Das ist je Pfad als Testfall festgenagelt, nicht behauptet.
Die umgekehrte Fehlerrichtung dieses Bereichs ist an der ECHTEN, ausgelieferten Regel fuer `CalendarSource` gemessen — an dem Stand, den die Datenbank NACH der Migration 20260910120000 hat (die diese Tabelle nachweislich nicht angefasst hat, mit Anweisung belegt). Mindestens ein Schreib- und ein Lesepfad sind ueber den GENERIERTEN Prisma-Client gemessen, an einer Wegwerf-Tabelle, die saemtliche Spalten des Modells traegt (die Lehre aus Pruefung 5b im Bereich `dashboard`).
Die umgekehrte Fehlerrichtung ist in ihrer Auspraegung fuer diesen Bereich benannt: nach dem Scharfschalten liefert ein zu kleines Leseergebnis KEIN Fehlerbild, sondern einen leeren Kalender, der als 'mein Kalender ist leer' oder 'die Synchronisation ist kaputt' gelesen wird, und eine leere Quellenliste, die als 'keine Quelle eingerichtet' gelesen wird. Zusaetzlich benannt: das Frontend verwandelt sogar LAUTE Fehler der Lesepfade in denselben leeren Zustand. Festgehalten in der Kritikschrift UND als offener Eintrag im Broken-Windows-Register mit konkreter Vorabpruefung fuer Etappe 4.
Die Frage nach der Zugangsdaten-Erhaltung ist mit Messung beantwortet, nicht mit Annahme: ob `calendar.service.ts` die zerstoerende Form aus `dkv` (lesen, entschluesseln, neu verschluesseln — nach dem Scharfschalten leer) traegt oder nicht. Der Befund steht in der Kritikschrift, und das tatsaechliche Erhaltungsverhalten (Passwort weggelassen, Passwort leer, Passwort gesetzt) ist als drei Testfaelle festgenagelt, damit eine spaetere Aenderung der Form sichtbar wird.
Der Ereignis-Cache-Schluessel ohne Mandantenanteil hat ein schriftliches, belegtes Urteil: entweder bleibt die Benutzerkennung, die er traegt, auch nach der Etappe-3-Entscheidung 'Anmeldenamen pro Mandant eindeutig' plattformweit eindeutig — dann ist der Schluessel sicher und bleibt — oder sie bleibt es nicht, dann bekommt der Schluessel einen Mandantenanteil. Die Kette (Schema, Sitzungsnachweis, Controller) ist Glied fuer Glied nachgesehen, mit Anweisung, und das Urteil steht in Code UND Kritikschrift.
Die drei Besitzpruefungen sind GELESEN, nicht angenommen: dieser Durchlauf hat je Pfad nachgesehen, ob zwischen Nachschlagen ueber die Kennung und Schreiben tatsaechlich ein Vergleich gegen die Benutzerkennung aus dem Sitzungsnachweis steht (das Vorhaben fand bei `ldap` und `dkv` je einmal keinen). Die Pruefungen bleiben bestehen, werden durch die Bindung ERGAENZT und sind als Testfaelle festgenagelt — sie sind der einzige Schutz zwischen Kollegen DESSELBEN Mandanten, weil die Regel keine Benutzerdimension kennt (gemessen).
Die Testlage steht VOR der Umstellung: `calendar.service.spec.ts` existiert heute nicht und wird mit dem Zwei-Klienten-Nachweis neu angelegt, mit Attrappen fuer Verschluesselung und die drei Provider — die Provider selbst werden NICHT ausgeuebt. Ein vergessener Bindungsaufruf wird dadurch rot, statt aus einem anderen Grund zu scheitern.
Der Controller reicht die bereits in `extractContext` aufgeloeste Mandantenkennung an alle fuenf Handler durch, die sie heute verwerfen; es entsteht KEINE neue Vertrauensquelle, nichts wird aus Rumpf oder Pfad uebernommen.
Alle fuenf handgepflegten Dokumentstellen der Klassifikation sind nachgezogen und maschinell gegatet, die Gates LEITEN ihre Werte aus den im Dokument genannten Messanweisungen ab, und der Umfang ist als ERLAUBNISLISTE gegen `50b3a36` gegatet.
Baseline gehalten am Ende JEDER Aufgabe: mindestens 860 Tests gruen (mindestens 56 Dateien), Typpruefung sauber, das Wegwerf-Werkzeug meldet alle Pruefungen bestanden. Der Schalter bleibt AUS, Schema und Migrationen unveraendert, keine Compose- oder Umgebungsdatei angefasst, nichts in Active Directory.
apps/api/scripts/rls-scratch-check.mjs — ein elfter Abschnitt `runCalendarAreaChecks` mit mindestens zwoelf namentlich benannten Pruefungen gegen die aus der ausgelieferten Migration geschnittene Regel fuer `CalendarSource`, davon mindestens vier ueber den generierten Client
docs/mandantentrennung-etappe2-fehlerrichtung.md — Abschnitt `## Bereich calendar` mit (k1) Messung, (k2) Signaltabelle, (k3) Leere-als-Abwesenheit im Backend UND im Frontend samt der Fehlerverschluckung, (k4) bewusst nicht geloest (darunter das Urteil zum Cache-Schluessel und der Befund zur Zugangsdaten-Erhaltung), (k5) bewusst nicht angefasst
apps/api/src/calendar/calendar.service.spec.ts — NEU, Zwei-Klienten-Nachweis ueber `__makeBoundClient`, Attrappen fuer `CryptoService` und die drei Provider, Faelle fuer jeden Pfad mit Datenbankzugriff
apps/api/src/calendar/calendar.service.ts — alle zwoelf Zugriffe gebunden, ein Klient je Methode, das Cache-Schluessel-Urteil als Kommentar an der Stelle
apps/api/src/calendar/calendar.controller.ts — die fuenf Handler, die den aufgeloesten Mandanten heute verwerfen, reichen ihn durch
docs/mandantentrennung-zugriffsklassifikation.md — eine Bestandsaufnahme-Zeile, Uebersichtszeile, Summenzeile, Klassen-Verteilung (unveraendert, ausdruecklich vermerkt), Hintergrunddienst-Abschnitt (kein sechster Fall, gemessen, mit dem Sonderfall der abgekoppelten Cache-Auffrischung), Abschnitt `Was diese Etappe NICHT entscheidet`
.planning/WINDOWS.md — ein neuer OFFENER Eintrag zur lautlosen Leere dieses Bereichs, ueber `gsd-tools windows append` angelegt, damit Tabelle, JSON-Block und Kopfzaehler zusammenpassen
`extractContext` im Controller loest den Mandanten bereits auf und bricht ohne ihn mit `ForbiddenException` ab; fuenf von sechs kontextnutzenden Handlern verwerfen ihn heute. Die Umstellung ist ein Durchreichen, keine neue Vertrauensquelle.
`fetchAndCacheEvents` laedt die Quellen und schreibt je Quelle den Synchronstatus zurueck — innerhalb eines `try/catch`, dessen `catch` selbst wieder schreibt. Waere das Laden gebunden und das Rueckschreiben nicht, schluege das erste Rueckschreiben nach dem Scharfschalten mit 'Zeile nicht gefunden' fehl, der `catch` versuchte das zweite, das ebenso scheitert, und `Promise.allSettled` liesse die Ereignisse dieser Quelle STILL fallen. Ein Klient je Methode ist hier keine Stilfrage.
Der Cache-Schluessel traegt `req.user.id`; das ist laut `JwtStrategy.validate` `payload.sub`, das laut `auth.service.ts` `user.id` ist, das laut Schema `@default(uuid())` traegt. Diese Kette ist das Urteil — jedes Glied ist zur Ausfuehrungszeit nachzusehen.
Die Regel auf `CalendarSource` lautet `"tenantId" = current_tenant_id()` ohne Benutzerdimension: die verschluesselten Exchange-/CalDAV-Zugangsdaten eines Kollegen DESSELBEN Mandanten sind auf Datenbankebene sichtbar. Die anwendungsseitigen `userId`-Filter und Besitzpruefungen sind der einzige Schutz und bleiben unveraendert.
Das Frontend (`calendar-widget.tsx`, `calendar-settings-panel.tsx`) faengt Fehler der Lesepfade und zeigt denselben leeren Zustand wie bei einer leeren Antwort. Damit ist selbst ein LAUTER Backend-Fehler auf `GET /calendar/sources` oder `GET /calendar/events` fuer den Nutzer unsichtbar — das Signal existiert nur im Netzwerkprotokoll des Browsers und im API-Log.
Etappe 2 der Mandantentrennung, neunter Bereich: `calendar`. Die zwoelf Datenbankzugriffe des einzigen Dienstes dieses Bereichs werden auf `forTenant()` umgestellt — alle zwoelf, denn `CalendarSource` traegt eine Pflicht-Mandantenkennung, und kein Pfad dieses Bereichs liest ueber Mandanten hinweg.

Zweck: dieser Bereich haelt nicht bloss Daten, sondern ZUGANGSDATEN zu fremden Servern — die verschluesselten Exchange-, CalDAV- und ICS-Anmeldungen eines Nutzers. Ein Quer-Lesen ist hier nicht Offenlegung eines Termins, sondern Offenlegung der Anmeldung einer anderen Firma bei ihrem Mailserver. Und die umgekehrte Fehlerrichtung sieht hier nicht wie ein Fehler aus, sondern wie ein leerer Kalender: nach dem Scharfschalten liefert eine ungebunden gebliebene Abfrage keine Meldung, sondern null Quellen und null Ereignisse. Der Nutzer liest das als "die Synchronisation ist kaputt" oder "ich habe keine Quelle eingerichtet", legt seine Quelle neu an — und tippt dabei sein Exchange-Passwort ein zweites Mal in ein System, das gerade aussieht, als waere es defekt. Das Frontend verstaerkt das: es faengt sogar laute Fehler der Lesepfade und zeigt denselben leeren Zustand. Deshalb braucht dieser Bereich seine Kritikschrift VOR der Umstellung, gemessen.

Ergebnis: zwoelf gebundene Zugriffe, eine gemessene Kritikschrift, eine aus dem Nichts angelegte Testlage, die einen vergessenen Bindungsaufruf rot macht, ein schriftliches Urteil zum Cache-Schluessel, ein belegter Befund zur Zugangsdaten-Erhaltung, und zwei Dokumente, die am Ende nachweislich mit dem Quelltext uebereinstimmen.

Der Schalter bleibt AUS. DATABASE_URL zeigt weiterhin auf die Rolle tessera mit BYPASSRLS. Das Scharfschalten ist Etappe 4 und findet hier NICHT statt. Schema und Migrationen werden NICHT angefasst. Nichts wird in Active Directory geaendert.

<execution_context> @/.claude/gsd-core/workflows/execute-plan.md @/.claude/gsd-core/templates/summary.md </execution_context>

@.planning/STATE.md @docs/mandantentrennung-zugriffsklassifikation.md @docs/mandantentrennung-etappe2-fehlerrichtung.md @apps/api/prisma/migrations/20260909140000_rls_remaining_tenant_tables/migration.sql @apps/api/prisma/migrations/20260910120000_rls_widen_membership_grant_and_platform_read/migration.sql @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/calendar/calendar.service.ts @apps/api/src/calendar/calendar.controller.ts @apps/api/src/calendar/calendar.module.ts @apps/api/src/calendar/dto/update-calendar-source.dto.ts @apps/api/src/dkv/dkv.service.ts @apps/api/src/dkv/dkv.service.spec.ts @apps/api/src/dashboard/dashboard.service.spec.ts @apps/api/src/auth/strategies/jwt.strategy.ts @.planning/quick/260910-krx-mandantentrennung-etappe-2-bereich-dashb/260910-krx-PLAN.md

<planning_time_findings>

Alle Zahlen unten sind zur Planungszeit am 2026-09-11 gegen HEAD 50b3a36 GEMESSEN, mit der jeweils angegebenen Anweisung. Sie leiten die Untersuchung, sie sind KEINE Bearbeitungsvollmacht — jede Datei wird vor jeder Aenderung erneut gelesen, jede Zahl zur Ausfuehrungszeit erneut gemessen, und weicht eine Messung ab, gilt die Messung und nicht dieser Plan.

Befund A — die Zahl haelt, ein Modell, eine Datei. Gemessen mit grep -rnoE "this\.prisma\.[a-zA-Z]+" apps/api/src/calendar --include=*.ts | grep -v spec: zwoelf Treffer, alle in calendar.service.ts, alle auf calendarSource (Zeilen 124, 163, 176, 210, 227, 239, 248, 267, 278, 361, 387, 398). Verteilt auf sechs Methoden mit Datenbankzugriff: getSources (1), addSource (1), updateSource (2), deleteSource (2), testConnection (3), fetchAndCacheEvents (3). aggregateEvents und refreshCacheInBackground greifen nicht selbst zu, sie delegieren an fetchAndCacheEvents. calendar.controller.ts, calendar.module.ts, die vier DTOs und die drei Provider unter providers/ halten null Zugriffe (gemessen mit grep -rln "prisma" apps/api/src/calendar --include=*.ts — einzige Datei ist der Dienst). Fuenfter Bereich in Folge, in dem beim Hineinschauen nichts schrumpft. Die eine Bestandsaufnahme-Zeile des Bereichs (calendar.service.ts/calendarSource, muss-mandantengebunden, ungebunden) stimmt mit dem Quelltext ueberein.

Befund B — keine Transaktion, kein Roh-SQL, kein Hintergrunddienst — aber ein Sonderfall. grep -rn '\$transaction(\|\$queryRaw\|\$executeRaw' apps/api/src/calendar --include=*.ts: null Treffer; withTenantTransaction() wird hier nicht gebraucht. grep -rn "@Cron\|onModuleInit\|onApplicationBootstrap\|setInterval\|setTimeout" apps/api/src/calendar --include=*.ts: ein einziger Treffer, providers/ics.provider.ts:100 — ein setTimeout fuer den Abbruch eines HTTP-Abrufs nach acht Sekunden, kein Planer. ABER: refreshCacheInBackground (Zeile 430) ist eine ABGEKOPPELTE Fortsetzung einer Anfrage: aggregateEvents stoesst sie an, wartet nicht auf sie, und sie ruft fetchAndCacheEvents mit den Parametern der Anfrage auf. Nach der Umstellung muss sie die Mandantenkennung der Anfrage MITNEHMEN — sie hat keinen anderen Mandanten, aus dem sie schoepfen koennte. Das ist NICHT die Bauform des Hintergrunddienst-Abschnitts (uebergreifend lesen, dann je Mandant binden), sondern ein Anfragekontext, der die Anfrage ueberlebt. Aufgabe 3 haelt das im Hintergrunddienst-Abschnitt als PLAIN-Absatz fest.

Befund C — es gibt KEINE Testdatei. ls apps/api/src/calendar/*.spec.ts liefert nichts. Das ist die dkv-Form (260909-mir): eine calendar.service.spec.ts mit dem Zwei-Klienten-Nachweis ist die VORAUSSETZUNG dafuer, dass irgendeine Aussage dieses Plans nachpruefbar ist — nicht eine Zugabe. Der Dienst hat fuenf Konstruktorabhaengigkeiten (PrismaService, CryptoService, ICSProvider, CalDAVProvider, ExchangeProvider); die Provider reden mit echten Servern und bekommen Attrappen (fetchEvents/testConnection als vi.fn), sie werden NICHT ausgeuebt. Vorlage fuer Aufbau und Attrappen: dkv.service.spec.ts (makeFakeCrypto, __makeBoundClient, expectBoundCall); Vorlage fuer den Wachhund "genau ein Klient je Aufruf": dashboard.service.spec.ts ab Zeile 545 (vi.mocked(forTenant).mock.calls.length).

Befund D — die Besitzpruefungen sind ECHT, in allen drei Pfaden, und sie antworten anders als im Bereich dashboard. updateSource (176-186), deleteSource (227-237) und testConnection (248-250) laden ueber die Kennung, pruefen existing.userId !== userId gegen die Benutzerkennung aus dem Sitzungsnachweis und werfen ForbiddenException('Not your calendar source') — nicht NotFoundException wie dashboard. Ein fremder Nutzer bekommt damit 403 statt 404: die Existenz einer Kennung wird preisgegeben. Kennungen sind UUIDs, also nicht erratbar; nach der Bindung bekommt ein Nutzer eines FREMDEN Mandanten ohnehin 404 (Zeile unsichtbar), nur der Kollege DESSELBEN Mandanten weiterhin 403. Dieser Plan aendert die Antwortsemantik NICHT (waere eine API-Aenderung ausserhalb des Auftrags), haelt sie aber in (k4) fest. Die Echtheit aller drei Pruefungen ist zur Ausfuehrungszeit erneut zu lesen, bevor sie im SUMMARY behauptet wird — dieses Vorhaben fand bei ldap und dkv je einmal keine.

Was daraus folgt: die beiden (bei testConnection: drei) Abfragen jedes Pfads muessen ueber DENSELBEN gebundenen Klienten laufen. Waere das Nachschlagen gebunden und das Schreiben nicht, ginge die Pruefung auf einer Zeile auf, die der Schreibvorgang nicht mehr sieht — und umgekehrt.

Befund E — die zerstoerende dkv-Form der Zugangsdaten-Erhaltung existiert hier NICHT, und das ist ein Befund, kein Freispruch. dkv.service.ts saveConfig liest die bestehende Zeile, entschluesselt das gespeicherte Passwort und verschluesselt es neu, wenn das Formularfeld leer blieb — laeuft dieser Lesezugriff nach dem Scharfschalten leer, wird ein leerer Wert verschluesselt abgelegt (d3, Stelle 5). calendar.service.ts updateSource (204-208) macht etwas anderes: if (dto.password !== undefined) — nur wenn das Feld im Rumpf STEHT, wird geschrieben (gesetzt: verschluesseln; leer: null, also ausdrueckliches Loeschen); FEHLT das Feld, wird encryptedPassword im Prisma-update gar nicht angefasst und bleibt in der Datenbank stehen. Es gibt keinen Lesezugriff, der leer laufen koennte. Auf der Web-Seite (apps/web/src/components/settings/calendar-source-form.tsx, um Zeile 149: if (password) payload.password = password;) wird ein leer gelassenes Passwortfeld WEGGELASSEN, nicht als leere Zeichenkette gesendet — die Erhaltung laeuft also per Weglassen, und update-calendar-source.dto.ts fuehrt password als @IsOptional(). Die einzige Stelle, die gespeicherte Zugangsdaten LIEST, um sie zu benutzen, ist testConnection (256-258, Verbindungstest mit gespeichertem Passwort) und fetchAndCacheEvents (374-376) — beide entschluesseln nur, sie schreiben nichts Entschluesseltes zurueck. Zur Ausfuehrungszeit an allen vier Stellen erneut nachzulesen; faellt es anders aus, ist die dkv-Reparatur (gemeinsame Bindung, kein stilles Weiterlaufen mit leerem Wert) hier anzuwenden und der Befund umzudrehen, nicht zu uebergehen.

Befund F — der Cache-Schluessel traegt eine plattformweit eindeutige Kennung, und die Kette ist vierteilig. eventCache (Zeile 109) wird mit ${userId}:${from}:${to} (Zeile 337) beschluesselt, ohne Mandantenanteil. Die Benutzerkennung stammt aus calendar.controller.ts extractContext (req.user?.id), das ist laut apps/api/src/auth/strategies/jwt.strategy.ts validate das Feld id: payload.sub, das laut apps/api/src/auth/auth.service.ts (Zeile 143, sub: user.id) die Datenbankkennung ist, und die traegt laut apps/api/prisma/schema.prisma model User @id @default(uuid()). Die Etappe-3-Entscheidung des Users vom 2026-09-10 (STATE.md, Sitzungsabschnitt, Commit 89fb027) betrifft User.username/User.email — NICHT User.id. Damit lautet das voraussichtliche Urteil: der Schluessel ist sicher, weil er eine UUID traegt, die kein Mandant mit einem anderen teilen kann; er bleibt unveraendert. Das Urteil wird in Aufgabe 1 Glied fuer Glied nachgesehen und aufgeschrieben, nicht aus diesem Absatz uebernommen. Faellt ein Glied anders aus (etwa: req.user.id waere ein Anmeldename), bekommt der Schluessel in Aufgabe 2 einen Mandantenanteil.

Befund G — die Regel kennt keine Benutzerdimension, und das wiegt hier schwerer als anderswo. 20260909140000_rls_remaining_tenant_tables/migration.sql Zeile 61: USING ("tenantId" = current_tenant_id()), ein Ausdruck, ohne WITH CHECK, ohne Benutzerdimension. Gemessen mit grep -n CalendarSource apps/api/prisma/migrations/20260910120000_rls_widen_membership_grant_and_platform_read/migration.sql: null Treffer — die Regelaenderung von 260910-jab hat diese Tabelle NICHT angefasst, die Regel aus 20260909140000 ist der Stand, gegen den gemessen wird (Aufgabe 1 prueft das zur Laufzeit im Werkzeug, damit die Messfalle aus 260910-jab — still die abgeloeste Regel messen — hier nicht zuschlagen kann). Zwei Nutzer DESSELBEN Mandanten sind fuereinander auf Datenbankebene vollstaendig sichtbar — einschliesslich encryptedPassword. Die Etappe-3-Entscheidung (2) des Users (Kollegen strikt getrennt, Benutzerdimension in den Regeln, CalendarSource ausdruecklich in der Liste) schliesst das spaeter; bis dahin sind der userId-Filter in getSources/fetchAndCacheEvents und die drei Besitzpruefungen der EINZIGE Schutz und bleiben unveraendert.

Befund H — keine Eindeutigkeitskette. model CalendarSource traegt ausser dem Primaerschluessel (UUID, clientseitig erzeugt) KEINE Eindeutigkeitsbedingung. Die Kette unsichtbare Zeile -> falsches 'frei' -> harter Eindeutigkeitsfehler (WINDOWS #22, tenders/user/dashboard) kann hier strukturell nicht auftreten — der zweite Bereich nach module-registry, in dem sie abwesend statt umgangen ist. Es wird deshalb KEINE Konfliktuebersetzung eingebaut, die nichts uebersetzt. Aufgabe 1 misst stattdessen, was ein gebundenes update ueber den generierten Client auf eine unsichtbare Zeile tut — das ist der Fall, den testConnection/fetchAndCacheEvents bei einem Wettlauf zwischen Nachschlagen und Rueckschreiben traefen.

Befund I — welcher Code Leere als Abwesenheit deutet, Backend. Zwei Stellen: getSources (124-136) liefert bei null Treffern eine leere Liste, Status 200; fetchAndCacheEvents (365) if (sources.length === 0) return []; — kehrt VOR dem Cache-Eintrag zurueck, also wird ein leeres Quellenergebnis nicht einmal fuer fuenf Minuten festgehalten, es wird bei jedem Aufruf neu leer geliefert. Die Besitzpruefungspfade (updateSource, deleteSource, testConnection) sind LAUT: ein zu kleines Nachschlagen wirft NotFoundException('Calendar source not found'). addSource ist laut in die andere Richtung: ungebunden unter der Anwendungsrolle wuerde das Einfuegen mit SQLSTATE 42501 abgewiesen (gemessen in Aufgabe 1).

Befund J — welcher Code Leere als Abwesenheit deutet, Frontend, und die Fehlerverschluckung. Gemessen an drei Web-Dateien, die dieser Plan NICHT aendert:

  1. apps/web/src/components/dashboard/widgets/calendar-widget.tsx, um Zeile 37: if (sources.length === 0) — leere Quellenliste heisst emptyNoSources ("keine Quelle eingerichtet"); um Zeile 50: catch { // Silent fail — show empty state } — ein FEHLER von GET /calendar/sources oder GET /calendar/events (403, 500, Netzwerk) fuehrt zu setEvents([]), also zu demselben leeren Zustand wie eine erfolgreiche leere Antwort.
  2. apps/web/src/components/settings/calendar-settings-panel.tsx, um Zeile 49: .catch(() => { // Silent fail — show empty state }); um Zeile 127: sources.length === 0 zeigt sourceEmpty. Dieselbe Verschluckung auf der Einstellungsseite.
  3. calendar-source-form.tsx (Befund E) — nicht Leere, sondern Weglassen; gehoert hierhin, weil es die Erhaltungsfrage beantwortet.

Folge nach dem Scharfschalten bei einem zu kleinen Lesepfad: Widget zeigt "keine Quelle eingerichtet", Einstellungsseite zeigt "keine Quelle eingerichtet", der Nutzer legt seine Quelle NEU an (addSource, gebunden, gelingt), tippt sein Passwort erneut ein, und die urspruengliche Zeile bleibt unsichtbar liegen — nicht ueberschrieben (anders als dashboard), aber verdoppelt, sobald die Ursache behoben ist: doppelte Quellen, doppelte Termine. Und: weil das Frontend Fehler verschluckt, ist selbst ein LAUTER Fehler (etwa ein 500 durch eine halb gebundene Schleife) fuer den Nutzer vom leeren Kalender nicht zu unterscheiden — das Signal existiert nur im Netzwerkprotokoll des Browsers und im API-Log. Zur Ausfuehrungszeit an den Dateien erneut zu pruefen, bevor es in der Kritikschrift behauptet wird.

Befund K — die halb gebundene Schleife als eigener Gefahrenfall. fetchAndCacheEvents laedt die Quellen (361) und schreibt je Quelle den Synchronstatus zurueck: bei Erfolg (387), bei Fehler im catch (398). Waere das Laden gebunden und das Rueckschreiben nicht (oder umgekehrt), traefe das Rueckschreiben nach dem Scharfschalten keine Zeile, Prisma wuerfe 'Record to update not found', der catch versuchte das Fehler-Rueckschreiben, das ebenso scheitert, und Promise.allSettled liesse das Ergebnis dieser Quelle als rejected STILL fallen — die Ereignisse fehlen, lastSyncError wird nie gesetzt, der Nutzer sieht "keine Termine". Deshalb: ein gebundener Klient je Methode, und die beiden Rueckschreibungen als Testfaelle festgenagelt.

Befund L — der Controller verwirft den Mandanten in fuenf von sechs Handlern. extractContext (Zeile 38-51) loest userId und tenantId aus dem Sitzungsnachweis auf und bricht ohne einen von beiden mit ForbiddenException ab. addSource reicht beide durch; getSources, updateSource, deleteSource, testSource, getEvents nehmen nur die Benutzerkennung. testSourceConfig ruft extractContext gar nicht auf und hat keinen Datenbankzugriff — er bleibt unveraendert. Die Umstellung ist ein Durchreichen; die Parameterreihenfolge des Dienstes folgt addSource: Mandantenkennung unmittelbar hinter der Benutzerkennung.

</planning_time_findings>

Aufgabe 1: Die Fehlerrichtung fuer diesen Bereich MESSEN und aufschreiben — an der Regel, wie sie nach der Migration 20260910120000 steht, durch den generierten Client Der lokale Datenbank-Container `tessera-ctl-db-1` laeuft; `docker inspect tessera-ctl-db-1` liefert eine Adresse. Ohne ihn kann das Wegwerf-Werkzeug nichts messen und die Aufgabe ist zu stoppen, nicht zu schaetzen. apps/api/scripts/rls-scratch-check.mjs, docs/mandantentrennung-etappe2-fehlerrichtung.md apps/api/scripts/rls-scratch-check.mjs (Kopf, `extractPolicySql`, `readRlsWidenMigrationSql`, `runDkvAreaChecks`, `runDashboardAreaChecks` VOLLSTAENDIG einschliesslich Pruefung 5b, `buildInlineExtendedClient`, `main`), apps/api/prisma/migrations/20260909140000_rls_remaining_tenant_tables/migration.sql, apps/api/prisma/migrations/20260910120000_rls_widen_membership_grant_and_platform_read/migration.sql, apps/api/prisma/migrations/20260629130000_add_missing_tables/migration.sql (CREATE TABLE "CalendarSource"), apps/api/prisma/migrations/20260723111946_add_rss_feed_source_and_poll_granularity/migration.sql (ALTER "CalendarSource" ADD "domain"), apps/api/prisma/schema.prisma (model CalendarSource, model User), apps/api/src/calendar/calendar.service.ts, apps/api/src/calendar/calendar.controller.ts, apps/api/src/calendar/dto/update-calendar-source.dto.ts, apps/api/src/auth/strategies/jwt.strategy.ts, apps/api/src/auth/auth.service.ts (Aufbau des Sitzungsnachweises), apps/web/src/components/dashboard/widgets/calendar-widget.tsx, apps/web/src/components/settings/calendar-settings-panel.tsx, apps/web/src/components/settings/calendar-source-form.tsx, docs/mandantentrennung-etappe2-fehlerrichtung.md (Abschnitte `## Bereich dkv` (d3) und `## Bereich dashboard` vollstaendig) TEIL 1 — die Messung. Erweitere `apps/api/scripts/rls-scratch-check.mjs` um einen elften Abschnitt `runCalendarAreaChecks(adminUrl, scratchRoleUrl, results)` und rufe ihn in `main()` NACH `runDashboardAreaChecks` und VOR `runTransactionShapeMeasurement` auf. Der Abschnitt ist ein Blatt in der Aufrufkette: er legt die Wegwerf-Tabelle `CalendarSource` selbst an, setzt auf keiner Tabelle eines anderen Abschnitts auf, und keine spaetere Pruefung setzt auf seiner auf. Halte diese Reihenfolgebedingung im Kopfkommentar fest, so wie `runDkvAreaChecks` es vormacht.

Die Regel wird mit extractPolicySql() WORTGLEICH aus 20260909140000_rls_remaining_tenant_tables geschnitten, nicht nachgetippt. Zusaetzlich — die Messfalle aus 260910-jab — prueft der Abschnitt zur Laufzeit, dass readRlsWidenMigrationSql() KEINE Regel fuer "CalendarSource" enthaelt (Befund G); faende er eine, meldet er eine FEHLGESCHLAGENE Pruefung calendarsource-regelstand-eindeutig und bricht ab, statt die abgeloeste Regel weiterzumessen. Findet extractPolicySql() die Regel nicht, ebenso Abbruch mit FEHLGESCHLAGEN.

Die Wegwerf-Tabelle traegt SAEMTLICHE Spalten des Modells CalendarSource mit den Typen und Vorgaben der ausgelieferten Migrationen (CREATE TABLE aus 20260629130000 plus die Spalte domain aus 20260723111946) — nicht nur die, die Roh-SQL braucht. Das ist die Lehre aus Pruefung 5b im Bereich dashboard: der generierte Client waehlt standardmaessig JEDE Spalte des Modells aus und scheitert mit P2022 an jeder fehlenden, Roh-SQL merkt das nie. Lies die Spaltenliste zur Laufzeit aus apps/api/prisma/schema.prisma (Block model CalendarSource, Feldname = erstes Wort jeder Zeile, die nicht leer ist, nicht mit @@ und nicht mit // beginnt) und vergleiche sie mit information_schema.columns der angelegten Tabelle — das ist Pruefung 8 unten, keine Annahme.

Testzeilen ueber die Wartungsrolle: zwei Quellen unter TENANT-A mit VERSCHIEDENEN Benutzerkennungen (user-a1, user-a2), beide mit einem gesetzten encryptedPassword-Platzhalter, damit Pruefung 3 zeigen kann, dass der Kollege die verschluesselten Zugangsdaten sieht; eine Quelle unter TENANT-B; alle mit isVisible = true und den Pflichtspalten.

Mindestens zwoelf namentlich benannte Pruefungen, jede mit einer Belegausgabe, die die beobachteten Werte nennt:

  1. calendarsource-gebunden-nur-eigener-mandant — gebundener Lesezugriff fuer TENANT-A liefert ausschliesslich A-Zeilen.
  2. calendarsource-ungebunden-null-zeilen — die tragende Belegzeile: der IDENTISCHE Lesezugriff ohne Mandantenkontext liefert null Zeilen, nicht alle vorhandenen.
  3. calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar — das GELINGEN ist das bestandene Ergebnis: die Regel kennt keine Benutzerdimension, die Zeile von user-a2 ist unter TENANT-A sichtbar, EINSCHLIESSLICH encryptedPassword. Die Belegausgabe sagt ausdruecklich, dass die verschluesselten Zugangsdaten eines Kollegen auf Datenbankebene lesbar sind und die anwendungsseitige Filterung ueber die Benutzerkennung deshalb der einzige Schutz bleibt — bis die Etappe-3-Entscheidung (2) die Benutzerdimension nachzieht.
  4. calendarsource-ungebundene-einzelabfrage-ueber-kennung-liefert-keine-zeile — die Datenbankseite der drei Besitzpruefungen (Befund I): ein ungebundenes Nachschlagen ueber eine vorhandene Kennung liefert null Zeilen; das ist der Weg in NotFoundException.
  5. calendarsource-gebundenes-einfuegen-fremder-mandant-abgelehnt — ein gebundenes Einfuegen unter TENANT-A mit tenantId = TENANT-B wird mit SQLSTATE 42501 abgewiesen.
  6. calendarsource-gebundenes-loeschen-fremder-zeile-trifft-keine-zeile — gebundenes Loeschen ueber die Kennung der B-Zeile entfernt nichts, wirft nichts; die Zeile ist danach ueber die Wartungsrolle noch da.
  7. calendarsource-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen — gebundenes UPDATE ... WHERE id = <B-Zeile> unter TENANT-A trifft null Zeilen (Roh-SQL, lastSyncError bleibt unveraendert).
  8. calendarsource-wegwerftabelle-deckt-alle-spalten-des-generierten-clients — Spaltenmenge der Wegwerf-Tabelle ist identisch mit der Feldmenge des Modells im Schema (siehe oben). Faellt sie durch, sind alle folgenden Client-Messungen wertlos — deshalb steht sie VOR ihnen.
  9. calendarsource-generierter-client-gebundene-quellenliste-nur-eigener-mandant — ueber buildInlineExtendedClient(prisma, 'TENANT-A').calendarSource.findMany({ where: { userId: 'user-a1', isVisible: true } }), also der Abfrage, die fetchAndCacheEvents stellt: genau die A1-Zeile.
  10. calendarsource-generierter-client-ungebundene-quellenliste-null-zeilen — dieselbe Abfrage auf dem UNGEBUNDENEN generierten Client liefert eine leere Liste ohne Fehler. Die Belegausgabe nennt es beim Namen: das ist exakt der Wert, den getSources als "keine Quelle" und fetchAndCacheEvents als "keine Termine" weiterreicht.
  11. calendarsource-generierter-client-gebundenes-update-auf-unsichtbare-zeile-scheitert-laut — bound.calendarSource.update({ where: { id: <B-Zeile> }, data: { lastSyncError: 'x' } }) unter TENANT-A. Bestanden genau dann, wenn ein Fehler geworfen wird; die Belegausgabe nennt KONSTRUKTORNAME und code woertlich, damit (k2) und die Kritikschrift die tatsaechlich gemessene Klasse nennen. Rate das Ergebnis NICHT vorweg — es ist der Wettlauf-Fall aus Befund H/K.
  12. calendarsource-generierter-client-gebundenes-anlegen-eigener-mandant-gelingt — bound.calendarSource.create(...) unter TENANT-A mit tenantId: 'TENANT-A' und den Pflichtfeldern (der addSource-Weg) gelingt, die Zeile ist danach gebunden lesbar. Das prueft nebenbei, dass die Wegwerf-Tabelle die clientseitig erzeugten Werte (id, createdAt, updatedAt) annimmt.

Ergaenzt wird nur, gestrichen wird nicht; nenne im SUMMARY die tatsaechlich gezaehlte Zahl, nicht diese.

TEIL 2 — die Codeaussagen, jede mit ihrer Reichweite. Fuehre die Nachpruefungen aus den Befunden D, E, F, J und L tatsaechlich aus und notiere jeweils die Anweisung oder die Datei-und-Zeile, mit der du nachgesehen hast, damit jede Aussage widerlegbar bleibt:

  • Befund D: die drei Besitzpruefungen — steht in updateSource, deleteSource UND testConnection zwischen Nachschlagen und Schreiben ein Vergleich gegen die Benutzerkennung aus dem Sitzungsnachweis? Welche Ausnahme wird geworfen?
  • Befund E: die Zugangsdaten-Erhaltung — gibt es in calendar.service.ts einen Lesezugriff, der ein gespeichertes Passwort laedt, um es neu zu verschluesseln? Wie behandelt updateSource die drei Faelle Feld fehlt, Feld leer, Feld gesetzt? Sendet calendar-source-form.tsx ein leeres Feld oder laesst es es weg?
  • Befund F: das Urteil zum Cache-Schluessel — alle vier Glieder (Schema User.id, JwtStrategy.validate, Aufbau des Sitzungsnachweises in auth.service.ts, extractContext im Controller), jedes einzeln nachgesehen. Formuliere das Urteil in EINEM Satz, der die Etappe-3- Entscheidung (1) beim Namen nennt und sagt, warum sie den Schluessel beruehrt oder nicht.
  • Befund J: die drei Web-Dateien — an welcher Zeile wird Leere als "keine Quelle" gedeutet, an welcher Zeile wird ein Fehler verschluckt?
  • Befund L: welche Handler verwerfen den Mandanten, welche nicht.
  • Befund B: die Anweisung fuer den Hintergrunddienst-Abschnitt und der Sonderfall der abgekoppelten Cache-Auffrischung.

Faellt eine Nachpruefung ANDERS aus als in den Planungsbefunden, gilt die Messung; schreibe sie auf und benenne die Abweichung ausdruecklich.

TEIL 3 — die Kritikschrift. Erweitere docs/mandantentrennung-etappe2-fehlerrichtung.md um einen Abschnitt ## Bereich calendar unmittelbar VOR ## Verweis, in der Form der vorhandenen Bereichsabschnitte, mit fuenf Unterabschnitten unter dem Buchstaben k:

  • ### (k1) Die Messung — die tatsaechlich beobachtete Werkzeugausgabe woertlich eingerueckt, die tragende Belegzeile benannt, ausdruecklich der Regelstand NACH 20260910120000 samt der Anweisung, mit der belegt ist, dass jene Migration CalendarSource nicht anfasst. Nenne, welche Pruefungen ueber den generierten Client laufen und warum (dashboard-Lehre).
  • ### (k2) Signaltabelle je umgestelltem Pfad — je Dienstmethode mit Datenbankzugriff eine Zeile (sechs Zeilen), plus eine fuer refreshCacheInBackground: Verhalten bei zu wenig Ergebnis und das konkrete Signal, an dem man es saehe — UND, in einer eigenen Spalte oder im Text, ob das Frontend dieses Signal durchlaesst oder verschluckt. Die Zeile zu fetchAndCacheEvents nennt die gemessene Fehlerklasse aus Pruefung 11 fuer den Wettlauf-Fall und die halb gebundene Schleife aus Befund K.
  • ### (k3) Welcher Code Leere als Abwesenheit deutet — namentlich, mit Dateiname und Stelle, Backend (zwei Stellen, Befund I) getrennt vom Frontend (Befund J). Beschreibe die Folgekette: leerer Kalender, "Synchronisation kaputt" oder "keine Quelle", Neuanlage, Passwort ein zweites Mal eingetippt, urspruengliche Zeile bleibt unsichtbar liegen, Dublette nach Behebung. Grenze ausdruecklich gegen dashboard ab: hier wird nichts ueberschrieben, aber der Nutzer gibt Zugangsdaten in ein scheinbar defektes System ein. Und benenne die Fehlerverschluckung als eigenen Punkt: selbst ein LAUTER Fehler der Lesepfade sieht fuer den Nutzer wie ein leerer Kalender aus.
  • ### (k4) Was dieser Durchlauf bewusst nicht löst — (a) das Urteil zum Cache-Schluessel mit der vierteiligen Kette und der Anweisung je Glied; (b) der Befund zur Zugangsdaten-Erhaltung (Befund E) mit dem Vergleich zur dkv-Form und der Aussage, warum hier kein Lesezugriff leer laufen kann; (c) die fehlende Benutzerdimension der Regel (Befund G) mit Verweis auf die Etappe-3-Entscheidung (2) und dem Hinweis, dass die verschluesselten Zugangsdaten eines Kollegen bis dahin datenbankseitig sichtbar sind; (d) 403 statt 404 als Existenzpreisgabe zwischen Kollegen (Befund D); (e) die fehlende Unterscheidbarkeit von "keine Quelle" und "Quelle nicht sichtbar" und die konkrete Vorabpruefung fuer Etappe 4 (rls-preflight.mjs: physisch vorhandene CalendarSource-Zeilen je Mandant ueber die Wartungsrolle zaehlen und mit der gebundenen Zaehlung je Mandant vergleichen — jede Abweichung ist ein Trennungsfehler, kein Erstbenutzer). Eine Laufzeitwarnung ist zu erwaegen und, wenn verworfen, mit eigener Begruendung zu verwerfen (Praezedenz: getAllActiveConfigs im Bereich ldap, Dauerlaerm auf frischer Installation) — begruende, uebernimm nicht.
  • ### (k5) Was dieser Durchlauf bewusst nicht anfasst — das Frontend (nur beschrieben), die drei Provider (reden mit echten Servern, werden nicht getestet), testConnectionFromConfig (kein Datenbankzugriff, kein Mandant, unveraendert), der Bereich favorites (eigener Bereich), die Antwortsemantik 403/404, und der Fremdkommentar in ldap-config.service.ts (Zeile 24, nennt CalendarSource als Vorbild der Verschluesselung) — zutreffend, nicht zu aendern.

Aendere in dieser Aufgabe KEINE Datei unter apps/api/src, KEINE unter apps/api/prisma und KEINE unter apps/web. DB_IP=$(docker inspect tessera-ctl-db-1 --format '{{range $k,$v := .NetworkSettings.Networks}}{{$v.IPAddress}}{{end}}') && OUT=$(TESSERA_SCRATCH_ADMIN_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/postgres" node apps/api/scripts/rls-scratch-check.mjs) && echo "$OUT" && for K in calendarsource-gebunden-nur-eigener-mandant calendarsource-ungebunden-null-zeilen calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar calendarsource-ungebundene-einzelabfrage-ueber-kennung-liefert-keine-zeile calendarsource-gebundenes-einfuegen-fremder-mandant-abgelehnt calendarsource-gebundenes-loeschen-fremder-zeile-trifft-keine-zeile calendarsource-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen calendarsource-wegwerftabelle-deckt-alle-spalten-des-generierten-clients calendarsource-generierter-client-gebundene-quellenliste-nur-eigener-mandant calendarsource-generierter-client-ungebundene-quellenliste-null-zeilen calendarsource-generierter-client-gebundenes-update-auf-unsichtbare-zeile-scheitert-laut calendarsource-generierter-client-gebundenes-anlegen-eigener-mandant-gelingt; do echo "$OUT" | grep -q "^$K: bestanden" || { echo "FEHLENDE ODER FEHLGESCHLAGENE PRUEFUNG: $K"; exit 1; }; done && echo "$OUT" | grep -qE '^Alle [0-9]+ Pruefungen bestanden.$' && N=$(echo "$OUT" | sed -nE 's/^Alle ([0-9]+) Pruefungen bestanden.$/\1/p') && { test "$N" -ge 100 || { echo "PRUEFUNGSZAHL: $N, erwartet mindestens 100 (88 bisherige plus mindestens 12 neue)"; exit 1; }; } && grep -q 'runCalendarAreaChecks' apps/api/scripts/rls-scratch-check.mjs && awk '/await runDashboardAreaChecks(/{d=NR} /await runCalendarAreaChecks(/{c=NR} /await runTransactionShapeMeasurement(/{t=NR} END{ if(!(d&&c&&t&&d<c&&c<t)){print "REIHENFOLGE in main(): runCalendarAreaChecks muss nach runDashboardAreaChecks und vor runTransactionShapeMeasurement stehen"; exit 1} }' apps/api/scripts/rls-scratch-check.mjs && grep -q '^## Bereich calendar$' docs/mandantentrennung-etappe2-fehlerrichtung.md && for S in k1 k2 k3 k4 k5; do grep -qE "^### ($S) " docs/mandantentrennung-etappe2-fehlerrichtung.md || { echo "FEHLENDER UNTERABSCHNITT: ($S)"; exit 1; }; done && awk '/^## Bereich calendar$/{f=1; next} /^## /{f=0} f && /calendar-widget|calendar-settings-panel|calendar-source-form/{m++} f && /JwtStrategy|jwt.strategy/{j=1} f && /uuid/{u=1} f && /20260910120000/{r=1} END{ if(m+0 < 3){print "ABSCHNITT (k3): die drei gemessenen Frontend-Dateien sind nicht namentlich genannt"; exit 1} if(!j||!u){print "ABSCHNITT (k4): das Urteil zum Cache-Schluessel nennt nicht beide Glieder der Kette (JwtStrategy, uuid)"; exit 1} if(!r){print "ABSCHNITT (k1): der Regelstand nach 20260910120000 ist nicht benannt"; exit 1} }' docs/mandantentrennung-etappe2-fehlerrichtung.md && npm --prefix apps/api run test && npm --prefix apps/api run type-check && git rev-parse --verify 50b3a36 >/dev/null && PRISMA_CHANGED=$(git diff --name-only 50b3a36 -- apps/api/prisma) && { test -z "$PRISMA_CHANGED" || { printf 'SCHEMA/MIGRATION GEAENDERT — in diesem Plan verboten:\n%s\n' "$PRISMA_CHANGED"; exit 1; }; } && CHANGED=$(git diff --name-only 50b3a36) && UNEXPECTED=$(printf '%s\n' "$CHANGED" | grep -v '^$' | grep -vE '^(apps/api/scripts/rls-scratch-check.mjs|docs/mandantentrennung-etappe2-fehlerrichtung.md|.planning/.*)$' || true) && { test -z "$UNEXPECTED" || { printf 'UNERWARTETE AENDERUNGEN AUSSERHALB DES PLANUMFANGS:\n%s\n' "$UNEXPECTED"; exit 1; }; } apps/api/scripts/rls-scratch-check.mjs hat einen elften Abschnitt runCalendarAreaChecks in der richtigen Reihenfolge mit mindestens zwoelf neuen, namentlich benannten Pruefungen gegen die aus der ausgelieferten Migration geschnittene Regel, davon vier ueber den generierten Client an einer Wegwerf-Tabelle, deren Spaltenmenge zur Laufzeit gegen das Schema geprueft wird; alle Pruefungen des Werkzeugs bestehen. docs/mandantentrennung-etappe2-fehlerrichtung.md hat einen Abschnitt ## Bereich calendar mit (k1) bis (k5), die tatsaechlich beobachtete Werkzeugausgabe woertlich, die drei Web-Dateien namentlich, das Urteil zum Cache-Schluessel mit vierteiliger Kette, den Befund zur Zugangsdaten-Erhaltung und die Vorabpruefung fuer Etappe 4. Baseline gehalten: 860 Tests gruen, Typpruefung sauber. Unter apps/api/src, apps/api/prisma, apps/web und den Compose-/Umgebungsdateien ist nichts geaendert.

Aufgabe 2: Die Testlage aus dem Nichts anlegen, dann alle zwoelf Zugriffe binden und den Mandanten durchreichen — Nachschlagen und Schreiben nie getrennt apps/api/src/calendar/calendar.service.spec.ts, apps/api/src/calendar/calendar.service.ts, apps/api/src/calendar/calendar.controller.ts, docs/mandantentrennung-zugriffsklassifikation.md apps/api/src/calendar/calendar.service.ts, apps/api/src/calendar/calendar.controller.ts, apps/api/src/dkv/dkv.service.spec.ts (Kopf, `makeFakePrisma` mit `__makeBoundClient`, `expectBoundCall`, `makeFakeCrypto`, `makeDkvService`), apps/api/src/dashboard/dashboard.service.spec.ts (Zeilen 545-570, Wachhund fuer einen Klienten je Aufruf), apps/api/src/prisma/prisma-tenant.extension.ts, apps/api/src/prisma/rls-access-inventory.spec.ts (`analyzeFile`, `computeStandByKey`), docs/mandantentrennung-zugriffsklassifikation.md (Bestandsaufnahme-Zeile `calendar.service.ts`), docs/mandantentrennung-etappe2-fehlerrichtung.md (Abschnitt `## Bereich calendar` aus Aufgabe 1) ZUERST die Testlage, DANN die Umstellung. Lege `apps/api/src/calendar/calendar.service.spec.ts` NEU an, in der Form von `dkv.service.spec.ts`: `vi.mock` auf das Bindungshilfsmittel, umgeleitet auf `prisma.__makeBoundClient(tenantId)`; ein handgerollter Prisma-Nachbau mit In-Memory-Zeilen fuer `calendarSource` (`findMany`, `findUnique`, `create`, `update`, `delete`), dessen ungebundene Form NICHT protokolliert und dessen gebundene Form je Aufruf Mandantenkennung, Modell und Methode in ein Bindungsprotokoll schreibt; Attrappen fuer `CryptoService` (`encrypt`/`decrypt` als umkehrbare Textfunktionen) und die drei Provider (`fetchEvents`/`testConnection` als `vi.fn`). Die Provider werden NICHT ausgeuebt.

Jeder Fall eigenstaendig, jeder mit sprechendem Namen:

  • getSources: laeuft gebunden mit der uebergebenen Mandantenkennung im Protokoll; die Antwort traegt hasCredentials und NIE encryptedPassword.
  • getSources von Nutzer A liefert nicht die Quellen von Nutzer B desselben Mandanten — der userId-Filter bleibt, die Bindung ergaenzt ihn.
  • addSource: gebunden, die Mandantenkennung wird als Pflichtwert geschrieben, das Passwort verschluesselt, die Antwort ohne encryptedPassword.
  • updateSource: BEIDE Abfragen (Nachschlagen und Aendern) ueber DENSELBEN gebundenen Klienten und dieselbe Mandantenkennung.
  • updateSource, Zugangsdaten-Erhaltung in drei Faellen (Befund E): Feld FEHLT — encryptedPassword bleibt unveraendert und es findet KEIN Lesezugriff statt, der ein Passwort laedt; Feld LEER — wird null; Feld GESETZT — wird verschluesselt. Diese drei Faelle nageln fest, dass hier keine dkv-Form existiert; sie werden rot, sobald jemand eine einbaut.
  • updateSource/deleteSource/testConnection: die Besitzpruefung bleibt wirksam — eine Quelle eines anderen Benutzers fuehrt weiterhin zu ForbiddenException, eine unbekannte Kennung zu NotFoundException. Drei Faelle je Ausnahmeart. Diese Faelle sind der Nachweis, dass die Bindung die Pruefung ERGAENZT und nicht ersetzt.
  • deleteSource: beide Abfragen ueber denselben gebundenen Klienten.
  • testConnection: alle drei Abfragen (Nachschlagen, Rueckschreiben bei Erfolg ODER Rueckschreiben im catch) ueber denselben gebundenen Klienten; der Provider erhaelt das ENTSCHLUESSELTE Passwort; bei Providerfehler ist die Antwort generisch (kein Passwort, keine Serverdetails).
  • aggregateEvents: das Laden der Quellen laeuft gebunden; die beiden Synchronstatus-Rueckschreibungen (Erfolgspfad UND Fehlerpfad, Befund K) stehen gebunden unter derselben Mandantenkennung im Protokoll. Zwei Faelle.
  • aggregateEvents ohne Quellen: Rueckgabe ist eine leere Liste, kein Fehler, und es wird KEIN Cache-Eintrag angelegt — die Deutung von Leere als Abwesenheit als heutiges Verhalten festgehalten, damit eine spaetere Aenderung sichtbar wird.
  • Cache: ein zweiter Aufruf innerhalb der Lebensdauer erzeugt keinen weiteren Datenbankzugriff; zwei VERSCHIEDENE Benutzerkennungen teilen sich keinen Eintrag (das Urteil aus Aufgabe 1, festgenagelt).
  • testConnectionFromConfig: kein Datenbankzugriff, weder gebunden noch ungebunden — der Nachbau bleibt unberuehrt.
  • Wachhund: keine Methode dieses Bereichs erzeugt mehr als EINEN gebundenen Klienten je Aufruf (Muster dashboard.service.spec.ts Zeile 545).

Die Zahl der Faelle wird am Ende ABGEZAEHLT und im SUMMARY mit der gezaehlten Zahl genannt, nicht mit der hier aufgelisteten — tenders und module-registry haben genau an dieser Stelle je eine falsche Zahl behauptet. Stelle in calendar.service.ts alle zwoelf Zugriffe auf forTenant() um. Ein gebundener Klient JE METHODE mit Datenbankzugriff, unter dem woertlichen Namen tenantPrisma in der Zuweisungsform, die rls-access-inventory.spec.ts erkennt, wie in jedem bereits umgestellten Bereich — sechs Aufrufstellen (getSources, addSource, updateSource, deleteSource, testConnection, fetchAndCacheEvents); aggregateEvents und refreshCacheInBackground erzeugen keinen eigenen Klienten, sie reichen die Mandantenkennung an fetchAndCacheEvents durch. Gebundene Klienten werden nicht zwischen Methoden weitergereicht. Die Mandantenkennung steht in jeder Signatur unmittelbar hinter der Benutzerkennung, wie addSource es vormacht.

Die bestehenden where-Filter ueber die Benutzerkennung und die drei Besitzpruefungen bleiben ausnahmslos stehen. Begruende im Quelltext an EINER Stelle (Klassenkommentar), warum sie kein Beiwerk sind: die Regel dieses Bereichs kennt keine Benutzerdimension (gemessen in Aufgabe 1), die verschluesselten Zugangsdaten eines Kollegen sind datenbankseitig sichtbar, und bis zum Scharfschalten ist die Anwendungspruefung ohnehin der einzige wirksame Schutz. Der veraltete Satz "Source config is per-user (D-09), not per-tenant" im Klassenkommentar ist zu praezisieren: je Nutzer UND je Mandant gebunden.

Schreibe das Urteil zum Cache-Schluessel aus Aufgabe 1 als Kommentar unmittelbar ueber die Zuweisung von eventCache: nenne User.id als das, was der Schluessel traegt, die Kette bis zum Sitzungsnachweis, und die Etappe-3-Entscheidung (1) mit dem Grund, warum sie den Schluessel nicht beruehrt. Lautet das Urteil aus Aufgabe 1 anders, bekommt der Schluessel einen Mandantenanteil — dann steht das hier, mit dem Grund. Die Entscheidung folgt der Messung, nicht diesem Absatz.

fetchAndCacheEvents und refreshCacheInBackground bekommen die Mandantenkennung als Parameter; die abgekoppelte Auffrischung nimmt sie aus der Anfrage mit, die sie angestossen hat. Halte im Kommentar von refreshCacheInBackground fest, dass sie den Mandanten der urspruenglichen Anfrage traegt und keinen anderen haben kann.

Reiche in calendar.controller.ts bei den fuenf Handlern, die den bereits aufgeloesten Mandanten heute verwerfen (getSources, updateSource, deleteSource, testSource, getEvents), diesen an den Dienst durch: jeder der sechs kontextnutzenden Handler nimmt Benutzer- UND Mandantenkennung aus extractContext in einer Destrukturierung (die Form, die addSource heute schon hat), keiner nimmt nur die Benutzerkennung. Die Mandantenkennung stammt unveraendert aus extractContext und damit aus dem Sitzungsnachweis — es entsteht KEINE neue Vertrauensquelle, aus Rumpf oder Pfad wird nichts uebernommen. testSourceConfig bleibt unveraendert. Schreibe den Kopfkommentar des Controllers nach, damit er das Durchreichen nennt.

Kommentare in calendar.service.ts duerfen die Zeichenfolge, mit der die Uebersichtstabelle der Klassifikation ungebundene Zugriffe zaehlt (siehe deren Messanweisung), NICHT woertlich enthalten — sonst zaehlt die Buchfuehrung einen Zugriff, den es nicht mehr gibt; das Gate vergleicht die Zaehlung mit und ohne Kommentare.

Ziehe in DIESER Aufgabe die eine Bestandsaufnahme-Zeile in docs/mandantentrennung-zugriffsklassifikation.md nach (calendar.service.ts/calendarSource, Spalte Stand auf den gemessenen Wert, Begruendung fortgeschrieben mit Verweis auf 260911-cwh, die gemeinsame Bindung je Pfad und den Befund zur Zugangsdaten-Erhaltung) — sonst ist die maschinelle Bestandspruefung am Ende dieser Aufgabe rot und die Baseline gebrochen (die Lehre aus 260910-exd). Die uebrigen vier handgepflegten Stellen sind Aufgabe 3.

Aendere keine Datei ausserhalb der vier genannten. Fuehre am Ende dieser Aufgabe einen Falsifizierungsnachweis durch: nimm probeweise die Bindung EINER Synchronstatus-Rueckschreibung in fetchAndCacheEvents zurueck (ungebundener Klient nur fuer diese eine Anweisung), ueberzeuge dich, dass die Testlage rot wird, notiere Testname und Fehlermeldung woertlich, und stelle den Zustand wieder her. 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 && npm --prefix apps/api run test -- src/calendar/calendar.service.spec.ts && npm --prefix apps/api run test -- src/prisma/rls-access-inventory.spec.ts && test -f apps/api/src/calendar/calendar.service.spec.ts && grep -q '__makeBoundClient' apps/api/src/calendar/calendar.service.spec.ts && grep -q "vi.mock('../prisma/prisma-tenant.extension'" apps/api/src/calendar/calendar.service.spec.ts && grep -qi 'cache' apps/api/src/calendar/calendar.service.spec.ts && grep -q 'testConnectionFromConfig' apps/api/src/calendar/calendar.service.spec.ts && grep -q "from '../prisma/prisma-tenant.extension'" apps/api/src/calendar/calendar.service.ts && SRC=$(grep -vE '^\s*(//|*|/*)' apps/api/src/calendar/calendar.service.ts) && B=$(printf '%s\n' "$SRC" | grep -o "tenantPrisma.calendarSource." | wc -l | tr -d ' ') && { test "$B" -ge 12 || { echo "BINDUNG: nur $B gebundene Modellzugriffe in calendar.service.ts, erwartet mindestens 12"; exit 1; }; } && U=$(printf '%s\n' "$SRC" | grep -o "this.prisma.[a-zA-Z]" | wc -l | tr -d ' ') && { test "$U" -eq 0 || { echo "REST: $U ungebundene Modellzugriffe in calendar.service.ts, erwartet 0 — dieser Bereich hat keinen begruendet ungebundenen Zugriff"; exit 1; }; } && URAW=$(grep -o "this.prisma.[a-zA-Z]" apps/api/src/calendar/calendar.service.ts | wc -l | tr -d ' ') && { test "$URAW" -eq "$U" || { echo "KOMMENTARE in calendar.service.ts nennen die ungebundene Zugriffsform woertlich ($URAW mit, $U ohne Kommentare) — die Uebersichtstabelle wuerde sie mitzaehlen"; exit 1; }; } && C=$(printf '%s\n' "$SRC" | grep -o 'forTenant(this.prisma' | wc -l | tr -d ' ') && { test "$C" -eq 6 || { echo "KLIENTEN: $C forTenant-Aufrufstellen in calendar.service.ts, erwartet genau 6 (eine je Methode mit Datenbankzugriff, keine zweite in derselben Methode, keine in aggregateEvents/refreshCacheInBackground)"; exit 1; }; } && grep -B10 'eventCache = new Map' apps/api/src/calendar/calendar.service.ts | grep -q 'User.id' && test 0 -eq "$(grep -rn '$transaction(' apps/api/src/calendar --include=.ts | grep -v spec | wc -l | tr -d ' ')" && H=$(grep -c 'const { userId, tenantId } = this.extractContext(req)' apps/api/src/calendar/calendar.controller.ts) && { test "$H" -eq 6 || { echo "CONTROLLER: $H Handler nehmen Benutzer- und Mandantenkennung, erwartet 6"; exit 1; }; } && test 0 -eq "$(grep -vE '^\s(//|*|/*)' apps/api/src/calendar/calendar.controller.ts | grep -c 'const { userId } = this.extractContext')" && grep -qE '^| apps/api/src/calendar/calendar.service.ts | calendarSource | muss-mandantengebunden | gebunden |' docs/mandantentrennung-zugriffsklassifikation.md && git rev-parse --verify 50b3a36 >/dev/null && PRISMA_CHANGED=$(git diff --name-only 50b3a36 -- apps/api/prisma) && { test -z "$PRISMA_CHANGED" || { printf 'SCHEMA/MIGRATION GEAENDERT — in diesem Plan verboten:\n%s\n' "$PRISMA_CHANGED"; exit 1; }; } && CHANGED=$(git diff --name-only 50b3a36) && UNEXPECTED=$(printf '%s\n' "$CHANGED" | grep -v '^$' | grep -vE '^(apps/api/scripts/rls-scratch-check.mjs|docs/mandantentrennung-etappe2-fehlerrichtung.md|apps/api/src/calendar/calendar.service.ts|apps/api/src/calendar/calendar.service.spec.ts|apps/api/src/calendar/calendar.controller.ts|docs/mandantentrennung-zugriffsklassifikation.md|.planning/.*)$' || true) && { test -z "$UNEXPECTED" || { printf 'UNERWARTETE AENDERUNGEN AUSSERHALB DES PLANUMFANGS:\n%s\n' "$UNEXPECTED"; exit 1; }; } calendar.service.spec.ts existiert neu mit dem Zwei-Klienten-Nachweis, Attrappen fuer Verschluesselung und Provider, und deckt jeden in <behavior> genannten Fall ab — einschliesslich der drei Erhaltungsfaelle, der Besitzpruefungen je Ausnahmeart, der beiden Rueckschreibungen der Aggregationsschleife und des Wachhunds. In calendar.service.ts laufen alle zwoelf Zugriffe ueber forTenant() unter dem Namen tenantPrisma, genau ein Klient je Methode mit Datenbankzugriff, das Cache-Schluessel-Urteil steht als Kommentar an der Stelle. calendar.controller.ts reicht den bereits aufgeloesten Mandanten in allen sechs kontextnutzenden Handlern durch, ohne neue Vertrauensquelle. Die Bestandsaufnahme-Zeile steht auf gebunden, die maschinelle Bestandspruefung ist gruen. Der Falsifizierungsnachweis ist durchgefuehrt, zurueckgenommen und mit Testname und Fehlermeldung notiert. Baseline gehalten.

Aufgabe 3: Die uebrigen vier handgepflegten Dokumentstellen nachziehen, den Ledger-Eintrag anlegen und die Gates falsifizieren docs/mandantentrennung-zugriffsklassifikation.md, .planning/WINDOWS.md docs/mandantentrennung-zugriffsklassifikation.md (vollstaendig: Uebersichtstabelle samt Messanweisung, Klassen-Verteilung, Hintergrunddienst-Abschnitt, Abschnitt `Was diese Etappe NICHT entscheidet`), .planning/WINDOWS.md (Kopfzeilen, Eintrag 23 und 25 in Tabelle und JSON), docs/mandantentrennung-etappe2-fehlerrichtung.md (Abschnitt `## Bereich calendar`), apps/api/src/calendar/calendar.service.ts (Endstand aus Aufgabe 2) Ziehe `docs/mandantentrennung-zugriffsklassifikation.md` an den vier noch offenen handgepflegten Stellen nach — jede einzeln nachgesehen, keine ueberflogen (Fehler 4 dieses Vorhabens):
  1. Die Uebersichtszeile calendar mit den NEU GEMESSENEN Zahlen aus der im Dokument genannten Messanweisung (beide Spalten), im etablierten Stil mit dem Vermerk des vorherigen Standes (**war 12/0**) und der Aufzaehlung der sechs umgestellten Methoden. Anders als bei den sieben Bereichen davor gibt es hier keinen verbleibenden ungebundenen Treffer zu begruenden — schreibe das ausdruecklich hin, mit dem Grund (Pflicht-Mandantenkennung, kein uebergreifender Pfad).
  2. Die Summenzeile derselben Tabelle, mit fortgeschriebener Herkunftsspur im Hinweisfeld.
  3. Die Klassen-Verteilung samt der Zahl in ihrer Ueberschrift. Aendert sich nichts, schreibe in einem **Stand 260911-cwh**-Absatz ausdruecklich hin, dass sich nichts aendert und warum (das eine Paar war bereits richtig klassifiziert, nur der Stand wechselte in Aufgabe 2) — eine unveraenderte Tabelle ohne Vermerk ist von einer vergessenen nicht zu unterscheiden.
  4. Den Abschnitt zur Hintergrunddienst-Falle: ergaenze einen PLAIN-Absatz (KEINEN Aufzaehlungspunkt in der Form der bestehenden Faelle und KEINE Zeile der Form "Der ... Fall, anderer Bauart", sonst waere die Zahl in der Ueberschrift falsch), der festhaelt, dass dieser Bereich keinen sechsten Fall hinzufuegt, mit der Anweisung aus Aufgabe 1 — UND der den Sonderfall refreshCacheInBackground benennt: eine abgekoppelte Fortsetzung einer Anfrage, die den Mandanten der Anfrage mitnimmt, nicht die Bauform "uebergreifend lesen, dann je Mandant binden". Die Abwesenheit steht da, damit sie nicht wie ein Uebersehen aussieht.

Ergaenze den Abschnitt Was diese Etappe NICHT entscheidet um die Entscheidung dieses Bereichs zur offenen Architekturfrage — er bindet dienst-intern, ein Klient je Methode, wie alle acht Bereiche vor ihm.

Lege in .planning/WINDOWS.md einen neuen OFFENEN Eintrag an, und zwar ueber gsd-tools windows append --kind deviation --phase quick-260911-cwh --file apps/web/src/components/dashboard/widgets/calendar-widget.tsx --description "...", damit Tabelle, JSON-Block und die Zaehler im Dateikopf zusammenpassen — nicht von Hand. Inhalt: die lautlose Auspraegung der umgekehrten Fehlerrichtung im Bereich calendar aus (k3): zu kleines Leseergebnis auf getSources/fetchAndCacheEvents sieht aus wie "keine Quelle eingerichtet" beziehungsweise "keine Termine"; das Frontend (calendar-widget.tsx, calendar-settings-panel.tsx, mit Stellen) verschluckt zusaetzlich LAUTE Fehler derselben Pfade in denselben leeren Zustand; der Nutzer legt seine Quelle neu an und tippt seine Exchange-/CalDAV-Zugangsdaten ein zweites Mal in ein scheinbar defektes System ein, die urspruengliche Zeile bleibt unsichtbar liegen und wird nach Behebung zur Dublette; die konkrete Vorabpruefung fuer Etappe 4 aus (k4)(e); an dieselbe Bedingung gebunden wie #18; das Frontend wird von 260911-cwh NICHT geaendert. Verweise auf #23 und #25 als Familie. Pruefe nach dem Anlegen, dass der Eintrag in Tabelle UND JSON-Block steht und die Kopfzaehler stimmen.

Fuehre am Ende zwei Falsifizierungsnachweise durch, jeder zurueckgenommen und mit Meldung woertlich notiert: (a) setze die Bestandsaufnahme-Zeile dieses Bereichs probeweise auf einen falschen Stand — die maschinelle Bestandspruefung muss rot werden; (b) setze die Uebersichtszeile probeweise auf eine falsche Zahl — das herleitende Gate dieser Aufgabe muss fehlschlagen.

Aendere in dieser Aufgabe KEINE Datei unter apps/. 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 && npm --prefix apps/api run test -- src/prisma/rls-access-inventory.spec.ts && DU=$(grep -ro "this.prisma.[a-zA-Z]" apps/api/src/calendar | grep -v spec | wc -l | tr -d ' ') && DB=$(grep -ro "tenantPrisma.[a-zA-Z]." apps/api/src/calendar | grep -v spec | wc -l | tr -d ' ') && { test "$DU" -eq 0 || { echo "UEBERSICHTSZEILE: ungebundene Rohtreffer in apps/api/src/calendar sind $DU, erwartet 0"; exit 1; }; } && { test "$DB" -ge 12 || { echo "UEBERSICHTSZEILE: gebundene Rohtreffer in apps/api/src/calendar sind $DB, erwartet mindestens 12"; exit 1; }; } && { grep -qE "^| calendar | ${DU} | ${DB} | **war 12/0**" docs/mandantentrennung-zugriffsklassifikation.md || { echo "UEBERSICHTSZEILE calendar nennt nicht die neu gemessenen Zahlen ${DU}/${DB} im etablierten Stil"; exit 1; }; } && awk -F'|' '$2 ~ /^ [a-z][a-z-] *$/ && $3 ~ /^ *[0-9]+ *$/ && $4 ~ /^ *[0-9]+ *$/ { su+=$3; sb+=$4; n++ } $2 ~ /^ ***Summe** *$/ && $4 !~ /^ *$/ { g3=$3; g4=$4; gsub(/[^0-9]/,"",g3); gsub(/[^0-9]/,"",g4); ru=g3+0; rb=g4+0; seen=1 } END { if (!seen || n+0 != 12) { print "UEBERSICHTSTABELLE nicht erkannt, Bereichszeilen: " n; exit 1 } if (su+0 != ru || sb+0 != rb) { print "SUMMENZEILE stimmt nicht: Bereichszeilen ergeben " su "/" sb ", Summenzeile nennt " ru "/" rb; exit 1 } }' docs/mandantentrennung-zugriffsklassifikation.md && awk -F'|' '$2 ~ /^ *apps/api/src// { k=$4; gsub(/^ +| +$/,"",k); cls[k]++; pairs++ } $2 ~ /^ *(muss-mandantengebunden|keine-mandantengebundene-tabelle|beides|bewusst-uebergreifend) *$/ && $3 ~ /^ *[0-9]+ *$/ && $4 ~ /^ *$/ { k=$2; gsub(/^ +| +$/,"",k); v=$3; gsub(/[^0-9]/,"",v); tab[k]=v+0; tn++ } $2 ~ /^ ***Summe** *$/ && $4 ~ /^ *$/ { v=$3; gsub(/[^0-9]/,"",v); tsum=v+0; tseen=1 } /^## Klassen-Verteilung/ { h=$0; gsub(/[^0-9]/,"",h); hp=h+0; hseen=1 } END { if (tn+0 != 4 || !tseen || !hseen) { print "KLASSEN-VERTEILUNG nicht erkannt: Klassenzeilen " tn ", Summenzeile " tseen ", Ueberschrift " hseen; exit 1 } if (tsum != pairs+0) { print "KLASSEN-SUMME stimmt nicht: Bestandsaufnahme hat " pairs " Paare, Tabellensumme nennt " tsum; exit 1 } if (hp != pairs+0) { print "UEBERSCHRIFT der Klassen-Verteilung nennt " hp " Paare, Bestandsaufnahme hat " pairs; exit 1 } s=0; for (k in tab) { if (tab[k] != cls[k]+0) { print "KLASSE " k ": Tabelle nennt " tab[k] ", Bestandsaufnahme zaehlt " cls[k]+0; exit 1 } s+=tab[k] } if (s != pairs+0) { print "KLASSENZEILEN ergeben " s ", Bestandsaufnahme hat " pairs; exit 1 } }' docs/mandantentrennung-zugriffsklassifikation.md && awk '/^## Klassen-Verteilung/{f=1; next} /^## /{f=0} f && /Stand 260911-cwh/{m=1} END{ if(!m){print "KLASSEN-VERTEILUNG: kein Stand-Vermerk fuer 260911-cwh — eine unveraenderte Tabelle ohne Vermerk ist von einer vergessenen nicht zu unterscheiden"; exit 1} }' docs/mandantentrennung-zugriffsklassifikation.md && awk 'BEGIN{split("ein zwei drei vier x sechs sieben acht neun",w," "); w[5]="fünf"} /^## Der Hintergrunddienst als Falle/{seen=1; head=$0; f=1; next} /^## /{f=0} f && /^- **/{n++} f && /^\*\*Der .* Fall, anderer Bauart/{n++} f && /calendar/{m=1} f && /refreshCacheInBackground/{r=1} END{ if(!seen){print "ABSCHNITT Hintergrunddienst nicht gefunden"; exit 1} want="## Der Hintergrunddienst als Falle — " w[n] " Fälle"; if(head != want){printf "HINTERGRUNDDIENST-UEBERSCHRIFT nennt \"%s\", gezaehlt wurden %d Faelle, erwartet \"%s\"\n", head, n, want; exit 1} if(!m){print "ABSCHNITT Hintergrunddienst nennt diesen Bereich nicht — die Abwesenheit eines sechsten Falls ist nicht belegt"; exit 1} if(!r){print "ABSCHNITT Hintergrunddienst nennt den Sonderfall refreshCacheInBackground nicht"; exit 1} }' docs/mandantentrennung-zugriffsklassifikation.md && awk '/^## Was diese Etappe NICHT entscheidet/{f=1; next} f && /calendar/{m=1} END{ if(!m){print "ABSCHNITT \"Was diese Etappe NICHT entscheidet\" nennt den Bereich calendar nicht"; exit 1} }' docs/mandantentrennung-zugriffsklassifikation.md && python3 -c " import re,sys s=open('.planning/WINDOWS.md',encoding='utf-8').read() rows=[l for l in s.splitlines() if re.match(r'^\| \d+ \|', l)] ids=re.findall(r'\"id\": (\d+),', s) if len(rows)!=len(ids): print('WINDOWS: %d Tabellenzeilen, %d JSON-Eintraege' % (len(rows), len(ids))); sys.exit(1) mine=[l for l in rows if 'quick-260911-cwh' in l] if len(mine)!=1: print('WINDOWS: erwartet genau einen Tabelleneintrag fuer quick-260911-cwh, gefunden %d' % len(mine)); sys.exit(1) mid=re.match(r'^\| (\d+) \|', mine[0]).group(1) if mid not in ids: print('WINDOWS: Eintrag %s fehlt im JSON-Block' % mid); sys.exit(1) if '| open |' not in mine[0]: print('WINDOWS: Eintrag %s ist nicht offen' % mid); sys.exit(1) if 'calendar' not in mine[0] or 'calendar-widget' not in mine[0]: print('WINDOWS: Eintrag %s nennt Bereich oder Widget-Datei nicht' % mid); sys.exit(1) tc=int(re.search(r'^total_count: (\d+)', s, re.M).group(1)); oc=int(re.search(r'^open_count: (\d+)', s, re.M).group(1)) if tc!=len(rows): print('WINDOWS: total_count %d, Tabellenzeilen %d' % (tc,len(rows))); sys.exit(1) op=len([l for l in rows if '| open |' in l]) if oc!=op: print('WINDOWS: open_count %d, offene Zeilen %d' % (oc,op)); sys.exit(1) " && test 0 -eq "$(grep -rn '\$transaction(' apps/api/src/calendar --include=*.ts | grep -v spec | wc -l | tr -d ' ')" && git rev-parse --verify 50b3a36 >/dev/null && PRISMA_CHANGED=$(git diff --name-only 50b3a36 -- apps/api/prisma) && { test -z "$PRISMA_CHANGED" || { printf 'SCHEMA/MIGRATION GEAENDERT — in diesem Plan verboten:\n%s\n' "$PRISMA_CHANGED"; exit 1; }; } && CHANGED=$(git diff --name-only 50b3a36) && UNEXPECTED=$(printf '%s\n' "$CHANGED" | grep -v '^$' | grep -vE '^(apps/api/scripts/rls-scratch-check\.mjs|docs/mandantentrennung-etappe2-fehlerrichtung\.md|docs/mandantentrennung-zugriffsklassifikation\.md|apps/api/src/calendar/calendar\.service\.ts|apps/api/src/calendar/calendar\.service\.spec\.ts|apps/api/src/calendar/calendar\.controller\.ts|\.planning/.*)$' || true) && { test -z "$UNEXPECTED" || { printf 'UNERWARTETE AENDERUNGEN AUSSERHALB DES PLANUMFANGS:\n%s\n' "$UNEXPECTED"; exit 1; }; }</automated> </verify> <done>Alle fuenf handgepflegten Stellen von docs/mandantentrennung-zugriffsklassifikation.mdsind nachgezogen und maschinell gegatet: Bestandsaufnahme-Zeile (Aufgabe 2), Uebersichtszeile mit neu gemessenen Zahlen, Summenzeile, Klassen-Verteilung mit ausdruecklichem Unveraendert-Vermerk, Hintergrunddienst-Abschnitt mit Messbeleg fuer die Abwesenheit eines sechsten Falls und dem Sonderfall der abgekoppelten Auffrischung; dazu der AbschnittWas diese Etappe NICHT entscheidet. .planning/WINDOWS.md` traegt den neuen offenen Eintrag ueber das Werkzeug in Tabelle, JSON-Block und Kopfzaehlern. Beide Falsifizierungsnachweise sind durchgefuehrt, zurueckgenommen und woertlich notiert. Baseline gehalten, Schalter unveraendert aus.

<threat_model>

Konfiguriert: ASVS-Stufe 1, blockierend ab high.

Trust Boundaries

Boundary Description
Browser/Benutzer → Kalender-API Benutzer- und Mandantenkennung stammen ausschliesslich aus dem validierten Sitzungsnachweis (extractContext liest req.user.id und req.tenantId/req.user.tenantId, bricht ohne beide ab). Die Quellenkennung im Pfad ist frei waehlbare Nutzereingabe.
Nutzer A → Kalenderquellen (samt Zugangsdaten) von Nutzer B DESSELBEN Mandanten Die Grenze, die die Datenbank NACHWEISLICH nicht zieht — die Regel kennt nur die Mandantendimension. Gezogen allein vom userId-Filter in den Listenpfaden und den drei Besitzpruefungen.
Mandant A → Zeilen des Mandanten B Die Grenze dieses Plans. Heute nur von der Anwendung gezogen, nach diesem Plan zusaetzlich von der Datenbank — wirksam erst nach Etappe 4.
API → PostgreSQL Die Zeilenschutz-Grenze. Heute wirkungslos (Rolle mit BYPASSRLS, WINDOWS #18) — dieser Plan bereitet sie vor, schaltet sie NICHT scharf.
API → externe Kalenderserver (Exchange/CalDAV/ICS) Die Grenze, ueber die entschluesselte Zugangsdaten gehen. Dieser Plan aendert daran nichts; er sorgt dafuer, dass nur die Zugangsdaten der eigenen Quelle dorthin gehen.
Anfrage → abgekoppelte Cache-Auffrischung Ein Anfragekontext, der die Anfrage ueberlebt: die Auffrischung traegt Benutzer- und Mandantenkennung der Anfrage, die sie angestossen hat, und kann keinen anderen Mandanten haben.

STRIDE Threat Register

Threat ID Category Component Severity Disposition Mitigation Plan
T-CWH-01 Information Disclosure calendar.service.ts, getSources/fetchAndCacheEvents/testConnection/updateSource (Lesepfade, die encryptedPassword laden) high mitigate Quer-Lesen der Kalenderquellen eines fremden Mandanten EINSCHLIESSLICH der verschluesselten Exchange-/CalDAV-Zugangsdaten — die Klasse, in der der Bereich dkv Postfach-Zugangsdaten behandelt hat. Alle Lesepfade werden gebunden; SOURCE_SAFE_SELECT bleibt, encryptedPassword verlaesst die API weiterhin nie. Gemessen in Aufgabe 1: calendarsource-gebunden-nur-eigener-mandant, calendarsource-generierter-client-gebundene-quellenliste-nur-eigener-mandant.
T-CWH-02 Information Disclosure Regel auf CalendarSource, keine Benutzerdimension high accept Quer-Lesen der Zugangsdaten eines Kollegen DESSELBEN Mandanten auf Datenbankebene. Gemessen in Aufgabe 1 (calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar, das Gelingen IST das Ergebnis, Belegausgabe nennt encryptedPassword). Bewusst akzeptiert und aufgezeichnet: der Schutz bleibt vollstaendig beim userId-Filter und den drei Besitzpruefungen, die dieser Plan NICHT entfernt und als Testfaelle festnagelt; die Benutzerdimension in der Regel ist die Etappe-3-Entscheidung (2) des Users vom 2026-09-10, CalendarSource steht dort ausdruecklich in der Liste. Bis dahin ist derselbe Zustand wie heute — dieser Plan verschlechtert ihn nicht.
T-CWH-03 Tampering calendar.service.ts, updateSource/deleteSource/testConnection high mitigate Quer-Aendern, Quer-Loeschen oder fremder Verbindungstest ueber die Kennung im Pfad — die Bauform, die bei ldap und dkv je eine Luecke riss. Hier existiert die Besitzpruefung in allen drei Pfaden (Befund D, zur Ausfuehrungszeit erneut zu lesen), sie wird durch die Bindung ERGAENZT, und alle Abfragen jedes Pfads laufen ueber DENSELBEN gebundenen Klienten. Als Testfaelle je Ausnahmeart festgenagelt; datenbankseitig gemessen mit calendarsource-gebundenes-loeschen-fremder-zeile-trifft-keine-zeile und calendarsource-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen.
T-CWH-04 Denial of Service calendar.service.ts getSources/fetchAndCacheEvents, plus calendar-widget.tsx/calendar-settings-panel.tsx high mitigate Die umgekehrte Fehlerrichtung: ein zu kleines Leseergebnis liefert einen leeren Kalender und eine leere Quellenliste, kein Fehlerbild — gelesen als "Synchronisation kaputt" oder "keine Quelle eingerichtet". Das Frontend verschluckt zusaetzlich LAUTE Fehler derselben Pfade in denselben leeren Zustand. Der Nutzer legt neu an und tippt Zugangsdaten in ein scheinbar defektes System. Vollstaendige Bindung aller Lesepfade; Signaltabelle mit Spalte "laesst das Frontend das Signal durch"; namentliche Liste in (k3); offener Ledger-Eintrag mit konkreter Vorabpruefung fuer Etappe 4. Das Frontend wird NICHT geaendert — beschrieben, nicht unterbrochen.
T-CWH-05 Tampering calendar.service.ts fetchAndCacheEvents, Synchronstatus-Rueckschreibungen high mitigate Die halb gebundene Schleife (Befund K): Laden gebunden, Rueckschreiben nicht (oder umgekehrt) — nach dem Scharfschalten scheitert das Rueckschreiben, der catch scheitert erneut, Promise.allSettled laesst die Ereignisse dieser Quelle STILL fallen, lastSyncError wird nie gesetzt. Ein Klient je Methode; beide Rueckschreibungen als Testfaelle festgenagelt; der Falsifizierungsnachweis von Aufgabe 2 nimmt genau eine dieser Bindungen zurueck. Fehlerklasse des Wettlauf-Falls gemessen (...-gebundenes-update-auf-unsichtbare-zeile-scheitert-laut).
T-CWH-06 Tampering calendar.service.ts updateSource, Zugangsdaten-Erhaltung medium mitigate Zugangsdatenverlust ueber einen Erhaltungspfad, der nach dem Scharfschalten leer laeuft (die dkv-Form: lesen, entschluesseln, neu verschluesseln). Befund E: die Form existiert hier NICHT — Erhaltung per Weglassen des Felds, das Web-Formular laesst ein leeres Feld weg, kein Lesezugriff kann leer laufen. Zur Ausfuehrungszeit an allen vier Stellen nachgelesen (Aufgabe 1), als drei Testfaelle festgenagelt (Aufgabe 2), damit eine spaeter eingebaute Erhaltungsform sofort rot wird. Faellt der Befund anders aus, wird die dkv-Reparatur angewandt.
T-CWH-07 Information Disclosure calendar.service.ts eventCache, Schluessel ohne Mandantenanteil medium mitigate Quer-Lesen von Ereignissen ueber einen Cache-Treffer, falls Benutzerkennungen zwischen Mandanten kollidieren koennten. Befund F: der Schluessel traegt User.id (@default(uuid())), nicht den Anmeldenamen; die Etappe-3-Entscheidung (1) betrifft username/email, nicht id. Vierteilige Kette in Aufgabe 1 Glied fuer Glied nachgesehen; Urteil als Kommentar an der Stelle und in (k4); Testfall, dass zwei Benutzerkennungen keinen Eintrag teilen. Lautet das Urteil anders, bekommt der Schluessel einen Mandantenanteil.
T-CWH-08 Elevation of Privilege calendar.controller.ts, Durchreichen des Mandanten high mitigate Die Mandantenkennung koennte beim Umbau versehentlich aus Rumpf oder Pfad statt aus dem Sitzungsnachweis genommen werden. Alle sechs kontextnutzenden Handler nehmen sie unveraendert aus extractContext, das ohne Mandant mit ForbiddenException abbricht — keine neue Vertrauensquelle. Gate: sechs Handler mit Benutzer- und Mandantenkennung, keiner mit Benutzerkennung allein; Erlaubnisliste des Umfangs.
T-CWH-09 Information Disclosure Besitzpruefung, 403 statt 404 low accept Ein Kollege desselben Mandanten erfaehrt ueber 403 die Existenz einer fremden Quellenkennung. Kennungen sind UUIDs; ein fremder Mandant bekommt nach der Bindung 404. Antwortsemantik wird NICHT geaendert (API-Aenderung ausserhalb des Auftrags); festgehalten in (k4)(d).
T-CWH-10 Information Disclosure validateUrlNotPrivate, SSRF-Ausnahme fuer Exchange low accept Bestehender Zustand aus Phase 05/T-05-11 (Exchange-Server liegen im Intranet, Pruefung fuer diesen Typ uebersprungen). Dieser Plan aendert daran nichts und schwaecht es nicht.
T-CWH-11 Spoofing Sitzungsnachweis low accept Mandanten- oder Benutzerkennung aus Rumpf oder Pfad. Bereits in Phase 05 behandelt (T-05-12); dieser Plan aendert daran nichts.
T-CWH-SC Tampering Paketinstallation low accept Dieser Plan installiert kein Paket (npm/pip/cargo) und fuegt keine Abhaengigkeit hinzu. Das Legitimitaets-Gate faellt nicht an; ausdruecklich festgehalten statt schweigend ausgelassen.

</threat_model>

Nach Abschluss aller drei Aufgaben:

  1. node apps/api/scripts/rls-scratch-check.mjs meldet alle Pruefungen bestanden (88 bisherige plus die neuen), Rueckgabewert 0.
  2. npm --prefix apps/api run test meldet mindestens 860 Tests gruen in mindestens 57 Dateien (56 bisherige plus die neue Testdatei dieses Bereichs).
  3. npm --prefix apps/api run type-check ist sauber.
  4. npm --prefix apps/api run test -- src/prisma/rls-access-inventory.spec.ts ist gruen — die Bestandsaufnahme stimmt mit dem Quelltext ueberein.
  5. Alle fuenf handgepflegten Stellen der Klassifikation sind maschinell gegatet und gruen, und die Zaehlgates LEITEN ihre Werte aus den im Dokument selbst genannten Messanweisungen ab.
  6. Der Umfang ist als ERLAUBNISLISTE gegatet: jede Datei, die sich gegenueber 50b3a36 geaendert hat, ist eine der sieben in files_modified genannten (oder liegt unter .planning/). Unter apps/api/prisma und apps/web hat sich nichts geaendert. Beide Pruefungen laufen gegen den Ausgangsstand, nicht gegen HEAD.
  7. DATABASE_URL zeigt unveraendert auf die Rolle tessera; keine Compose- oder Umgebungsdatei ist angefasst; nichts in Active Directory.
  8. Der Ledger-Eintrag steht in Tabelle, JSON-Block und Kopfzaehlern von .planning/WINDOWS.md.

<success_criteria>

  • Die zwoelf Zugriffe des Bereichs sind vollstaendig gebunden, genau ein Klient je Methode mit Datenbankzugriff, keiner unentschieden, keiner begruendet ungebunden — und die Abwesenheit eines ungebundenen Rests ist in der Uebersichtstabelle ausdruecklich begruendet.
  • Keine Zeile dieses Bereichs wird halb gebunden: Nachschlagen und Schreiben jeder Besitzpruefung und Laden und Rueckschreiben der Aggregationsschleife laufen ueber denselben Klienten — festgenagelt, falsifiziert.
  • Die umgekehrte Fehlerrichtung ist an der echten Regel in ihrem AKTUELLEN Stand gemessen, mindestens vier Pruefungen laufen ueber den generierten Client an einer schemagleichen Wegwerf-Tabelle, und die Fehlerverschluckung des Frontends ist als eigener Punkt benannt.
  • Das Urteil zum Cache-Schluessel ist vierteilig belegt und steht in Code und Kritikschrift; der Befund zur Zugangsdaten-Erhaltung ist an vier Stellen nachgelesen und als drei Testfaelle festgenagelt.
  • Die drei Besitzpruefungen sind gelesen, bestaetigt, ergaenzt und als Testfaelle festgenagelt.
  • Alle drei Falsifizierungsnachweise sind durchgefuehrt, zurueckgenommen und im SUMMARY mit Testname beziehungsweise Meldung festgehalten.
  • Jede im SUMMARY genannte Zahl (neue Werkzeugpruefungen, neue Testfaelle, umgestellte Zugriffe) ist ABGEZAEHLT, nicht aus diesem Plan abgeschrieben.
  • Baseline gehalten am Ende jeder Aufgabe. Der Schalter ist weiterhin AUS.

</success_criteria>

Create `.planning/quick/260911-cwh-mandantentrennung-etappe-2-bereich-calen/260911-cwh-SUMMARY.md` when done