Files
tessera-ctl/.planning/quick/260909-mir-mandantentrennung-etappe-2-bereich-dkv-a/260909-mir-PLAN.md
T

62 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-260909-mir 01 execute 1
true
WINDOWS-20
ETAPPE-2-DKV
apps/api/scripts/rls-scratch-check.mjs
docs/mandantentrennung-etappe2-fehlerrichtung.md
apps/api/src/dkv/dkv.service.ts
apps/api/src/dkv/dkv.service.spec.ts
apps/api/src/dkv/dkv-scheduler.service.ts
docs/mandantentrennung-zugriffsklassifikation.md
.planning/WINDOWS.md
tokens raw_tokens tasks confidence
170000 170000 3 low
truths artifacts key_links
Jeder Zugriff des Bereichs `dkv`, der auf Rechnung genau eines Mandanten eine der drei DKV-Tabellen beruehrt, laeuft ueber einen gebundenen Client — Postfach-/Modulkonfiguration, Rechnungshistorie und Fahrzeugstammdaten vollstaendig.
Der eine bewusst uebergreifende Zugriff des Bereichs — der Planer-Startpfad, der heute eine BELIEBIGE Konfigurationszeile zieht — ist eine eigene, benannte Methode mit eigenem Kopfkommentar, nicht ein Zweig hinter einem optionalen Parameter. Er bleibt ungebunden, weil ihn zu binden ihn garantiert leer laufen liesse.
Die Entscheidung zum Planer ist ausgeschrieben, nicht stillschweigend getroffen: Weiterfuehrung als benannte Altlast mit Markierung im Code, im Fehlerrichtungs-Dokument und im Broken-Windows-Register — ausdruecklich NICHT der Umbau auf einmal-abfragen-viele-bedienen, weil das die in 07-04 zurueckgestellte Mehrmandanten-Planung ist und damit eine Funktionsaenderung, kein Bindungsumbau.
Die Fehlerrichtung dieses Bereichs ist GEMESSEN, nicht behauptet: dass eine ungebundene Einzelabfrage auf die Modulkonfiguration nach dem Scharfschalten keine beliebige Zeile mehr liefert, sondern gar keine, haengt an der echten, ausgelieferten Policy und steht als Ausgabezeile im Werkzeug.
Die Luecke der ldap-Klasse dieses Bereichs ist gefunden und geschlossen: der Download einer Ausfuhrdatei loest heute allein ueber den Dateinamen auf und wirft den uebergebenen Mandanten weg — kuenftig entscheidet ein gebundener Lesezugriff auf die Rechnungshistorie, ob die Datei zu diesem Mandanten gehoert.
Es existiert ein `dkv`-Abschnitt der Kritikschrift, der je umgestelltem Pfad das konkrete Signal nennt UND die diesem Bereich eigene Fehlerform abdeckt: eine Abfrage, die heute eine beliebige-aber-richtige Zeile liefert und kuenftig `null`, wobei `null` an dieser Stelle als 'das Modul ist nicht eingerichtet' gelesen wird — ein Zustand, den die Oberflaeche als ganz normales leeres Formular zeigt.
Die Testlage dieses Bereichs ist repariert: vor diesem Durchlauf gab es fuer `dkv` KEINE einzige Testdatei, der Bereich konnte also auf keinen Fehler rot werden. Es existiert jetzt eine Testdatei mit Zwei-Klienten-Nachweis, die rot wird, sobald eine Fundstelle ungebunden bleibt — nachgewiesen ueber einen probeweisen Rueckbau, nicht behauptet.
Dass dieser Bereich keine mandantengebundene Transaktion enthaelt, ist nachgemessen und damit der im Kopf von `prisma-tenant.extension.ts` verlangte erneute Test fuer diesen Bereich beantwortet; die eine mehrschrittige Stelle (Ersetzen-Modus des Fahrzeug-Imports) bleibt so unatomar wie heute, statt unter dem Deckmantel der Umstellung atomar gemacht zu werden.
Klassifikationsdokument und `rls-access-inventory.spec.ts` zeigen fuer alle drei Paare des Bereichs denselben, maschinell gemessenen Stand.
772+ Tests und die Typpruefung sind gruen, das Wegwerf-Werkzeug meldet alle Pruefungen bestanden; Schema, Migrationen, alle Compose-Dateien und beide Beispiel-Umgebungsdateien sind unveraendert; der Schalter bleibt AUS.
apps/api/scripts/rls-scratch-check.mjs
docs/mandantentrennung-etappe2-fehlerrichtung.md
apps/api/src/dkv/dkv.service.ts
apps/api/src/dkv/dkv.service.spec.ts
apps/api/src/dkv/dkv-scheduler.service.ts
docs/mandantentrennung-zugriffsklassifikation.md
.planning/WINDOWS.md
gebundener Klient <-> die drei Policies `tenant_isolation_policy` auf DkvInvoiceHistory/DkvModuleConfig/DkvVehicleMaster, wortgleich aus der ausgelieferten Migration `20260909140000_rls_remaining_tenant_tables` herausgeschnitten statt im Werkzeug nachgetippt
der Planer-Startpfad <-> die eine Abfrage ohne Mandantenbedingung, deren Ergebnis heute beliebig und kuenftig leer ist — die Stelle, an der ein Bindungsumbau zur Funktionsaenderung wuerde, wenn man sie 'mitrepariert'
Dateiname der Ausfuhrdatei <-> `DkvInvoiceHistory.exportFilename` — die einzige mandantengebundene Aussage darueber, wem eine Datei im gemeinsamen Ablageverzeichnis gehoert
verschluesselte Postfachzugangsdaten in DkvModuleConfig <-> der gebundene Lesezugriff der Verarbeitungsstrecke — der Pfad, der nach dem Scharfschalten aus 'Postfach nicht erreichbar' ein stilles 'kein Postfach eingerichtet' machen wuerde
zusammengesetzte Eindeutigkeit (tenantId, kennzeichen) <-> gebundenes `upsert` im Fahrzeug-Import — die Stelle, an der dieser Bereich NICHT die Falle des Bereichs `tenders` hat, was zu messen und nicht zu unterstellen ist
`rls-access-inventory.spec.ts` <-> Stand-Spalte des Klassifikationsdokuments fuer alle drei Paare des Bereichs
Der Bereich `dkv` ist der vierte Bereich der Etappe 2 — und der erste, dessen Hauptschwierigkeit nicht der Umbau ist, sondern eine Entscheidung. 21 Zugriffe auf drei Tabellen, alle in einer einzigen Datei, alle mandantengebunden zu machen: das ist die kleinere Haelfte. Die groessere ist eine bereits dokumentierte Altlast aus 07-04 — der Planer dieses Moduls zieht seine Konfiguration ueber eine Abfrage OHNE Mandantenbedingung und bekommt damit eine BELIEBIGE Zeile. Bei einem Mandanten ist die beliebige Zeile immer die richtige, weshalb es nie jemandem auffiel.

Zweck: Dieser Bereich haelt die Tankkartenabrechnung eines Unternehmens — welche Fahrzeuge es faehrt, wer sie faehrt, was es tankt und was es dafuer zahlt — und die Zugangsdaten zu dem Postfach, in dem diese Rechnungen ankommen. Ein Quer-Lesen ist die Offenlegung von Fuhrpark und Ausgaben eines fremden Unternehmens.

Die diesem Bereich eigene Fehlerform ist eine andere als in allen drei Bereichen davor: eine ungebundene Einzelabfrage liefert nach dem Scharfschalten nicht "zu wenige Zeilen", sondern null — und null heisst an dieser Stelle im Code nicht "Fehler", sondern "dieses Modul ist nicht eingerichtet". Ein eingerichtetes Modul saehe danach aus wie ein nie eingerichtetes: ein leeres Formular, eine unauffaellige Protokollzeile, kein Alarm.

Ergebnis: Die Kritikschrift bekommt einen dkv-Abschnitt samt dieser Fehlerform. Das Messwerkzeug bekommt die drei Policies dieses Bereichs und drei Messungen, die es bisher nirgends gab. Die drei Tabellen sind gebunden, der eine bewusst uebergreifende Pfad ist benannt und markiert statt still gelassen, die Luecke der ldap-Klasse (Ausfuhrdatei ueber den Dateinamen allein) ist geschlossen — und der Bereich hat zum ersten Mal ueberhaupt Tests.

<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/src/prisma/prisma-tenant.extension.ts @apps/api/src/prisma/rls-access-inventory.spec.ts @apps/api/scripts/rls-scratch-check.mjs @apps/api/src/groups/groups.service.spec.ts @apps/api/src/tenders/tender-saved-search.service.ts @apps/api/prisma/migrations/20260909140000_rls_remaining_tenant_tables/migration.sql @apps/api/src/dkv/dkv.service.ts @apps/api/src/dkv/dkv-scheduler.service.ts @apps/api/src/dkv/dkv.controller.ts @apps/api/src/dkv/dkv-export.service.ts @CLAUDE.md

<planning_time_findings>

