docs(quick-260909-mir): Plan fuer Etappe 2, Bereich dkv

This commit is contained in:
2026-09-09 16:27:47 +02:00
parent 6464ccb821
commit 748f0b513e
@@ -0,0 +1,791 @@
---
phase: quick-260909-mir
plan: 01
type: execute
wave: 1
depends_on: []
autonomous: true
requirements: [WINDOWS-20, ETAPPE-2-DKV]
files_modified:
- 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
estimate:
tokens: 170000
raw_tokens: 170000
tasks: 3
confidence: low
must_haves:
truths:
- "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."
artifacts:
- 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
key_links:
- "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"
---
<objective>
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.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<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
</context>
<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>
<tasks>
<task type="tracer">
<name>Aufgabe 1: Die Fehlerform dieses Bereichs messen und die Kritikschrift fuer dkv schreiben</name>
<precondition>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).</precondition>
<files>apps/api/scripts/rls-scratch-check.mjs, docs/mandantentrennung-etappe2-fehlerrichtung.md</files>
<action>
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.
</action>
<verify>
<automated>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)"</automated>
</verify>
<done>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.</done>
</task>
<task type="auto" tdd="true">
<name>Aufgabe 2: Die Testlage herstellen, die Konfigurationspfade binden und den Planer-Pfad benennen statt still lassen</name>
<precondition>Aufgabe 1 ist abgeschlossen und uebersetzt; der Container `tessera-ctl-db-1` laeuft (Adresse erneut ermitteln, nicht abschreiben).</precondition>
<files>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</files>
<behavior>
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.
</behavior>
<action>
`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.
</action>
<verify>
<automated>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)"</automated>
</verify>
<done>`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.</done>
</task>
<task type="auto" tdd="true">
<name>Aufgabe 3: Fahrzeugstammdaten und Rechnungshistorie binden, die Ausfuhrdatei-Luecke schliessen und beide Dokumente nachziehen</name>
<precondition>Aufgabe 2 ist abgeschlossen und uebersetzt; der Container `tessera-ctl-db-1` laeuft (Adresse erneut ermitteln, nicht abschreiben).</precondition>
<files>apps/api/src/dkv/dkv.service.ts, apps/api/src/dkv/dkv.service.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md, docs/mandantentrennung-etappe2-fehlerrichtung.md</files>
<behavior>
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.
</behavior>
<action>
`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.
</action>
<verify>
<automated>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)"</automated>
</verify>
<done>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`.</done>
</task>
</tasks>
<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>
<verification>
- 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.
</verification>
<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>
<output>
Create `.planning/quick/260909-mir-mandantentrennung-etappe-2-bereich-dkv-a/260909-mir-SUMMARY.md` when done
</output>