Alles Folgende wurde am 2026-09-09 zur Planungszeit am lebenden Baum gemessen. Die Zahlen und Zeilenangaben aus dem Auftrag waren Hinweise zum Aufschlagen, keine Aenderungsvollmacht — jede Fundstelle wurde einzeln aufgeschlagen. Auch die Zahlen in diesem Abschnitt sind Planungsstand: bei der Ausfuehrung neu messen, nicht abschreiben.

Ausgangsstand (jetzt gemessen, nicht aus einem Bericht zitiert):

  • npm --prefix apps/api run test -> 53 Dateien, 772 Tests, gruen, 5,19 s, Rueckgabewert 0.
  • git rev-parse --short HEAD -> 6464ccb, Arbeitsbaum sauber. Deckt sich mit dem Auftrag.
  • apps/api/package.json fuehrt test (vitest run) und type-check (tsc --noEmit) — die beiden Befehle, auf denen jede Pruefung dieses Plans aufsetzt.
  • Die Adresse von tessera-ctl-db-1 ist bei der Ausfuehrung neu zu ermitteln; eine Container-Adresse ist veraenderlich und darf nicht aus einem Plan abgeschrieben werden.

Befund A — die 21 sind echt, und sie liegen alle in EINER Datei. grep -ro "this\.prisma\.[a-zA-Z]*" apps/api/src/dkv | grep -v spec liefert 21 Treffer, aufgeteilt in 10 auf dkvVehicleMaster, 7 auf dkvModuleConfig, 4 auf dkvInvoiceHistory — alle in apps/api/src/dkv/dkv.service.ts, kein einziger Treffer ist ein Nicht-Modellzugriff. Dieses eine Mal schrumpft die Ueberschriftszahl bei der Nachschau NICHT; sie ist der tatsaechliche Arbeitsvorrat. Das ist eine Feststellung, keine Erlaubnis, beim naechsten Bereich wieder zu schaetzen.

Befund B — die uebrigen Dateien des Bereichs erreichen die Datenbank nicht, und das ist nachgesehen statt geglaubt. Der Auftrag verlangt ausdruecklich, dem Erkenner nicht zu vertrauen (der Bereich tenders hatte eine blinde Stelle). grep -rn "prisma\.\|PrismaService\|\$transaction\|forTenant\|withTenantTransaction" apps/api/src/dkv --include=*.ts | grep -v '\.spec\.' zeigt: nur dkv.service.ts injiziert PrismaService. dkv-scheduler.service.ts geht ueber DkvService, dkv-mail.service.ts ueber SettingsService, dkv.seed.ts ueber ModuleRegistryService, und dkv-export.service.ts, dkv-parser.service.ts, dkv.controller.ts, dkv.module.ts beruehren die Datenbank ueberhaupt nicht. Die blinde Stelle des tenders-Erkenners war der Transaktionsparameter — hier gibt es keine Transaktion (Befund C), also auch keine solche Stelle. Zwei Uebergaben in noch nicht umgestellte Bereiche folgen daraus und gehoeren in die Kritikschrift, nicht in diesen Umbau: dkv-mail.service.ts haengt an settings.service.ts (dieselbe Reihenfolgebedingung, die der tenders-Durchlauf als Befund K festhielt), dkv.seed.ts an module-registry.

Befund C — dieser Bereich hat KEINE Transaktion. grep -rn '\$transaction(' apps/api/src/dkv --include=*.ts | grep -v spec liefert null Treffer. Der Kopfkommentar von prisma-tenant.extension.ts verlangt woertlich, vor jedem NEUEN mandantengebundenen Fall mit eigener Transaktion erneut zu messen — fuer diesen Bereich faellt kein solcher Fall an, withTenantTransaction() wird hier nicht gebraucht und darf nicht eingefuehrt werden. Zwei Stellen sind mehrschrittig, aber HEUTE schon nicht atomar: der Ersetzen-Modus des Fahrzeug-Imports (deleteMany gefolgt von createMany) und die Zugangsdaten-Erhaltung in saveConfig (lesen, entschluesseln, neu verschluesseln, schreiben). Sie in eine Transaktion zu heben waere eine Verhaltensaenderung jenseits dieses Auftrags — dieselbe Grenze, die der tenders-Durchlauf fuer saveConfig/createForUser zog.

Was dieser Bereich statt einer Transaktion hat, ist eine bisher NICHT gemessene Nebenlaeufigkeitsform: getHistory fuehrt zwei Abfragen ueber Promise.all parallel aus. Nach der Umstellung sind das zwei parallele Einzeloperationen auf EINEM gebundenen Klienten. Die vorhandene Lastprobe misst die interaktiven Formen (ii) und (iii), nicht diese. Sie gehoert deshalb gemessen (Aufgabe 1, Teil 2) — nicht, weil ein Problem vermutet wird, sondern weil sie die Form ist, auf die sich dieser Bereich stuetzt.

Befund D — der Planer, und warum er die eigentliche Aufgabe dieses Plans ist. Die Altlast aus 07-04 ist in vier Stellen aufgeschlagen und bestaetigt:

  • dkv-scheduler.service.ts, onModuleInit(): ruft this.dkvService.loadConfig() OHNE Mandanten auf und uebernimmt config.tenantId als den einen Mandanten, den der eine benannte Cron-Auftrag dkv-inbox-poll fortan bedient.
  • dkv.service.ts, loadConfig(tenantId?): ohne Mandant ein findFirst ganz ohne Bedingung, mit Mandant ein findUnique ueber tenantId. EINE Methode, ZWEI gegensaetzliche Bindungsanforderungen hinter einem optionalen Parameter.
  • Die Auswahl greift auf CONFIG_SAFE_SELECT zu, das encryptedInboxCreds ausschliesst. Der uebergreifende Pfad sieht also KEINE Zugangsdaten — festgehalten als Entlastung, weil man beim Lesen des Auftrags das Gegenteil vermuten koennte.
  • Die Pruefung lautet config?.isActive && config.tenantId. Bei zwei Mandanten, von denen der erste inaktiv ist, registriert der Planer gar nichts — obwohl der zweite aktiv waere. Die Altlast ist damit nicht nur "bedient einen beliebigen", sondern "kann ganz ausfallen".

Nach dem Scharfschalten liefert dieselbe ungebundene Abfrage null. Der Planer protokolliert dann DKV scheduler: no active config found — cron job not registered und beendet die Einrichtung — eine Zeile, die auf einer frischen Installation der Normalfall ist und deshalb niemanden alarmiert. Die zweite stille Stelle liegt in _runPipeline: no config for tenant ... als Warnung, dann return.

Die Entscheidung dieses Plans, ausgeschrieben statt still getroffen. Von den drei im Auftrag genannten Formen:

  • (a) An einen konkret aufgeloesten Mandanten binden — nicht moeglich. onModuleInit() hat keine Anfrage, keinen Sitzungsnachweis und keinen Konfigurationswert, aus dem ein Mandant kaeme. Einen einzufuehren waere eine neue Einstellung, also eine Funktionsaenderung.
  • (b) Umbau auf einmal-abfragen-viele-bedienen — abgelehnt, mit Begruendung. Das ist genau die Mehrmandanten-Planung, die 07-04 zurueckgestellt hat: alle aktiven Konfigurationen lesen, je Mandant einen Auftrag fuehren, deren Lebenszyklus bei jeder Konfigurationsaenderung nachziehen (heute verwaltet setInterval GENAU EINEN Auftrag unter einem festen Namen), und entscheiden, was bei unterschiedlichen Intervallen je Mandant gilt. Das ist eine Funktion, kein Bindungsumbau. Der Auftrag sagt selbst: wenn das die Schlussfolgerung ist, dann klar sagen statt hineinzuschlittern. Sie ist es.
  • (c) Als benannte Altlast weiterfuehren, mit Markierung — gewaehlt. Es gibt dafuer einen unmittelbaren Praezedenzfall: getAllActiveConfigs im Bereich ldap (Befund B/E) ist derselbe Fall — ein bewusst uebergreifender Planer-Lesezugriff, der ungebunden bleibt, einen eigenen Kopfkommentar traegt, und dessen Verstummen nach dem Scharfschalten an die Vorabpruefung von Etappe 4 uebergeben wird.

Die eine Unsymmetrie, die dabei NICHT verschwiegen werden darf und die den Praezedenzfall nicht deckt: getAllActiveConfigs ist heute RICHTIG und verstummt erst spaeter. Der DKV-Planer ist heute schon FALSCH — er bedient bei mehreren Mandanten einen beliebigen und die uebrigen nie — und verstummt zusaetzlich spaeter. Beides muss die Markierung sagen, sonst liest sie sich wie eine Entwarnung. Die gewaehlte Form hat deshalb drei Teile: die uebergreifende Abfrage wird eine EIGENE, benannte Methode (kein Zweig hinter einem optionalen Parameter, den jemand spaeter "mitrepariert"), sie und der Planer tragen einen Kopfkommentar, der beide Zustaende benennt, und die Altlast wird als Eintrag im Broken-Windows-Register gefuehrt.

Befund E — die Luecke der ldap-Klasse dieses Bereichs, und sie ist heute ausnutzbar. DkvService.getExportFile(tenantId, filename) nimmt einen Mandanten entgegen und benutzt ihn nirgends. Die Methode prueft den Dateinamen gegen ein Muster (Wegverzeichnis-Schutz, T-07-09), setzt ihn auf das GEMEINSAME Verzeichnis user-files/ und liest. DkvExportService.writeAndPrune schreibt alle Mandanten in dasselbe Verzeichnis. Ein Administrator eines beliebigen Mandanten kann ueber GET /dkv/exports/:filename die Abrechnungsdatei eines fremden Mandanten herunterladen, sobald er den Namen kennt oder raet — und der Name ist halb vorhersagbar (RG-DKV-{Rechnungsnummer}-{JJMMTT}.xlsx). Das ist dieselbe Klasse wie der ldap-Fund (Aufloesung ueber die Kennung allein), nur ueber einen Dateinamen statt eine Datensatzkennung.

Die Reparatur passt genau in diesen Auftrag, statt daneben zu liegen: die einzige mandantengebundene Aussage darueber, wem eine Datei gehoert, ist DkvInvoiceHistory.exportFilename — und dieser Lesezugriff wird in diesem Plan ohnehin gebunden. Am Frontend nachgesehen statt aus dem Backend geschlossen: InvoiceHistoryTable.tsx und ExportFileList.tsx beziehen JEDEN angebotenen Dateinamen aus den Historienzeilen (h.exportFilename). Ein Riegel ueber die gebundene Historie ist fuer die tatsaechliche Benutzung folgenlos und schliesst genau den Weg, der daran vorbeigeht.

Die Verhaltensaenderung, die dabei entsteht, gehoert benannt statt uebersehen: eine Datei, die auf der Platte liegt, aber zu KEINER Historienzeile gehoert, ist danach nicht mehr herunterladbar. Das ist die Absicht.

Befund F — die zweite, nicht reparierbare Haelfte derselben Beobachtung. writeAndPrune behaelt die letzten zehn Dateien des GEMEINSAMEN Verzeichnisses. Verarbeitet ein Mandant zehn Rechnungen, verdraengt er damit die Dateien aller anderen; deren Historienzeilen nennen dann einen Dateinamen, der nicht mehr existiert. Das ist keine Bindungsfrage — es ist die Ablagestruktur, und sie zu aendern (Unterverzeichnisse je Mandant, Umzug der Bestandsdateien) ist ein eigener Auftrag. Festhalten, nicht hier loesen.

Befund G — die Besitzpruefungen bei Fahrzeugen, mit einer echten Beobachtung. updateVehicle und deleteVehicle lesen zuerst ueber findFirst({ id, tenantId }) — mit Mandantenbedingung, also heute korrekt geschuetzt — und schreiben danach ueber update({ where: { id } }) bzw. delete({ where: { id } }), also ueber die Kennung ALLEIN. Heute deckt die vorgeschaltete Pruefung das ab; es ist keine Luecke wie Befund E. Nach der Umstellung muessen aber BEIDE Anweisungen gebunden sein: eine gebundene Vorpruefung mit einem ungebundenen Schreibzugriff dahinter waere genau der Riss, den der Auftrag als Klasse benennt. Die Mandantenbedingung im findFirst bleibt dabei erhalten — nicht mit dem Argument "macht jetzt die Datenbank" entfernen, dieselbe Regel, die der tenders-Durchlauf fuer die Benutzerfilterung aufstellte.

Befund H — dieser Bereich hat NICHT die Eindeutigkeitsfalle des Bereichs tenders, und das ist zu messen statt zu unterstellen. Im Schema nachgelesen: DkvVehicleMaster traegt @@unique([tenantId, kennzeichen]) — der Mandant ist Teil des Schluessels. DkvModuleConfig.tenantId ist selbst @unique. Ein gebundenes upsert kann hier also nicht auf eine unsichtbare fremde Zeile treffen, wie es der tenders-Befund F beschrieb. Das ist der Gegenbefund, und weil er eine Entscheidung traegt (keine P2002-Uebersetzung noetig), wird er gemessen und nicht aus dem Schematext geschlossen. Alle drei Tabellen haben ein NICHT nullbares tenantId — WINDOWS #19 (nullbare Mandantenkennung) faellt in diesem Bereich nicht an.

Befund I — die Policies dieses Bereichs, wortgleich nachgelesen. Alle drei in 20260909140000_rls_remaining_tenant_tables lauten USING ("tenantId" = current_tenant_id()), mit ENABLE und FORCE ROW LEVEL SECURITY, ohne FOR-Einschraenkung und ohne eigene WITH CHECK-Klausel. Was PostgreSQL daraus fuer ein INSERT macht, ist eine Eigenschaft der Datenbank und keine des Textes — deshalb wird das Schreibverhalten gemessen (Aufgabe 1) und nicht gelesen. Eine Benutzerdimension haben diese Policies wie die des Bereichs tenders nicht; hier faellt das weniger ins Gewicht, weil die DKV-Daten mandantenweit und nicht je Nutzer geschnitten sind — festhalten, nicht ausbauen.

Befund J — die Testlage, und sie ist die dritte Form. Der Auftrag verlangt, beide bisher beobachteten Formen zu pruefen. Gemessen: find apps/api -name "*.spec.ts" liefert 53 Dateien und darunter KEINE EINZIGE fuer den Bereich dkv. Es gibt also weder den Identitaets-Mock von ldap noch das Fehlen eines Mocks bei vorhandenen Tests wie in groups/tenders — es gibt gar keine Tests. Der Bereich kann heute auf keinen Fehler rot werden, nicht nur auf keinen Bindungsfehler. Eine Testdatei anzulegen ist damit keine Zugabe, sondern die Voraussetzung dafuer, dass irgendeine Aussage dieses Plans nachpruefbar ist. Das Muster liegt vor: apps/api/src/groups/groups.service.spec.ts mockt die Erweiterung und liefert __makeBoundClient(tenantId) als protokollierenden Wrapper um DIESELBEN Maps.

Befund K — welcher Code Leere als Abwesenheit deutet (Vorarbeit fuer Aufgabe 1, dort auszuformulieren und zu ergaenzen, nicht abzuschreiben).

Die Form, die diesen Bereich von den drei vorherigen unterscheidet: nicht "eine Liste ist leer", sondern "ein einzelnes Objekt ist null, und null bedeutet hier etwas Harmloses".

  1. loadConfig() ohne Mandant, gefolgt von config?.isActive im Planer — null heisst "kein aktives Modul", der Planer richtet nichts ein und protokolliert das als Normalfall. Kein Fehler, keine Warnung, keine sichtbare Aenderung.
  2. _runPipeline, if (!config) { warn; return; } — null heisst "dieser Mandant hat DKV nicht eingerichtet". Die Rechnungsverarbeitung stellt die Arbeit ein. Rechnungen laufen weiter im Postfach auf, es entsteht keine Historienzeile, keine Ausfuhrdatei, keine E-Mail — und keine Fehlermeldung.
  3. getConfigForApi, if (!safe) return null — die Oberflaeche zeigt daraufhin ein leeres Einrichtungsformular. Ein Administrator sieht "noch nicht eingerichtet" fuer ein Modul, das eingerichtet IST, und wuerde beim Neu-Ausfuellen die vorhandenen Zugangsdaten ueberschreiben.
  4. getConfigForApi, der try/catch um die Entschluesselung — faengt heute Entschluesselungsfehler ab und liefert einen leeren Benutzernamen. Nach dem Scharfschalten faellt der Lesezugriff selbst leer aus, raw ist null, und der Zweig laeuft ohne Fehler durch: hasPassword bleibt false. Die Oberflaeche meldet "kein Passwort hinterlegt" fuer ein hinterlegtes Passwort.
  5. saveConfig, die Erhaltung der nicht ausgefuellten Zugangsdaten — liest die bestehende Zeile, um Benutzername oder Passwort zu uebernehmen. Laeuft dieser Lesezugriff leer, wird das Feld mit dem LEEREN Wert neu verschluesselt: aus einem gespeicherten Passwort wird ein leeres. Zerstoerend und still, die gefaehrlichste Stelle des Bereichs. Sie liegt hinter einem try/catch, das ausdruecklich sagt, dass es Fehler ignoriert und mit dem Uebergebenen ueberschreibt.
  6. testConnection, der Rueckgriff auf das gespeicherte Passwort — laeuft leer, der Test schlaegt mit einem Anmeldefehler des Postfachs fehl. Harmlos in der Richtung, aber irrefuehrend: die Meldung zeigt auf das Postfach, nicht auf die Datenbank.
  7. _buildExportRows — die Fahrzeugstammdaten werden gebuendelt geladen und ueber eine Zuordnungstabelle gesucht; ein fehlender Treffer ist per D-13 ein GUELTIGER Zustand (unbekanntes Kennzeichen, leeres Fahrerfeld). Laeuft der Lesezugriff ganz leer, entsteht eine vollstaendige Ausfuhrdatei OHNE einen einzigen Fahrer — kein Fehler, keine Warnung, eine Datei, die plausibel aussieht und falsch ist. Diese Stelle ist der Grund, warum es nicht genuegt, nur die Anzeigepfade zu binden.

Gegenrichtung, ebenfalls nachgesehen: updateVehicle/deleteVehicle werfen bei Leere LAUT (NotFoundException), importVehiclesCsv wirft bei leerem CSV laut, und getExportFile wirft bei fehlender Datei laut. Das sind die harmlosen Stellen.

Befund L — was in diesem Bereich NICHT umzustellen ist. Ausser dem in Befund D behandelten Planer-Startpfad: nichts. Es gibt keine Klasse keine-mandantengebundene-tabelle in diesem Bereich; alle drei Paare sind muss-mandantengebunden und alle drei tragen ein nicht nullbares tenantId. Der Bereich endet damit im Stand gebunden fuer dkvInvoiceHistory und dkvVehicleMaster und gemischt fuer dkvModuleConfig — die eine Mischung ist der benannte Planer-Pfad, nicht eine uebrig gebliebene Fundstelle.

</planning_time_findings>

Aufgabe 1: Die Fehlerform dieses Bereichs messen und die Kritikschrift fuer dkv schreiben Der Container `tessera-ctl-db-1` laeuft; seine Adresse per `docker inspect tessera-ctl-db-1 --format '{{range $k,$v := .NetworkSettings.Networks}}{{$v.IPAddress}}{{end}}'` NEU ermitteln (eine Container-Adresse ist veraenderlich und darf nicht aus diesem Plan abgeschrieben werden). apps/api/scripts/rls-scratch-check.mjs, docs/mandantentrennung-etappe2-fehlerrichtung.md Zuerst messen, dann die Kritik aus der Messung schreiben — nicht umgekehrt. Kein Dienstcode in dieser Aufgabe.

TEIL 1, apps/api/scripts/rls-scratch-check.mjs: einen sechsten Abschnitt runDkvAreaChecks(adminUrl, scratchRoleUrl, results) nach dem Vorbild des vorhandenen runTendersAreaChecks ergaenzen und in main() NACH diesem, aber VOR runTransactionShapeMeasurement aufrufen — die Transaktionsmessung und die Lastprobe setzen auf den von runGroupsAreaChecks angelegten Tabellen auf und duerfen ihre Voraussetzung nicht verlieren; das ist beim Einhaengen zu pruefen, nicht anzunehmen.

Die drei Policies werden NICHT im Werkzeug neu getippt. Sie kommen aus derselben Migration, die readRemainingTenantTablesMigrationSql() bereits liest; extractPolicySql() schneidet je Tabelle heraus. Gebraucht werden DkvInvoiceHistory, DkvModuleConfig, DkvVehicleMaster. Findet die Extraktion eine der drei nicht, meldet der Abschnitt eine FEHLGESCHLAGENE Pruefung dkv-policies-aus-migration-gefunden und bricht ab — das Werkzeug darf nicht still mit einer geratenen Policy weitermessen.

Der Abschnitt legt in der Wegwerf-Datenbank schlanke Tabellen an, die genau die Spalten und Bedingungen tragen, die die Policies und die Messungen brauchen. Die Eindeutigkeitsbedingungen sind dabei KEIN Beiwerk, sondern Gegenstand von Befund H: DkvModuleConfig."tenantId" eindeutig, DkvVehicleMaster("tenantId","kennzeichen") zusammengesetzt eindeutig, DkvInvoiceHistory ohne Eindeutigkeit mit einer Spalte exportFilename. Alle drei tenantId-Spalten NICHT NULL. Danach ENABLE plus FORCE ROW LEVEL SECURITY, die drei extrahierten Policies, die Rechtevergabe an die Wegwerf-Rolle und Testzeilen: je Mandant (TENANT-A, TENANT-B) je eine Konfigurationszeile, je zwei Fahrzeugzeilen mit einem KENNZEICHEN, das in BEIDEN Mandanten vorkommt, und je eine Historienzeile mit einem gesetzten exportFilename.

Gemessen wird unter der Rolle ohne BYPASSRLS ueber das vorhandene forTenantQuery-Hilfsmittel, mit diesen Kennungen — jede Kennung genau so geschrieben, weil die Pruefung dieser Aufgabe sie einzeln in der Ausgabe sucht:

  • dkvmoduleconfig-gebunden-nur-eigene-zeile — der gebundene SELECT unter TENANT-A liefert die Zeile von A und keine von B.
  • dkvmoduleconfig-ungebunden-null-zeilen — DERSELBE SELECT ohne vorher gesetzten Kontext liefert null Zeilen. Die Belegzeile, die den ganzen Abschnitt der Kritikschrift traegt; sie muss an der echten, ausgelieferten Policy haengen.
  • dkvmoduleconfig-ungebundene-einzelabfrage-liefert-keine-zeile — die eigene, diesem Bereich vorbehaltene Messung: ein ungebundenes SELECT ... LIMIT 1 ohne jede Bedingung, also die Form, die der Planer-Startpfad heute benutzt. Bestanden, wenn KEINE Zeile zurueckkommt, obwohl zwei existieren. Der Meldetext sagt ausdruecklich, was das bedeutet: aus einer beliebigen-aber-vorhandenen Zeile wird keine Zeile, und der aufrufende Code liest das als "nicht eingerichtet". Ohne diesen Zusatz ist die Zeile nicht von der vorherigen zu unterscheiden.
  • dkvinvoicehistory-gebunden-nur-eigener-mandant und dkvvehiclemaster-gebunden-nur-eigener-mandant — je eine Pruefung nach dem Muster der ersten.
  • dkvvehiclemaster-gebundenes-einfuegen-fremder-mandant-abgelehnt — ein gebundenes INSERT unter TENANT-A mit tenantId von TENANT-B. Die Abweisung ist das bestandene Ergebnis. Diese Pruefung existiert, weil die ausgelieferten Policies KEINE eigene WITH-CHECK-Klausel tragen und was PostgreSQL daraus fuer ein INSERT ableitet eine Eigenschaft der Datenbank ist, keine des Policy-Textes (Befund I). Faellt sie anders aus als erwartet, ist das ein Ergebnis und kein Grund, sie umzuschreiben: dann gilt die Messung, und die Abweichung wird in der Kritikschrift ausgeschrieben, bevor Aufgabe 2 beginnt.
  • dkvvehiclemaster-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen — ein gebundenes UPDATE unter TENANT-A, das eine Zeile von TENANT-B allein ueber deren Kennung anspricht. Bestanden, wenn null Zeilen betroffen sind. Der Meldetext nennt die Folge fuer Aufgabe 3 (Befund G): ein Schreibzugriff ueber die Kennung allein scheitert gebunden nicht laut, sondern trifft still nichts — die vorgeschaltete Besitzpruefung bleibt deshalb erhalten und wird nicht durch die Datenbank ersetzt.
  • dkvvehiclemaster-schluessel-traegt-mandant-keine-fremdkollision — unter TENANT-A ein gebundenes INSERT auf ein Kennzeichen, das es unter TENANT-B bereits gibt. Bestanden, wenn es GELINGT. Das ist der Gegenbefund zu Befund F des Bereichs tenders: weil der Mandant Teil des zusammengesetzten Schluessels ist, gibt es hier keine Kollision auf einer unsichtbaren fremden Zeile und keine P2002-Uebersetzung zu bauen. Der Meldetext sagt genau das.

TEIL 2, die Nebenlaeufigkeitsform, auf die sich dieser Bereich stuetzt (Befund C): getHistory fuehrt zwei Abfragen ueber Promise.all parallel aus, nach der Umstellung also zwei parallele Einzeloperationen auf EINEM gebundenen Klienten. Die vorhandene runConcurrencyProbe misst die interaktiven Formen, nicht diese. Den Abschnitt deshalb um dkv-zwei-parallele-gebundene-einzelabfragen-je-eigener-kontext erweitern: zwei ueber Promise.all gleichzeitig gestartete gebundene Abfragen ueber DENSELBEN Klienten, eine unter TENANT-A und eine unter TENANT-B, jeweils mit pg_backend_pid() und current_tenant_id() in der Abfrage. Bestanden, wenn jede den Kontext sieht, unter dem sie gestartet wurde, und jede die richtige Zeilenzahl liefert. Als Verletzung zaehlt beides: ein fremder oder fehlender Kontext und ein Abbruch.

TEIL 3, Beleg statt Behauptung fuer Befund C: grep -rn '\$transaction(' apps/api/src/dkv --include=*.ts | grep -v spec ausfuehren und das Ergebnis (Trefferzahl) in der Kritikschrift festhalten, samt der Feststellung, dass der im Kopf von prisma-tenant.extension.ts verlangte erneute Test fuer diesen Bereich damit beantwortet ist: kein neuer Fall, withTenantTransaction() wird nicht gebraucht und nicht eingefuehrt, und die beiden mehrschrittigen Stellen bleiben so unatomar wie heute. Faellt das Ergebnis anders aus als in Befund C beschrieben, gilt die MESSUNG, und die Abweichung wird ausgeschrieben, bevor Aufgabe 2 beginnt.

Das Werkzeug raeumt weiterhin ausschliesslich seine fest verdrahtete Wegwerf-Datenbank ab und bekommt keine steuerbaren Namen (T-EOR-07 bleibt gueltig). Kein bestehender Abschnitt wird veraendert; alle 32 bisherigen Pruefungen muessen unveraendert weiterlaufen.

TEIL 4, docs/mandantentrennung-etappe2-fehlerrichtung.md um einen Abschnitt ## Bereich dkv ERWEITERN, nicht ein zweites Dokument anlegen. Die Leitfrage aus Abschnitt (a) gilt unveraendert weiter und wird nicht wiederholt; der neue Abschnitt verweist darauf und haelt im Kopf fest, dass er den Bereich dkv zum Zeitpunkt seiner Umstellung beschreibt (Quick-Task 260909-mir). In ganzen Saetzen auf Deutsch, mit derselben Gliederung wie der tenders-Abschnitt:

(d1) Die Messung — die TATSAECHLICH beobachtete Ausgabe des Laufs, hineinkopiert, nicht nacherzaehlt, mit Datum und der bei der Ausfuehrung ermittelten Adresse. Die Belegzeile ausdruecklich benennen, und daneben die zweite tragende Zeile (dkvmoduleconfig-ungebundene-einzelabfrage-liefert-keine-zeile) mit dem Hinweis, warum dieser Bereich sie zusaetzlich braucht: er liest an der entscheidenden Stelle kein Mengenergebnis, sondern ein Einzelobjekt. Die Ergebnisse aus Teil 2 und Teil 3 gehoeren ebenfalls hierher.

(d2) Signaltabelle je umgestelltem Pfad: Pfad, Verhalten bei zu wenig Ergebnis, konkretes Signal mit Ort. Es muessen alle in Aufgabe 2 und 3 umgestellten Pfade vorkommen, ausserdem der bewusst ungebunden bleibende Planer-Startpfad und der neue Riegel vor dem Ausfuhrdatei-Download (dessen Signal bei zu kleinem Leseergebnis eine 404 auf eine tatsaechlich vorhandene, tatsaechlich eigene Datei ist).

(d3) Welcher Code Leere als Abwesenheit deutet — der Kern dieses Abschnitts, weil die Form hier eine andere ist als in den drei Bereichen davor. Ausdruecklich ausfuehren: ein Einzelobjekt, das null wird, wo null bereits eine gueltige, harmlose Bedeutung hat ("dieses Modul ist nicht eingerichtet"), sieht nach dem Scharfschalten identisch aus wie ein nie eingerichtetes Modul — die Oberflaeche zeigt ein normales, unalarmiertes leeres Formular. Die sieben Stellen aus Befund K namentlich benennen, getrennt nach zerstoerend / lautlos / irrefuehrend, mit der Zugangsdaten-Erhaltung in saveConfig als der zerstoerenden. Die Rechnungsverarbeitung bekommt eigenen Raum: bei still verschwundener Konfiguration laufen Rechnungen im Postfach auf, ohne Historienzeile, ohne Ausfuhrdatei, ohne Versand und ohne Fehlermeldung. Die Gegenrichtung (die laut werfenden Stellen) ebenfalls nennen, damit der Abschnitt nicht nur Alarm ist.

(d4) Was dieser Durchlauf bewusst nicht loest: der Planer-Startpfad mit der VOLLSTAENDIGEN Begruendung aus Befund D — beide Zustaende (heute beliebig, kuenftig leer), die drei erwogenen Formen und warum (c) gewaehlt wurde, der Verweis auf den Praezedenzfall getAllActiveConfigs und die Unsymmetrie, die dieser Praezedenzfall NICHT deckt; die gemeinsame Ablage der Ausfuhrdateien samt Verdraengung ueber Mandantengrenzen (Befund F); die Uebergaben in die noch nicht umgestellten Bereiche settings (ueber dkv-mail.service.ts, dieselbe Reihenfolgebedingung, die der tenders-Durchlauf als Befund K fuehrt) und module-registry (ueber dkv.seed.ts); und die offene Architekturfrage req.tenantPrisma, die auch dieser Bereich nicht entscheidet.

(d5) Was dieser Durchlauf bewusst NICHT anfasst: die beiden mehrschrittigen Stellen bleiben unatomar (Befund C), und die Ablagestruktur der Ausfuhrdateien bleibt unveraendert (Befund F). Beide mit der Feststellung, dass sie geprueft und bewusst gelassen sind — nicht uebersehen. 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 dkvmoduleconfig-gebunden-nur-eigene-zeile dkvmoduleconfig-ungebunden-null-zeilen dkvmoduleconfig-ungebundene-einzelabfrage-liefert-keine-zeile dkvinvoicehistory-gebunden-nur-eigener-mandant dkvvehiclemaster-gebunden-nur-eigener-mandant dkvvehiclemaster-gebundenes-einfuegen-fremder-mandant-abgelehnt dkvvehiclemaster-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen dkvvehiclemaster-schluessel-traegt-mandant-keine-fremdkollision dkv-zwei-parallele-gebundene-einzelabfragen-je-eigener-kontext; 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.$' && grep -q '^## Bereich dkv$' docs/mandantentrennung-etappe2-fehlerrichtung.md && npm --prefix apps/api run test && npm --prefix apps/api run type-check && test -z "$(git diff --name-only HEAD -- apps/api/prisma docker-compose.yml docker-compose.dev.yml docker-compose.prod.yml docker-compose.ci.yml .env.example .env.prod.example)" Das Wegwerf-Werkzeug meldet alle Pruefungen bestanden (die 32 bisherigen plus die neun namentlich gepruefte des dkv-Abschnitts) mit Rueckgabewert 0; jede der neun Kennungen steht einzeln als bestanden in der Ausgabe; npm --prefix apps/api run test meldet weiterhin 772 Tests gruen und die Typpruefung ist sauber; docs/mandantentrennung-etappe2-fehlerrichtung.md traegt einen Abschnitt ## Bereich dkv mit der tatsaechlich beobachteten Ausgabe, einer Signaltabelle, dem eigenen Unterabschnitt zur null-als-Abwesenheit-Fehlerform mit sieben namentlich benannten Stellen, der ausgeschriebenen Planer-Entscheidung samt der drei erwogenen Formen, und den beiden Abschnitten zu dem, was bewusst offen bzw. unangetastet bleibt; Schema, Migrationen, Compose- und Beispiel-Umgebungsdateien sind unveraendert.

Aufgabe 2: Die Testlage herstellen, die Konfigurationspfade binden und den Planer-Pfad benennen statt still lassen Aufgabe 1 ist abgeschlossen und uebersetzt; der Container `tessera-ctl-db-1` laeuft (Adresse erneut ermitteln, nicht abschreiben). apps/api/src/dkv/dkv.service.spec.ts, apps/api/src/dkv/dkv.service.ts, apps/api/src/dkv/dkv-scheduler.service.ts, .planning/WINDOWS.md Der Nachweis kommt VOR der Umstellung, sonst beweist er nichts. Dieser Bereich hatte bisher KEINE einzige Testdatei (Befund J) — der erste Schritt ist deshalb, ueberhaupt eine Stelle zu schaffen, an der etwas rot werden kann.

apps/api/src/dkv/dkv.service.spec.ts neu anlegen, nach dem Muster aus apps/api/src/groups/groups.service.spec.ts:

  • Die Erweiterung ../prisma/prisma-tenant.extension wird gemockt, sodass forTenant(prisma, tenantId) an prisma.__makeBoundClient(tenantId) delegiert. withTenantTransaction wird NICHT gemockt und nicht gebraucht — dieser Bereich hat keine Transaktion (Befund C).
  • Ein handgeschriebener In-Memory-Fake mit Maps fuer dkvModuleConfig, dkvVehicleMaster und dkvInvoiceHistory. __makeBoundClient(tenantId) liefert je Modell einen protokollierenden Wrapper um DIESELBEN Maps und schreibt jeden Aufruf als { tenantId, model, method } in ein gemeinsames Protokoll. Zwei unterscheidbare Klienten ueber einen Speicher — der ungebundene Fake protokolliert nicht, der gebundene schon. Genau daran wird eine vergessene Bindung sichtbar.
  • Die uebrigen Abhaengigkeiten des Dienstes (Verschluesselung, Zerleger, Ausfuhr, Versand, die beiden Postfach-Anbieter) als schlichte Attrappen.

Erwartete Testfaelle dieser Aufgabe:

  • Test 1: getConfigForApi(tenantId) — beide Lesezugriffe auf dkvModuleConfig stehen im Bindungsprotokoll unter genau diesem Mandanten.
  • Test 2: getConfigForApi eines Mandanten liefert NICHT die Konfiguration eines zweiten Mandanten, wenn beide im Speicher liegen.
  • Test 3: saveConfig(tenantId, dto) mit gesetztem Benutzernamen und LEEREM Passwort — der erhaltende Lesezugriff UND der Schreibzugriff stehen gebunden im Protokoll, und das gespeicherte Passwort ist danach unveraendert. Das ist die zerstoerende Stelle aus Befund K; sie braucht einen eigenen Test, nicht nur eine Bindungszaehlung.
  • Test 4: testConnection(tenantId, dto) mit leerem Passwort — der Rueckgriff auf die gespeicherten Zugangsdaten steht gebunden im Protokoll.
  • Test 5: die Verarbeitungsstrecke liest ihre Konfiguration gebunden; bei fehlender Konfiguration bricht sie wie bisher still ab (das Verhalten wird NICHT geaendert, nur festgeschrieben, damit eine spaetere Aenderung sichtbar wird).
  • Test 6: der bewusst uebergreifende Planer-Startpfad steht NICHT im Bindungsprotokoll — die einzige Stelle des Bereichs, an der das Fehlen einer Bindung die bestandene Erwartung ist. Der Testname sagt das ausdruecklich, damit niemand ihn spaeter als vergessene Bindung "repariert".
  • Test 7: der Planer-Startpfad liefert die verschluesselten Zugangsdaten NICHT mit — die Entlastung aus Befund D wird festgeschrieben, nicht geglaubt.

Falsifizierungsnachweis, verlangt und zu belegen: nach der Umstellung eine der gebundenen Stellen probeweise zurueckbauen, beobachten, dass GENAU der erwartete Test rot wird, den Rueckbau zuruecknehmen, und beides im SUMMARY festhalten. Ein Test, von dem nur behauptet wird, dass er rot werden koennte, ist kein Nachweis. apps/api/src/dkv/dkv.service.ts, alle sieben Zugriffe auf dkvModuleConfig:

Die Methode loadConfig(tenantId?) wird in ZWEI Methoden geteilt. Das ist der Kern dieser Aufgabe und nicht kosmetisch: ein optionaler Parameter, hinter dem der eine Zweig gebunden werden MUSS und der andere gebunden werden DARF NICHT, ist genau die Form, die spaeter jemand versehentlich vereinheitlicht.

  • loadConfig(tenantId) bekommt einen PFLICHT-Mandanten und laeuft ueber einen gebundenen Klienten aus forTenant(this.prisma, tenantId).
  • Der uebergreifende Zweig wird eine eigene, benannte Methode, deren Name sagt, was sie tut (sie zieht IRGENDEINE aktive Konfiguration fuer die Einrichtung des Planers, nicht die eines bestimmten Mandanten). Sie bleibt bewusst UNGEBUNDEN und behaelt die sichere Feldauswahl, die die verschluesselten Zugangsdaten ausschliesst. Ihr Kopfkommentar benennt BEIDE Zustaende: dass sie heute schon eine beliebige Zeile zieht und bei mehreren Mandanten die uebrigen nie bedient, UND dass sie nach dem Scharfschalten gar keine Zeile mehr zieht und der Planer daraufhin still nichts einrichtet. Er benennt ausserdem, warum sie NICHT gebunden wird (binden hiesse garantiert leer laufen), warum sie NICHT auf einmal-abfragen-viele-bedienen umgebaut wird (das ist die in 07-04 zurueckgestellte Mehrmandanten-Planung, also eine Funktionsaenderung), und wohin das Signal gehoert (Vorabpruefung von Etappe 4, apps/api/scripts/rls-preflight.mjs). Der Kommentar verweist auf den dkv-Abschnitt der Kritikschrift statt die Begruendung zu wiederholen.

getConfigForApi, saveConfig, testConnection und der Konfigurations-Lesezugriff der Verarbeitungsstrecke binden vollstaendig: je Methode EIN gebundener Klient, nicht einer je Modellzugriff. Die bestehenden where-Bedingungen ueber tenantId bleiben erhalten — nicht mit dem Argument entfernen, das mache jetzt die Datenbank; dieselbe Regel, die der tenders-Durchlauf fuer die Benutzerfilterung aufstellte. Das upsert in saveConfig braucht keine Kollisionsbehandlung, weil der Mandantenschluessel selbst eindeutig ist (Befund H, in Aufgabe 1 gemessen).

apps/api/src/dkv/dkv-scheduler.service.ts: den vorhandenen Kopfkommentar zur Einmandanten-Fassung fortschreiben statt ersetzen. Er benennt danach zusaetzlich, dass der Planer nach dem Scharfschalten gar nichts mehr einrichtet, dass die Protokollzeile ueber die fehlende aktive Konfiguration auf einer frischen Installation der Normalfall ist und deshalb nicht alarmiert, und verweist auf den dkv-Abschnitt der Kritikschrift und auf den Registereintrag aus dieser Aufgabe. Der Aufruf wird auf die neue, benannte Methode umgestellt. An der Ablauflogik des Planers wird NICHTS geaendert: keine zweite Konfiguration, kein zweiter Auftrag, kein Fan-out.

Broken-Windows-Register: die Altlast als offenen Eintrag anlegen, mit node ~/.claude/gsd-core/bin/gsd-tools.cjs windows append (die Aufrufform ohne Argumente ausgeben lassen, wenn die erwarteten Felder unklar sind — nicht raten). Der Text nennt beide Zustaende, den betroffenen Pfad, die Reihenfolgebedingung fuer Etappe 4 und die Entscheidung samt Begruendung. Die Verwaltungsfelder des Registers werden dem Werkzeug ueberlassen, nicht von Hand geschrieben.

Nichts anderes wird in dieser Aufgabe angefasst: keine Fahrzeug- oder Historienpfade (Aufgabe 3), kein Schema, keine Migration, keine Compose- oder Umgebungsdatei. DB_IP=$(docker inspect tessera-ctl-db-1 --format '{{range $k,$v := .NetworkSettings.Networks}}{{$v.IPAddress}}{{end}}') && TESSERA_SCRATCH_ADMIN_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/postgres" node apps/api/scripts/rls-scratch-check.mjs && npm --prefix apps/api run test && npm --prefix apps/api run type-check && test -f apps/api/src/dkv/dkv.service.spec.ts && grep -q '__makeBoundClient' apps/api/src/dkv/dkv.service.spec.ts && test -z "$(git diff --name-only HEAD -- apps/api/prisma docker-compose.yml docker-compose.dev.yml docker-compose.prod.yml docker-compose.ci.yml .env.example .env.prod.example)" apps/api/src/dkv/dkv.service.spec.ts existiert, nutzt den Zwei-Klienten-Nachweis ueber __makeBoundClient, und deckt die sieben in <behavior> genannten Faelle ab; die Testzahl liegt ueber 772 und der Lauf ist gruen; die Typpruefung ist sauber; das Wegwerf-Werkzeug meldet weiterhin alle Pruefungen bestanden; alle sieben Zugriffe auf dkvModuleConfig ausser dem einen benannten Planer-Startpfad laufen ueber forTenant(); der Planer-Startpfad ist eine eigene, benannte Methode mit einem Kopfkommentar, der beide Zustaende, die getroffene Entscheidung und den Ort des Signals nennt; dkv-scheduler.service.ts traegt den fortgeschriebenen Kommentar; die Altlast steht als offener Eintrag im Broken-Windows-Register; der Falsifizierungsnachweis (probeweiser Rueckbau, erwarteter Test rot, Rueckbau zurueckgenommen) ist im SUMMARY festgehalten; Schema, Migrationen, Compose- und Beispiel-Umgebungsdateien sind unveraendert.

Aufgabe 3: Fahrzeugstammdaten und Rechnungshistorie binden, die Ausfuhrdatei-Luecke schliessen und beide Dokumente nachziehen Aufgabe 2 ist abgeschlossen und uebersetzt; der Container `tessera-ctl-db-1` laeuft (Adresse erneut ermitteln, nicht abschreiben). apps/api/src/dkv/dkv.service.ts, apps/api/src/dkv/dkv.service.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md, docs/mandantentrennung-etappe2-fehlerrichtung.md Wieder Nachweis vor Umbau, im selben Fake und demselben Bindungsprotokoll aus Aufgabe 2 — der Fake wird erweitert, nicht ersetzt.
  • Test 1: listVehicles und createVehicle stehen gebunden im Protokoll und liefern bzw. schreiben nur unter dem uebergebenen Mandanten.
  • Test 2: updateVehicle und deleteVehicle — BEIDE Anweisungen je Methode (Besitzpruefung und Schreibzugriff) stehen gebunden im Protokoll. Das ist Befund G: eine gebundene Vorpruefung mit einem ungebundenen Schreibzugriff dahinter waere die Luecke, nicht die Loesung.
  • Test 3: updateVehicle/deleteVehicle auf ein Fahrzeug eines FREMDEN Mandanten werfen weiterhin die vorhandene Nicht-gefunden-Ausnahme; die Mandantenbedingung in der Besitzpruefung bleibt erhalten und wird nicht durch die Datenbank ersetzt.
  • Test 4: importVehiclesCsv in beiden Modi — Ersetzen (loeschen und anlegen) und Zusammenfuehren (je Zeile aktualisieren-oder-anlegen) — steht in JEDER Anweisung gebunden im Protokoll, und der Ersetzen-Modus loescht ausschliesslich Fahrzeuge des eigenen Mandanten, waehrend die eines zweiten Mandanten unberuehrt bleiben.
  • Test 5: getHistory — beide parallel gestarteten Abfragen stehen gebunden im Protokoll, und die Gesamtzahl zaehlt nur die eigenen Zeilen.
  • Test 6: die beiden Historien-Schreibzugriffe der Verarbeitungsstrecke (Erfolgsfall und Zerlegungsfehler) stehen gebunden im Protokoll.
  • Test 7: der gebuendelte Lesezugriff auf die Fahrzeugstammdaten beim Aufbau der Ausfuhrzeilen steht gebunden im Protokoll und zieht keine Fahrzeuge eines zweiten Mandanten in die Ausfuhrdatei.
  • Test 8: getExportFile liefert eine Datei, zu der eine Historienzeile DIESES Mandanten mit passendem Dateinamen existiert.
  • Test 9: getExportFile verweigert dieselbe Datei einem ZWEITEN Mandanten mit der vorhandenen Nicht-gefunden-Ausnahme, obwohl die Datei existiert und das Namensmuster besteht. Das ist der Beleg fuer die geschlossene Luecke aus Befund E und der wichtigste Test dieser Aufgabe.
  • Test 10: der Riegel laeuft ueber einen GEBUNDENEN Lesezugriff auf die Historie — im Bindungsprotokoll nachweisbar, nicht nur am Ergebnis.

Falsifizierungsnachweis wie in Aufgabe 2: eine gebundene Stelle probeweise zurueckbauen, beobachten, dass genau der erwartete Test rot wird, zuruecknehmen, im SUMMARY festhalten. apps/api/src/dkv/dkv.service.ts, die zehn Zugriffe auf dkvVehicleMaster und die vier auf dkvInvoiceHistory:

Alle binden ueber forTenant(this.prisma, tenantId), je Methode EIN gebundener Klient. Betroffen sind die Fahrzeugliste, das Anlegen, die beiden Paare aus Besitzpruefung und Schreibzugriff bei Aendern und Loeschen, beide Zweige des CSV-Imports, die beiden Historien-Schreibzugriffe der Verarbeitungsstrecke, die beiden parallelen Abfragen der Historienseite und der gebuendelte Lesezugriff beim Aufbau der Ausfuhrzeilen. Die bestehenden where-Bedingungen ueber tenantId und die vorgeschalteten Besitzpruefungen bleiben unveraendert erhalten. Fuer den Zusammenfuehren-Modus ist keine Kollisionsbehandlung noetig, weil der Mandant Teil des zusammengesetzten Schluessels ist (Befund H, in Aufgabe 1 gemessen) — falls die Messung in Aufgabe 1 anders ausgefallen ist, gilt sie und nicht dieser Satz.

Die Ausfuhrdatei-Luecke (Befund E) schliessen: getExportFile nimmt bereits einen Mandanten entgegen und verwirft ihn. Kuenftig entscheidet ein GEBUNDENER Lesezugriff auf die Rechnungshistorie ueber den hinterlegten Dateinamen, ob diese Datei zu diesem Mandanten gehoert; ohne Treffer wird dieselbe Nicht-gefunden-Ausnahme geworfen, die die Methode heute bei fehlender Datei wirft — Fehlen und Fremdbesitz kollabieren bewusst zu derselben Antwort, damit die Antwort selbst nichts ueber fremde Mandanten verraet. Der vorhandene Musterabgleich des Dateinamens bleibt als erste Stufe unveraendert stehen; der neue Riegel kommt DAHINTER und ersetzt ihn nicht. Ein Kommentar an der Methode haelt die dabei entstehende Verhaltensaenderung fest: eine Datei ohne zugehoerige Historienzeile ist danach nicht mehr abrufbar, und das ist die Absicht.

docs/mandantentrennung-zugriffsklassifikation.md nachziehen:

  • Die Bereichszeile dkv der Uebersichtstabelle mit den bei der Ausfuehrung NEU gemessenen Zahlen fortschreiben, im Stil der bereits fortgeschriebenen Zeilen (war X/0 plus eine Begruendung, welche Zugriffe umgestellt wurden und welche bewusst nicht). Die Summenzeile mitziehen.
  • Die drei Bestandsaufnahme-Zeilen des Bereichs auf den maschinell gemessenen Stand setzen: dkvInvoiceHistory und dkvVehicleMaster gebunden, dkvModuleConfig gemischt, jeweils mit einer Begruendung, die bei dkvModuleConfig ausdruecklich den einen benannten Planer-Startpfad als Grund der Mischung nennt — damit niemand ihn spaeter fuer eine uebersehene Fundstelle haelt.
  • Den Abschnitt zum Hintergrunddienst als Falle um den DKV-Planer erweitern, mit der Feststellung, dass er die entartete Form dieser Falle ist: er iteriert nicht ueber alle Mandanten, sondern zieht EINEN beliebigen — die uebrigen bekommen nicht zu wenig, sondern gar nichts.

Sollte rls-access-inventory.spec.ts nach der Umstellung einen anderen Stand messen als hier beschrieben, gilt die MESSUNG: dann wird das Dokument auf den gemessenen Stand gesetzt und die Abweichung im SUMMARY ausgeschrieben, statt die Pruefung passend zu machen.

docs/mandantentrennung-etappe2-fehlerrichtung.md abschliessen: den in Aufgabe 1 angelegten dkv-Abschnitt um einen Nachtrag ergaenzen, der die tatsaechlich umgesetzten Pfade gegen die dort angekuendigten haelt, und die geschlossene Ausfuhrdatei-Luecke festhalten. Wie in den vorherigen Durchlaeufen wird der urspruengliche Text NICHT umgeschrieben — er beschreibt korrekt den Zustand zum Zeitpunkt der Umstellung; der Nachtrag steht daneben. 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 && grep -qE '^| apps/api/src/dkv/dkv.service.ts | dkvVehicleMaster | muss-mandantengebunden | gebunden |' docs/mandantentrennung-zugriffsklassifikation.md && grep -qE '^| apps/api/src/dkv/dkv.service.ts | dkvInvoiceHistory | muss-mandantengebunden | gebunden |' docs/mandantentrennung-zugriffsklassifikation.md && grep -qE '^| apps/api/src/dkv/dkv.service.ts | dkvModuleConfig | muss-mandantengebunden | gemischt |' docs/mandantentrennung-zugriffsklassifikation.md && test -z "$(git diff --name-only HEAD -- apps/api/prisma docker-compose.yml docker-compose.dev.yml docker-compose.prod.yml docker-compose.ci.yml .env.example .env.prod.example)" Alle 21 Zugriffe des Bereichs ausser dem einen benannten Planer-Startpfad laufen ueber forTenant(); getExportFile gibt eine Datei nur heraus, wenn eine gebundene Historienzeile dieses Mandanten sie nennt, und verweigert sie einem zweiten Mandanten mit derselben Nicht-gefunden-Ausnahme; die Testdatei deckt die zehn in <behavior> genannten Faelle ab und der Lauf ist gruen mit mehr als 772 Tests; rls-access-inventory.spec.ts laeuft gruen und stimmt mit den drei Bestandsaufnahme-Zeilen des Klassifikationsdokuments ueberein (gebunden/gebunden/gemischt); die Bereichs- und Summenzeile der Uebersichtstabelle sind mit neu gemessenen Zahlen fortgeschrieben; der Abschnitt zum Hintergrunddienst als Falle nennt den DKV-Planer als entartete Form; die Kritikschrift traegt den Nachtrag mit den tatsaechlich umgesetzten Pfaden und der geschlossenen Ausfuhrdatei-Luecke; die Typpruefung ist sauber; das Wegwerf-Werkzeug meldet alle Pruefungen bestanden; Schema, Migrationen, Compose- und Beispiel-Umgebungsdateien sind unveraendert und DATABASE_URL zeigt weiterhin auf die Rolle tessera.

<threat_model>

Trust Boundaries

Boundary Description
Browser/Admin → DKV-API Der Mandant kommt ausschliesslich aus req.tenantId (gesetzt von TenantMiddleware nach den Auth-Guards); _requireTenant wirft, wenn er fehlt. Alles jenseits dieser Grenze ist nicht vertrauenswuerdig.
API → PostgreSQL Die RLS-Grenze. Heute wirkungslos, weil DATABASE_URL auf die Rolle tessera mit BYPASSRLS zeigt (WINDOWS #18) — dieser Plan bereitet die Grenze vor, schaltet sie aber NICHT scharf.
API → gemeinsames Ablageverzeichnis user-files/ Die einzige Grenze dieses Bereichs, die die Datenbank NICHT ziehen kann: alle Mandanten schreiben Ausfuhrdateien in dasselbe Verzeichnis, die Datei traegt keinen Mandanten.
API → fremdes Postfach (IMAP/Exchange) Ueber Zugangsdaten, die verschluesselt in DkvModuleConfig liegen und im Klartext nur innerhalb einer Methode existieren (T-05-13).
API → SMTP (ueber SettingsService) Bereich settings ist noch nicht umgestellt — Reihenfolgebedingung fuer Etappe 4, nicht Gegenstand dieses Plans.

STRIDE Threat Register

Threat ID Category Component Severity Disposition Mitigation Plan
T-MIR-01 Information Disclosure dkv.service.ts — Lesepfade auf dkvInvoiceHistory und dkvVehicleMaster (Fuhrpark, Fahrer, Rechnungs- und Ausgabenhistorie) high mitigate Aufgabe 3 bindet jeden dieser Lesepfade ueber forTenant(); Aufgabe 1 misst an der ausgelieferten Policy, dass ein gebundener SELECT nur die eigene Zeile liefert (dkvinvoicehistory-gebunden-nur-eigener-mandant, dkvvehiclemaster-gebunden-nur-eigener-mandant); die vorhandenen where-Bedingungen ueber tenantId bleiben als zweite Schicht erhalten.
T-MIR-02 Information Disclosure DkvService.getExportFile — Aufloesung einer Ausfuhrdatei allein ueber den Dateinamen, der uebergebene Mandant wird verworfen (Befund E, heute ausnutzbar) high mitigate Aufgabe 3 setzt einen gebundenen Lesezugriff auf DkvInvoiceHistory.exportFilename als Riegel hinter den bestehenden Musterabgleich; Fehlen und Fremdbesitz kollabieren zu derselben Nicht-gefunden-Antwort, damit die Antwort nichts ueber fremde Mandanten verraet. Test 9 der Aufgabe 3 belegt die Verweigerung gegenueber einem zweiten Mandanten.
T-MIR-03 Tampering updateVehicle/deleteVehicle — Schreibzugriff ueber die Datensatzkennung allein hinter einer Besitzpruefung (Befund G) medium mitigate Aufgabe 3 bindet BEIDE Anweisungen je Methode und laesst die Mandantenbedingung der Besitzpruefung stehen; Aufgabe 1 misst, dass ein gebundenes UPDATE ueber die Kennung allein auf eine fremde Zeile null Zeilen trifft (dkvvehiclemaster-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen) — es scheitert also still statt laut, weshalb die Vorpruefung nicht entfallen darf.
T-MIR-04 Tampering Gebundenes Einfuegen mit fremder Mandantenkennung — die ausgelieferten Policies tragen keine eigene WITH-CHECK-Klausel (Befund I) medium mitigate Aufgabe 1 misst das Schreibverhalten an der echten Policy statt es aus dem Policy-Text zu schliessen (dkvvehiclemaster-gebundenes-einfuegen-fremder-mandant-abgelehnt). Faellt die Messung anders aus als erwartet, gilt sie und die Abweichung wird ausgeschrieben, bevor umgebaut wird.
T-MIR-05 Information Disclosure Postfach-Zugangsdaten in DkvModuleConfig.encryptedInboxCreds — Auswahl der Konfigurationszeile durch eine Abfrage ohne Mandantenbedingung medium mitigate Der uebergreifende Planer-Startpfad behaelt die sichere Feldauswahl, die die verschluesselten Zugangsdaten ausschliesst; Test 7 der Aufgabe 2 schreibt das fest statt es zu glauben. Alle Pfade, die die Zugangsdaten tatsaechlich lesen (Anzeige, Speichern, Verbindungstest, Verarbeitungsstrecke), binden in Aufgabe 2 vollstaendig.
T-MIR-06 Denial of Service Umgekehrte Fehlerrichtung: nach dem Scharfschalten liefert der ungebundene Planer-Startpfad null, der Planer richtet still nichts ein, und die Verarbeitungsstrecke liest null als "Modul nicht eingerichtet" — ein eingerichtetes Modul sieht aus wie ein nie eingerichtetes medium accept Bewusst nicht in diesem Durchlauf geloest, mit voller Begruendung (Befund D): eine Bindung liesse den Pfad garantiert leer laufen, ein Umbau auf einmal-abfragen-viele-bedienen waere die in 07-04 zurueckgestellte Mehrmandanten-Planung und damit eine Funktionsaenderung. Stattdessen dreifach markiert: eigene benannte Methode mit Kopfkommentar, der beide Zustaende nennt (Aufgabe 2), Abschnitt (d4) der Kritikschrift (Aufgabe 1), offener Eintrag im Broken-Windows-Register (Aufgabe 2). Wirkung erst nach Etappe 4, die ihre eigene Vorabpruefung (rls-preflight.mjs) hat; kein Vertraulichkeits- oder Integritaetsschaden, kein Datenverlust, selbstanzeigend beim naechsten Rechnungslauf.
T-MIR-07 Tampering Zerstoerende Fehlerrichtung: laeuft der erhaltende Lesezugriff in saveConfig leer, wird ein gespeichertes Passwort mit dem leeren Wert neu verschluesselt (Befund K, Stelle 5) high mitigate Aufgabe 2 bindet Lese- UND Schreibzugriff dieser Methode gemeinsam an denselben Mandanten, sodass beide dieselbe Sichtbarkeit haben und ein leerer Lesezugriff nicht mit einem erfolgreichen Schreibzugriff kombiniert werden kann; Test 3 der Aufgabe 2 prueft die Erhaltung des Passworts ausdruecklich statt nur die Bindung zu zaehlen. Die Stelle ist zusaetzlich in Abschnitt (d3) der Kritikschrift als die zerstoerende Stelle des Bereichs benannt.
T-MIR-08 Denial of Service Gemeinsames Ablageverzeichnis: writeAndPrune behaelt die letzten zehn Dateien ueber alle Mandanten hinweg und verdraengt damit die Dateien fremder Mandanten (Befund F) low accept Keine Bindungsfrage, sondern die Ablagestruktur; eine Loesung (Unterverzeichnisse je Mandant plus Umzug der Bestandsdateien) ist ein eigener Auftrag. In Abschnitt (d4) der Kritikschrift festgehalten. Auswirkung ist ein fehlgeschlagener Download bei erhaltener Historienzeile, kein Datenverlust an den Abrechnungsdaten selbst.
T-MIR-SC Tampering npm/pip/cargo-Installationen n/a accept Dieser Plan installiert kein Paket — keine Aufgabe fuehrt einen Paketmanager aus, alle benutzten Bausteine (vitest, @prisma/client, forTenant) sind bereits Abhaengigkeiten. Das Legitimitaets-Gate faellt damit nicht an; sollte bei der Ausfuehrung wider Erwarten eine Installation noetig werden, ist das ein Abbruchgrund und keine Nebensache.
</threat_model>
- Das Wegwerf-Werkzeug meldet nach jeder Aufgabe alle Pruefungen bestanden, mit Rueckgabewert 0, und die 32 vorbestehenden Pruefungen laufen unveraendert mit. - `npm --prefix apps/api run test` ist nach jeder Aufgabe gruen und liegt nach Aufgabe 2 ueber 772 Tests, weil der Bereich zum ersten Mal eine Testdatei hat. - `npm --prefix apps/api run type-check` gibt nach jeder Aufgabe 0 zurueck. - `rls-access-inventory.spec.ts` und die Stand-Spalte des Klassifikationsdokuments stimmen fuer alle drei Paare des Bereichs ueberein. - `git diff --name-only HEAD -- apps/api/prisma docker-compose*.yml .env.example .env.prod.example` ist nach jeder Aufgabe leer: kein Schema, keine Migration, keine Compose-Datei, keine Umgebungsdatei angefasst. `DATABASE_URL` zeigt unveraendert auf die Rolle `tessera` — der Schalter bleibt AUS. - Der Falsifizierungsnachweis ist je Aufgabe (2 und 3) im SUMMARY ausgeschrieben: welche Bindung probeweise zurueckgebaut wurde, welcher Test daraufhin rot wurde, und dass der Rueckbau zurueckgenommen ist. - Im Verzeichnisdienst wurde nichts geaendert; dieser Plan beruehrt Active Directory an keiner Stelle.

<success_criteria>

  • Alle 21 Zugriffe des Bereichs dkv sind entweder ueber forTenant() gebunden oder gehoeren zu dem EINEN benannten, kommentierten und im Register gefuehrten Planer-Startpfad. Es bleibt keine Fundstelle ohne Zuordnung.
  • Die Entscheidung zum Planer steht ausgeschrieben an drei Orten (Code, Kritikschrift, Register) und nennt beide Zustaende — heute beliebig, kuenftig leer — statt nur den zweiten.
  • Die Luecke der ldap-Klasse dieses Bereichs (Ausfuhrdatei ueber den Dateinamen allein) ist geschlossen und der Riegel ist durch einen Test belegt, der einem zweiten Mandanten den Zugriff verweigert.
  • Der Bereich hat zum ersten Mal Tests, und diese Tests koennen auf eine vergessene Bindung rot werden — nachgewiesen durch probeweisen Rueckbau, nicht behauptet.
  • Beide Dokumente sind fortgeschrieben, nicht umgeschrieben: der urspruengliche Text bleibt lesbar, Nachtraege stehen daneben. </success_criteria>
Create `.planning/quick/260909-mir-mandantentrennung-etappe-2-bereich-dkv-a/260909-mir-SUMMARY.md` when done