diff --git a/.planning/quick/260914-eym-mandantentrennung-etappe-3c-systemkontex/260914-eym-PLAN.md b/.planning/quick/260914-eym-mandantentrennung-etappe-3c-systemkontex/260914-eym-PLAN.md new file mode 100644 index 0000000..93fa6c3 --- /dev/null +++ b/.planning/quick/260914-eym-mandantentrennung-etappe-3c-systemkontex/260914-eym-PLAN.md @@ -0,0 +1,280 @@ +--- +phase: quick-260914-eym +plan: 01 +type: execute +wave: 1 +depends_on: [] +autonomous: true +requirements: [ETAPPE-3C, WINDOWS-21, WINDOWS-30] + +files_modified: + - apps/api/prisma/migrations/20260914120000_rls_system_context_read/migration.sql + - apps/api/src/prisma/prisma-tenant.extension.ts + - apps/api/src/prisma/prisma-tenant.extension.spec.ts + - apps/api/src/prisma/rls-access-inventory.spec.ts + - apps/api/src/groups/migration-sql.spec.ts + - apps/api/scripts/rls-scratch-check.mjs + - apps/api/src/dkv/dkv.service.ts + - apps/api/src/dkv/dkv.service.spec.ts + - apps/api/src/dkv/dkv-scheduler.service.ts + - apps/api/src/dkv/dkv-scheduler.service.spec.ts + - apps/api/src/dkv/dkv.controller.ts + - apps/api/src/mail/mail.module.ts + - apps/api/src/mail/mail.service.ts + - apps/api/src/mail/mail.service.spec.ts + - apps/api/src/settings/settings.service.ts + - apps/api/src/settings/settings.service.spec.ts + - apps/api/src/auth/auth.service.ts + - apps/api/src/auth/auth.service.spec.ts + - apps/api/src/ldap/ldap-config.service.ts + - apps/api/src/ldap/ldap-config.service.spec.ts + - apps/api/src/tenders/tender-digest.scheduler.ts + - apps/api/src/tenders/tender-digest.scheduler.spec.ts + - apps/api/src/tenders/tender-matching.service.ts + - apps/api/src/tenders/tender-matching.service.spec.ts + - apps/api/src/tenders/tender-notifications.integration.spec.ts + - docs/mandantentrennung-zugriffsklassifikation.md + - docs/mandantentrennung-etappe2-fehlerrichtung.md + - docs/mandantentrennung-etappe3-auftrag.md + - docs/mandantentrennung-datenbankrolle.md + - .planning/WINDOWS.md + +estimate: + tokens: 280000 + raw_tokens: 280000 + tasks: 3 + confidence: low + +must_haves: + truths: + - "Es gibt einen Helfer `forSystem(prisma)` in `prisma-tenant.extension.ts`, gleiche Array-Form-`$transaction`-Bauart wie `forTenant()` (Kontext und Abfrage auf EINER Verbindung, WINDOWS #20), der in EINER getaggten Anweisung `app.system_context = 'true'` setzt und AUSDRUECKLICH `app.current_tenant = ''` und `app.current_user = ''`. `forTenant()` und `withTenantTransaction()` setzen umgekehrt ausdruecklich `app.system_context = ''`. Dass kein Kontext vom anderen erbt, ist im Werkzeug GEMESSEN (`-fortenant-a-nach-systemkontext-nur-a`, `-is-system-context-unter-fortenant-false`), nicht angenommen; das zweite Netz (der ausdrueckliche Reset) ist durch Rueckbau falsifiziert (local=false im Werkzeug-Nachbau UND Reset entfernt -> rot; local=false allein -> gruen, weil der Reset traegt)." + - "Migration `20260914120000_rls_system_context_read` bringt `is_system_context()` (STABLE, `COALESCE(current_setting('app.system_context', true) = 'true', false)`) und je betroffener Tabelle EINE zusaetzliche PERMISSIVE Regel `system_read_policy ... FOR SELECT USING (is_system_context())`. Betroffen sind GENAU die fuenf Tabellen, die die Systemkontext-Leser tatsaechlich lesen (gezaehlt in Aufrufe und `include`/`select` hinein): DkvModuleConfig, LdapConfig, LdapFieldMapping, TenderMatch, TenderSavedSearch. SmtpConfig ist NICHT dabei, weil der Startpfad des Mailmoduls in diesem Durchlauf nicht auf den Systemkontext umgestellt, sondern ENTFERNT wird (Transport je Versand nach Mandant des Empfaengers, siehe unten). Bestehende Migrationen bleiben UNVERAENDERT (Prisma-Pruefsumme). Der Schalter bleibt AUS." + - "Die Regel erweitert NUR das Lesen. INSERT/UPDATE/DELETE unter Systemkontext scheitern weiter an den bestehenden Regeln (Mandant erforderlich). Zur Planungszeit live gemessen und im Werkzeug festzunageln: INSERT -> PrismaClientUnknownRequestError, SQLSTATE 42501 (`new row violates row-level security policy`); `updateMany`/`deleteMany` -> count 0; `update` per id -> P2025. Je Tabelle im Werkzeug: `-ungebunden-null-zeilen` (0 Zeilen), `-systemkontext-sieht-beide-mandanten`, `-systemkontext-insert-abgewiesen-42501`, `-systemkontext-updatemany-count-0`, `-systemkontext-deletemany-count-0`, `-pg-policies-genau-eine-system-read-policy-select` — ueber den GENERIERTEN Client, Wegwerf-Tabellen mit ALLEN skalaren Spalten (Spaltenvergleich gegen schema.prisma, 260910-krx-Lehre), Regeln WORTGLEICH aus den Migrationen geschnitten." + - "Alle sechs Faelle sind behandelt, jeder auf seine Art: (1) DKV-Planer: einmal-abfragen-viele-bedienen — `loadActiveConfigsForScheduler()` liest ALLE aktiven DkvModuleConfig ueber den Systemkontext, je aktivem Mandanten ein eigener Cron-Auftrag `dkv-inbox-poll:`, `setInterval(intervalMin, tenantId)`/`stopJob(tenantId)` ersetzen/entfernen nur den Auftrag DIESES Mandanten, der Tick je Mandant (`processInbox(tenantId)`) bleibt wie er ist. (2) Mailmodul: der Startpfad (`findFirst()` beim Boot) ist GELOESCHT; `MailService` baut je Versand einen nodemailer-Transport aus `getDecryptedSmtpConfig(tenantId)` (gebunden, Vorlage DkvMailService/TenderMailService) und faellt NUR fuer Mandanten ohne SmtpConfig auf die bisherige Umgebungsvariablen-Kette (MAIL_* -> TESSERA_SMTP_* -> localhost:1025) zurueck; `requestPasswordReset` reicht `user.tenantId` durch (dort bereits bekannt, Zeile 241). (3) ldap: `getAllActiveConfigs()` UND der Nachverschluesselungs-Lauf in `onApplicationBootstrap()` lesen ueber den Systemkontext (inkl. `include: { tenant, fieldMappings }`), die Schreibzeile je Altzeile laeuft ueber `forTenant(this.prisma, config.tenantId)`. (4) tender-digest: die Kandidatenabfrage liest ueber den Systemkontext, die Schleife bleibt je Mandant gebunden. (5) tender-matching: `tenderSavedSearch.findMany()` liest ueber den Systemkontext, Treffer-Anlage und Sofortmeldung bleiben je Profil gebunden, der Katalog-Lesezugriff (`tender`, D-03) bleibt ungebunden. (6) admin-seed: GEMESSEN — der einzige Lesezugriff ausserhalb der Schleife ist `tenant.findMany` auf `Tenant` (keine Regel in irgendeiner Migration), deshalb NUR dokumentiert, Datei UNVERAENDERT." + - "Mit EINEM Mandanten und unter BYPASSRLS verhaelt sich jeder Pfad beobachtbar identisch zu heute — je Pfad als Test festgenagelt, nicht zugesichert: dkv (ein aktiver Mandant -> genau ein Auftrag mit derselben Cron-Expression wie bisher, `*/15 * * * *` bzw. `0 */1 * * *`, Tick ruft `processInbox` mit dieser tenantId; inaktive Config -> kein Auftrag, Protokollzeile 'no active config found'), mail (Mandant MIT SmtpConfig -> Transport aus GENAU dieser Config, `from` = deren fromAddress; Mandant OHNE -> Umgebungs-Kette; Transportfehler bleibt verschluckt, T-02-12), ldap/digest/matching (bestehende Verhaltenstests unveraendert gruen, plus je eine Zusicherung, dass der uebergreifende Lesezugriff ueber `forSystem` laeuft und `forTenant` genauso oft wie bisher)." + - "Der Detektor `rls-access-inventory.spec.ts` hat eine FUENFTE Erkennungsform `const = forSystem(` mit Stand `system-gebunden` (auch fuer Relationsziele ueber `include`/`select` auf einem System-Klienten). Stand-Vorrang: ungebunden vorhanden UND anderes -> `gemischt`; nur ungebunden -> `ungebunden`; Systemkontext vorhanden und KEIN ungebundener Zugriff -> `system-gebunden` (auch wenn daneben mandantengebundene Zugriffe stehen — die Begruendungsspalte nennt sie); nur mandantengebunden -> `gebunden`. Der Wachhund ist falsifizierbar: `FORSYSTEM_ALLOWED_CALL_SITES` nennt je Datei die EXAKTE Zahl der `forSystem(`-Aufrufe (dkv.service.ts 1, ldap-config.service.ts 2, tender-digest.scheduler.ts 1, tender-matching.service.ts 1 — Summe 5); jede Datei mit `forSystem(` ausserhalb der Liste, jede Abweichung der Zahl und jeder veraltete Listeneintrag machen die Spec rot (durch Rueckbau belegt: Zahl fuer tender-matching auf 0 -> rot)." + - "Die Frage aus Etappe 2 ist je Pfad beantwortet und aufgeschrieben: KEINER der sechs Pfade deutet eine LEERE Systemkontext-Antwort als Abwesenheit und loescht oder deaktiviert daraufhin — Leere fuehrt ueberall zu 'nichts tun' (kein Auftrag, kein Sync, kein Digest, kein Treffer, keine Reparatur); der gefaehrliche Loeschzweig in `ldap.service.ts` liegt INNERHALB eines je Mandant gebundenen Sync-Laufs, den eine leere Konfigurationsliste gar nicht erst startet. Abschnitt `## Systemkontext (Etappe 3c, 260914-eym)` in der Kritikschrift mit (y1) Messung, (y2) Signaltabelle beider Fehlerrichtungen, (y3) Leere-als-Abwesenheit je Pfad, (y4) bewusst nicht geloest, (y5) bewusst nicht angefasst." + - "Aktenstand kohaerent: Bestandsaufnahme-Zeilen der betroffenen Dateien tragen den neuen Stand (`system-gebunden` fuer dkv.service.ts/dkvModuleConfig, ldap-config.service.ts/ldapConfig, /ldapFieldMapping, /tenant, tender-digest.scheduler.ts/tenderMatch, tender-matching.service.ts/tenderSavedSearch; `gebunden` fuer settings.service.ts/smtpConfig), Klassen UNVERAENDERT (72 Paare, 35/21/14/2, per Gate gezaehlt), Uebersichtstabelle mit dritter Spalte `System` und Summenzeile ABGELEITET (Schleife, nicht abgeschrieben), Hintergrunddienst-Abschnitt mit Regelschluss je Fall, `docs/mandantentrennung-etappe3-auftrag.md` vermerkt 3c als Erledigt, `docs/mandantentrennung-datenbankrolle.md` fuehrt die dritte Sitzungsvariable und haelt fest, dass `rls-preflight.mjs` (`ohne-kontext-leer`) durch die neue Regel NICHT gelockert wird (ohne gesetzte Variable ist `is_system_context()` false — Werkzeugbeleg `is-system-context-ungesetzt-false`), WINDOWS #21 und #30 `fixed`, neuer Eintrag fuer den plattformweiten Single-Flight-Riegel in `processInbox`, Zaehler aus den Zeilen abgeleitet (erwartet open 15 / waived 1 / fixed 21 / total 37)." + - "Der Schalter bleibt AUS (`DATABASE_URL` -> Rolle `tessera`, BYPASSRLS), `schema.prisma`, Compose- und Umgebungsdateien, `package.json`/Lockfile, `rls-preflight.mjs`, `admin-seed.service.ts` unveraendert (Gates gegen HEAD 5e0e408). Baseline: 62 Dateien / 1028 Tests, `tsc --noEmit` Exit 0, Werkzeug 203/203 — am Ende jeder Aufgabe frisch gemessen: Tests mindestens 1040 (Aufgabe 1) bzw. 1044 (Aufgabe 2/3), Typpruefung sauber, Werkzeug `Alle N Pruefungen bestanden` mit N >= 216 (Aufgabe 1) bzw. N >= 250 (ab Aufgabe 2). `git status` sauber nach jedem Commit, am Ende gepusht (HEAD == origin/main)." + artifacts: + - "apps/api/prisma/migrations/20260914120000_rls_system_context_read/migration.sql — NEU, Kopfform 20260911120000: `is_system_context()`, fuenf `system_read_policy ... FOR SELECT`, Abschnitt 'Was diese Migration bewusst NICHT tut' (kein SmtpConfig — Startpfad entfernt; kein Tenant/Tender — keine Regel; keine Schreibregel unter Systemkontext; Schalter AUS)" + - "apps/api/src/prisma/prisma-tenant.extension.ts — `forSystem(prisma)` mit Kopfkommentar 'Systemkontext (Etappe 3c)'; `forTenant()`/`withTenantTransaction()` setzen `app.system_context = ''` in derselben Anweisung; `$transaction`-Feld behaelt GENAU ZWEI Eintraege" + - "apps/api/src/prisma/prisma-tenant.extension.spec.ts — mindestens drei neue Tests (forSystem: Template nennt alle drei Variablen mit 'true'/''/'' und genau zwei Feld-Eintraege; forTenant: Template setzt app.system_context auf ''; withTenantTransaction: dito)" + - "apps/api/src/groups/migration-sql.spec.ts — neuer describe-Block fuer die neue Migration (Funktion mit COALESCE, genau fuenf CREATE POLICY system_read_policy, jede FOR SELECT, jede USING (is_system_context()), kein DROP POLICY, kein SmtpConfig ausserhalb von Kommentaren, Kopf nennt die Nicht-Tabellen)" + - "apps/api/src/prisma/rls-access-inventory.spec.ts — fuenfte Erkennungsform, `systemModels`, `STAND_TOKENS` mit `system-gebunden`, Vorrangregel in `computeStandByKey`, `FORSYSTEM_ALLOWED_CALL_SITES` (Map Datei -> exakte Zahl) mit drei Tests (Fremddatei/Abweichung rot, veralteter Eintrag rot, Zuweisungsform), Proben C/D/E" + - "apps/api/scripts/rls-scratch-check.mjs — `readRlsSystemContextMigrationSql()`, `extractIsSystemContextFunctionSql()`, `extractSystemReadPolicySql()`, Funktion in `setupScratchDatabase` geschnitten + GRANT EXECUTE, `forTenantQuery`/`buildInlineExtendedClient` mit Reset, `forSystemQuery`/`buildInlineSystemClient`, Abschnitt `runSystemContextChecks` (vier Funktionsfaelle, fuenf Tabellen a neun Kennungen, eine Relations-Kennung fuer ldapConfig->fieldMappings), im Hauptlauf nach `runUserDimensionChecks`, vor `runConcurrencyProbe`" + - "dkv: `DkvService.loadActiveConfigsForScheduler()` (Systemkontext, `where: { isActive: true }`, CONFIG_SAFE_SELECT, `orderBy: { tenantId: 'asc' }`), `DkvSchedulerService` mit Auftrag je Mandant, `DkvController.saveConfig` ruft `stopJob(tenantId)`, NEUE `dkv-scheduler.service.spec.ts` (mindestens sechs Tests), `dkv.service.spec.ts` Tests 6/7 umgestellt" + - "mail: `mail.module.ts` ohne Mailer-Fabrik und ohne Startpfad, `mail.service.ts` mit `resolveTransport(tenantId)` (tenant | env) und Transport je Versand, NEUE `mail.service.spec.ts` (mindestens vier Tests), `settings.service.ts` ohne Startpfad-Methode, `settings.service.spec.ts` ohne deren vier Tests, `auth.service.ts`/`.spec.ts` reichen `user.tenantId` durch" + - "ldap-config.service.ts (+spec), tender-digest.scheduler.ts (+spec), tender-matching.service.ts (+spec), tender-notifications.integration.spec.ts (Mock um `forSystem` ergaenzt)" + - "docs/mandantentrennung-etappe2-fehlerrichtung.md — Abschnitt `## Systemkontext (Etappe 3c, 260914-eym)` mit (y1)–(y5) und woertlicher Werkzeugausgabe; datierte Nachtraege in (d4), (s4), (b4) und im Abschluss" + - "docs/mandantentrennung-zugriffsklassifikation.md, docs/mandantentrennung-etappe3-auftrag.md, docs/mandantentrennung-datenbankrolle.md, .planning/WINDOWS.md — Nachtraege, Stand-Aenderungen, 3c erledigt, #21/#30 fixed, ein neuer Eintrag" + key_links: + - "Die Kernzusicherung 'Systemkontext erweitert NUR das Lesen' haengt an der Befehlsbindung `FOR SELECT` der neuen Regel. Permissive Regeln werden ODER-verknuepft: fuer SELECT gilt (Mandantenregel ODER Systemregel), fuer INSERT/UPDATE/DELETE gilt NUR die Mandantenregel — unter Systemkontext ist `current_tenant_id()` der Leerstring, kein Mandant passt. Rueckbau-Beleg: `FOR SELECT` in der Migrationsdatei voruebergehend entfernen -> Werkzeug `…-systemkontext-insert-abgewiesen-42501` rot (der Insert GELINGT dann). Danach `git checkout` der Datei." + - "Der Detektor sieht `forSystem(` nur ueber die neue Form. Ohne sie wuerde jede umgestellte Stelle als 'verschwunden' oder — bei Relationszielen — als gar nichts gezaehlt; die Bestandsaufnahme wuerde stumm falsch. Deshalb landet die Form in Aufgabe 1 zusammen mit dem ersten Aufrufer, und die Erlaubnisliste traegt exakte Zahlen, damit ein zweiter `forSystem`-Aufruf in einer erlaubten Datei ebenso rot wird wie ein Aufruf in einer fremden." + - "Kein-Erben: `set_config(..., true)` ist transaktionslokal — das ist das erste Netz und der Grund, warum `forTenant(A)` unmittelbar nach `forSystem` auf demselben Client nur A sieht (live gemessen). Der ausdrueckliche Reset ist das zweite Netz fuer eine hypothetische `local=false`-Aenderung. Beides ist getrennt zu belegen (siehe truths)." + - "Die Umgehung des Mail-Startpfads ist nur deshalb die richtige Loesung, weil GEMESSEN ist, dass der Mandant an der einzigen Versandstelle bekannt ist (`auth.service.ts:241` nutzt `user.tenantId` eine Zeile vor dem Versand; `sendWelcomeEmail` hat null Aufrufer). Ein `forSystem`-`findFirst()` haette #30 nur halb geschlossen (nicht mehr stumm, aber weiter der SMTP-Server EINES beliebigen Mandanten fuer alle — T-GWH-03)." + - "Die zu-wenig-statt-zu-viel-Falle: Ein Systemkontext-Leser, dem die Regel FEHLT, liefert nach dem Scharfschalten 0 Zeilen und schweigt. Deshalb je Tabelle `…-ungebunden-null-zeilen` UND `…-systemkontext-sieht-beide-mandanten` als Paar, und deshalb die Frage 'wer deutet Leere als Abwesenheit' je Pfad in (y3) beantwortet, mit Zeilennummer." +--- + + +Etappe 3c der Mandantentrennung: ein benannter Systemkontext fuer die Hintergrunddienste. Sechs Stellen lesen absichtlich ueber alle Mandanten; heute (BYPASSRLS, Schalter AUS) sehen sie alles, nach dem Scharfschalten (Etappe 4) saehen sie NULL Zeilen und wuerden verstummen — zwei davon (WINDOWS #21 DKV-Planer, #30 Mail-Startpfad) sind ausserdem HEUTE schon falsch, weil `findFirst()` ohne Bedingung bei mehreren Mandanten einen beliebigen zieht. Gebaut werden: der Helfer `forSystem(prisma)`, die Funktion `is_system_context()` mit je einer zusaetzlichen, nur lesenden Regel auf den fuenf betroffenen Tabellen, die Umstellung der sechs Faelle (DKV-Planer auf Auftrag je Mandant; Mailmodul auf Transport je Versand nach Mandant des Empfaengers, Startpfad entfernt; ldap/digest/matching auf Systemkontext; admin-seed nur dokumentiert), die fuenfte Erkennungsform des Detektors mit falsifizierbarer Erlaubnisliste, der Werkzeugabschnitt `runSystemContextChecks`, und der kohaerente Aktenstand. + +Purpose: Nach dem Scharfschalten darf kein Hintergrunddienst stumm die Arbeit einstellen, und schon heute darf kein Mandant den SMTP-Server oder den Postfach-Planer eines anderen benutzen. Der Schalter bleibt AUS; morgen (2026-09-15) geht alpha mit EINEM Mandanten live — jeder der sechs Pfade muss sich dort beobachtbar identisch zu heute verhalten, und das ist je Pfad ein Test, keine Zusicherung. + +Output: eine handgeschriebene Migration (lokal angewendet), der erweiterte Helfer samt Spec, sechs umgestellte Stellen samt Specs (zwei davon Funktionsausbau), der erweiterte Detektor, das erweiterte Wegwerf-Werkzeug, kohaerenter Aktenstand (Kritikschrift, Klassifikation, Auftrag, Datenbankrolle, Ledger), drei Commits, gepusht. + + + +@~/.claude/gsd-core/workflows/execute-plan.md +@~/.claude/gsd-core/templates/summary.md + + + +@docs/mandantentrennung-etappe3-auftrag.md +@apps/api/src/prisma/prisma-tenant.extension.ts +@apps/api/prisma/migrations/20260911120000_rls_user_dimension_personal_tables/migration.sql +@apps/api/src/prisma/rls-access-inventory.spec.ts +@apps/api/scripts/rls-scratch-check.mjs +@apps/api/src/dkv/dkv-scheduler.service.ts +@apps/api/src/dkv/dkv-mail.service.ts +@apps/api/src/mail/mail.module.ts +@apps/api/src/mail/mail.service.ts +@docs/mandantentrennung-zugriffsklassifikation.md +@.planning/quick/260911-nke-mandantentrennung-etappe-3b-benutzerdime/260911-nke-PLAN.md +@./CLAUDE.md + +**Zur Planungszeit gemessen (2026-09-14, HEAD 5e0e408, Arbeitsbaum sauber) — vom Executor gegen den lebenden Baum ERNEUT zu pruefen, bevor er es als Bearbeitungsgrundlage nimmt (#3786). Zeilennummern sind Orientierung, keine Bearbeitungsgrundlage.** + +- Datenbank: Container `tessera-ctl-db-1` war beim Einstieg 33 Stunden `Exited` (nur `gitea-db` lief); der Planer hat ihn mit `docker start tessera-ctl-db-1` gestartet (lokaler Dev-Container, kein Server). IP per `docker inspect` 172.19.0.2, Rolle `tessera:tessera_dev`, Binary `apps/api/node_modules/.bin/prisma` (NICHT `npx prisma`). `migrate status` = up to date, 35 Migrationen angewendet. `pg_proc`: `current_user_id` 1, `is_system_context` 0. `pg_policies` (public): 29 Regeln, 0 mal `system_read_policy`; auf DkvModuleConfig, LdapConfig, LdapFieldMapping, SmtpConfig, TenderMatch, TenderSavedSearch je GENAU EINE `tenant_isolation_policy`, `cmd = ALL`, PERMISSIVE. Rollen: `tessera` rolsuper+rolbypassrls, `tessera_app` weder noch. Zeilen: Tenant 1, DkvModuleConfig 1, SmtpConfig 0, LdapConfig 0, TenderMatch 0, TenderSavedSearch 0. `Tenant` und `Tender` tragen in KEINER Migration `ENABLE ROW LEVEL SECURITY`. +- Baseline frisch gemessen: `npm --prefix apps/api run test` -> `Test Files 62 passed (62)`, `Tests 1028 passed (1028)`; `npm --prefix apps/api run type-check` Exit 0; `TESSERA_SCRATCH_ADMIN_URL="postgresql://tessera:tessera_dev@172.19.0.2:5432/postgres" node apps/api/scripts/rls-scratch-check.mjs` -> `Alle 203 Pruefungen bestanden.` Kein `mailhog` lokal: `ENOTFOUND mailhog` in Testausgaben ist Umgebung. +- Live-Probe der Regelsemantik (Wegwerf-Datenbank, Rolle ohne BYPASSRLS, TenderSavedSearch mit `tenant_isolation_policy` ALL + `system_read_policy` FOR SELECT, generierter Client): (a) ungebunden 0 Zeilen; (b) forSystem beide Mandanten; (c) INSERT -> `PrismaClientUnknownRequestError`, SQLSTATE 42501 `new row violates row-level security policy`; `updateMany` count 0; `deleteMany` count 0; `update` per id -> `PrismaClientKnownRequestError` P2025; (d) forTenant(A) unmittelbar nach forSystem auf DEMSELBEN Client -> nur A; `is_system_context()` innerhalb der forTenant-Transaktion `false` (Rohwert `''`); in einer Transaktion mit `set_config('app.system_context','true',true)` -> true, danach ausserhalb -> false; (e) `pg_policies` unter der Wegwerf-Rolle lesbar: `system_read_policy`/`SELECT`, `qual = is_system_context()`, `with_check = null`. +- Helfer `prisma-tenant.extension.ts` (192 Zeilen): `forTenant` ab Zeile 162 (EINE getaggte Anweisung mit zwei `set_config`, `$transaction([setContext, query(args)])`), `withTenantTransaction` ab 184 (setzt nur `app.current_tenant` direkt auf `tx`). Spec `prisma-tenant.extension.spec.ts` (11 Tests) prueft u. a. 'genau zwei Eintraege' (51), Leerstring an zweiter Parameterstelle (138) — bleibt gueltig, wenn `app.system_context` als LITERAL `''` im Template-Text steht, nicht als Parameter. +- Detektor `rls-access-inventory.spec.ts` (914 Zeilen, 24 Tests): Zuweisungsform Zeile 370 (`/const\s+(\w+)\s*=\s*forTenant\(/g`), `STAND_TOKENS` 112, `FileAnalysis` 115, `scanRelationKeys(region, ctx, isBound, unbound, bound)` 298, Anker-Empfaenger `allReceiverNames`/`boundReceiverNames` 461–467, `computeStandByKey` 560, Doc-Zeilenmuster 594 (Stand `[a-z-]+` — `system-gebunden` parst), Test 'einer der drei gueltigen Stand-Werte' 637. Ausnahmelisten-Muster mit Veraltet-Wachhund: 82/94/110 und Tests 681/729. +- Die sechs Faelle, gemessen: (1) `dkv.service.ts:148` `loadAnyActiveConfigForScheduler()` = `this.prisma.dkvModuleConfig.findFirst({ select: CONFIG_SAFE_SELECT })` (nur skalare Felder); Aufrufer `dkv-scheduler.service.ts:75` und `dkv.service.spec.ts` Tests 6/7 (417, 435); `dkv-scheduler.service.ts`: `JOB_NAME = 'dkv-inbox-poll'` (60), Feld `activeTenantId` (66), `setInterval(intervalMin, tenantId?)` (108), `stopJob()` (161); `dkv.controller.ts:84` `setInterval(dto.pollIntervalMin, tenantId)`, `:86` `stopJob()`. Der Auftragsname steht ausserhalb der Datei nur in `docs/mandantentrennung-etappe2-fehlerrichtung.md:901`. `dkv.service.ts:337–346`: Single-Flight-Riegel `this.processing` ist EIN Boolean fuer den ganzen Prozess, nicht je Mandant. `cron` 4.4.0 liegt in `apps/api/node_modules/cron` (`fireOnTick()`, `cronTime.source`, `isActive` vorhanden — im Node-REPL bestaetigt). (2) `settings.service.ts:240` `loadAnySmtpConfigForStartupTransport()` = `this.prisma.smtpConfig.findFirst()`; einziger Aufrufer `mail.module.ts:38` (Mailer-Fabrik); Spec-Block `settings.service.spec.ts:419–498` (vier Tests). `MailService` (134 Zeilen) hat GENAU EINEN produktiven Aufrufer: `auth.service.ts:246` `sendPasswordResetEmail(email, token)` — der Mandant ist dort bekannt (`:241` `forTenant(this.prisma, user.tenantId)`); `sendWelcomeEmail` hat NULL Aufrufer. Vorlage fuer Transport je Versand: `dkv-mail.service.ts` (`getDecryptedSmtpConfig(tenantId)`, `nodemailer.createTransport`, `transport.close()` im `finally`). `auth.service.spec.ts:372/390` mockt `sendPasswordResetEmail` und prueft die Argumente. (3) `ldap-config.service.ts`: `this.prisma.ldapConfig.findMany` Zeile 66 (Nachverschluesselung, `select: { id, tenantId, encryptedBindPassword }`), `this.prisma.ldapConfig.update` Zeile 78 (je Altzeile — ein SCHREIBZUGRIFF, der unter Systemkontext scheitern wuerde), `this.prisma.ldapConfig.findMany` Zeile 309 (`getAllActiveConfigs`, `include: { tenant: true, fieldMappings: true }`); Aufrufer `ldap-sync.scheduler.ts:31`; Spec-Tests 301/306 sichern 'forTenant nicht aufgerufen'. (4) `tender-digest.scheduler.ts:121` `this.prisma.tenderMatch.findMany({ where: { notifiedAt: null }, select: { userId, tenantId }, distinct: ['userId'] })`; Spec 364 sichert 'forTenant genau einmal'. (5) `tender-matching.service.ts:71` `this.prisma.tenderSavedSearch.findMany()`; `:84` `this.prisma.tender.findMany` (D-03, bleibt); Spec 433 sichert 'forTenant genau einmal'. (6) `admin-seed.service.ts:172` `this.prisma.tenant.findMany({ select: { id, slug } })` — `Tenant` ohne Regel; ausserhalb der Schleife KEIN geschuetzter Lesezugriff -> nur dokumentieren. +- Spec-Dateien, die die Erweiterung mocken und deren Prueflinge kuenftig `forSystem` importieren (der Mock-Fabrik muss `forSystem` hinzugefuegt werden, sonst wirft vitest beim Zugriff 'No "forSystem" export is defined on the mock'): `dkv.service.spec.ts:22`, `settings.service.spec.ts:19` (nur solange settings `forSystem` braeuchte — nach Loeschung des Startpfads NICHT noetig), `ldap-config.service.spec.ts:9`, `tender-digest.scheduler.spec.ts:30`, `tender-matching.service.spec.ts:25`, `tender-notifications.integration.spec.ts:12`. +- Werkzeug `rls-scratch-check.mjs` (5531 Zeilen): `setupScratchDatabase` 100–168 (tippt `current_tenant_id()`, SCHNEIDET `current_user_id()` aus der 3b-Migration, 148–166), `forTenantQuery` 192, `report` 180, `extractPolicySql` 441, `extractAllPolicySql` 478, `readRlsUserDimensionMigrationSql` 500, `extractCurrentUserIdFunctionSql` 509, `readRemainingTenantTablesMigrationSql` 682, `readRlsPoliciesMigrationSql` 428, `readSchemaModelScalarFieldNames` 2992, `sqlStateOf` 1862, `runSingleRulePersonalTableCheck` 4607 (Muster: DROP/CREATE, Spaltenvergleich, generierter Client, 42501), `buildInlineExtendedClient` 5281, `main` 5483–5526 (Reihenfolge; `runUserDimensionChecks` vor `runConcurrencyProbe`). Vorhandene Wegwerf-Tabellen: `SmtpConfig` 4375 und `TenderSavedSearch` 4924 VOLLSTAENDIG; `LdapConfig` 544, `LdapFieldMapping` 551, `DkvModuleConfig` 1608 UNVOLLSTAENDIG (Roh-SQL-Abschnitte); `TenderMatch` hat KEINE. Die Wegwerf-Tabellen des neuen Abschnitts sind deshalb vollstaendig NEU aus schema.prisma zu bauen (DkvModuleConfig 16 skalare Felder inkl. `encryptedInboxCreds`, LdapConfig 15 inkl. `groupFilterDns text[]`/`userExcludeList text[]`, LdapFieldMapping 6, TenderMatch 8, TenderSavedSearch 8). +- Regelquellen fuer die Mandantenregeln der fuenf Tabellen (WORTGLEICH schneiden): DkvModuleConfig und TenderMatch aus `20260909140000_rls_remaining_tenant_tables`; LdapConfig und LdapFieldMapping aus `20260618112133_rls_policies`; TenderSavedSearch aus `20260911120000_rls_user_dimension_personal_tables`. +- `rls-coverage.spec.ts`: Test 2 verlangt 'mindestens eine Policy' je RLS-Tabelle, Test 5 zaehlt nur in 20260909140000 — beide bleiben durch eine zweite Regel je Tabelle unberuehrt. `rls-preflight.mjs` (`getPolicyTables` per `SELECT DISTINCT tablename FROM pg_policies`, `ohne-kontext-leer` zaehlt ohne jede Variable) bleibt gueltig, weil `is_system_context()` ohne Variable false ist — die Datei wird NICHT angefasst. +- Klassifikation `docs/mandantentrennung-zugriffsklassifikation.md` (727 Zeilen): Uebersichtstabelle ab 143 (Spalten Ungebunden/Gebunden, Summe 68/178, Zeilen tenders 35/27, ldap 4/26, dkv 1/22, settings 1/3), Klassen-Verteilung ab 169 (72 Paare, 35/21/14/2), Hintergrunddienst-Abschnitt ab 298 (Ueberschrift '## Der Hintergrunddienst als Falle — sechs Fälle' bleibt), Bestandsaufnahme-Header 532–560 (nennt drei Stand-Werte und vier Erkennungsformen), betroffene Zeilen 590 (dkv/dkvModuleConfig gemischt), 605/606/607 (ldap-config ldapConfig gemischt, ldapFieldMapping gemischt, tenant ungebunden), 619 (settings/smtpConfig gemischt), 628 (digest/tenderMatch gemischt), 638 (matching/tenderSavedSearch ungebunden), 649 (admin-seed/tenant ungebunden — bleibt, Begruendung Nachtrag). Abgeleitete Erwartung NACH Aufgabe 2 (Executor rechnet mit der Schleife nach): Rohtreffer ungebunden 68 -> 61 (ldap −3, dkv −1, settings −1, tenders −2), gebunden 178 -> 179 (ldap +1), neue Spalte System 5 (dkv 1, ldap 2, tenders 2); Paare 72 und Klassen unveraendert. +- Kritikschrift `docs/mandantentrennung-etappe2-fehlerrichtung.md` (3360 Zeilen): (d4) ab 896 (nennt `dkv-inbox-poll` in 901), (s4) ab 3025, (b4) ab 3201 (erster Punkt 'Systemkontext fuer Hintergrunddienste (Etappe 3c)'), '## Etappe 2 — Abschluss' ab 3229, '## Verweis' 3356; Form-Vorlage '## Regelschluss Benutzerdimension (Etappe 3b, 260911-nke)' 3117–3228 mit (b1)–(b5). `docs/mandantentrennung-datenbankrolle.md` (220 Zeilen): Aufzaehlung 'was gebaut wurde' 55–88 (3b-Punkt ab 70), Absatz 'Diese Stellen brauchen einen ausdruecklichen, benannten Systemkontext' ab 131, Vorher-Pruefung Abschnitt 5 ab 176. `docs/mandantentrennung-etappe3-auftrag.md`: Erledigt-Form fuer 3b in 41–52, 3c-Abschnitt 143–165. +- Ledger `.planning/WINDOWS.md`: Frontmatter open 16 / waived 1 / fixed 19 / total 36; #21 Zeile 38 (`open`), #30 Zeile 47 (`open`). Signaturen: `node /home/vicolab/.claude/gsd-core/bin/gsd-tools.cjs windows fixed ` und `windows append --kind deviation --phase quick-260914-eym --file --description ""`. +- Planer-Beitraege geprueft: `api-coverage` Detektor auf dem Umfangstext -> `detected: false` (keine neue externe API; nodemailer und SMTP sind Bestand) — keine COVERAGE-Matrix noetig. `assumption-delta` Detektor -> `detected: false` (englisches Vokabular greift auf den deutschen Umfang nicht), INHALTLICH liegt aber eine Einzahl-zu-Mehrzahl-Wende vor (ein Cron-Auftrag -> einer je Mandant; ein Start-Transport -> einer je Versand) — Entscheidung unten im Block `` festgehalten. `schema-gate` FEUERT (neue Prisma-Migration): Aufgabe 1 traegt den [BLOCKING]-Schritt `migrate deploy` gegen die lokale Datenbank, danach `migrate status` sauber und `pg_policies` gelesen. `security`: `` unten, ASVS Level 1, Schwelle high. +- Testkommandos: `npm --prefix apps/api run test` (Zusammenfassungszeilen `Test Files N passed (N)` / `Tests N passed (N)`), `npm --prefix apps/api run type-check`. Werkzeug: `TESSERA_SCRATCH_ADMIN_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/postgres" node apps/api/scripts/rls-scratch-check.mjs`, Schlusszeile `Alle N Pruefungen bestanden.`; Zielzahlen abgeleitet: 203 + 4 Funktionsfaelle + 9 (DkvModuleConfig) = 216 nach Aufgabe 1; + 4 x 9 + 1 (ldapConfig->fieldMappings) = 253 nach Aufgabe 2 (Gate >= 250, tatsaechliche Zahl in die SUMMARY). + + + +Nomen, das primaer wird: der **Mandant als Auftragsidentitaet**. Vorher war der DKV-Planer EIN Auftrag mit einem Feld `activeTenantId` (Einzahl als Grundannahme, der Mandant ein Detail des einen Auftrags); der Mail-Transport war EIN Objekt beim Start. Entscheidung: **promote** — der Auftrag je Mandant (Registry-Name `dkv-inbox-poll:`) wird die primaere Form, das Einzahl-Feld verschwindet ersatzlos (nicht daneben behalten); der Transport je Versand wird die einzige Form, die Mailer-Fabrik beim Start verschwindet. Begruendung: 'add-alongside' (Einzelauftrag behalten, Mehrzahl daneben) haette zwei Wahrheiten ueber denselben Zustand erzeugt — genau die Form, die #21 heute falsch macht. Mit einem Mandanten ist die Mehrzahl-Form beobachtbar identisch (Gate in Aufgabe 1/2). Invariantentest angeboten und aufgenommen: `dkv-scheduler.service.spec.ts` 'zwei Mandanten -> zwei Auftraege, Aenderung des einen laesst den anderen unberuehrt'. + + + + + + Aufgabe 1: Der eine Pfad durch alle Schichten — Helfer, Migration, Werkzeug, Detektor, DKV-Planer je Mandant [BLOCKING: Migration lokal anwenden] + apps/api/prisma/migrations/20260914120000_rls_system_context_read/migration.sql, apps/api/src/prisma/prisma-tenant.extension.ts, apps/api/src/prisma/prisma-tenant.extension.spec.ts, apps/api/src/groups/migration-sql.spec.ts, apps/api/src/prisma/rls-access-inventory.spec.ts, apps/api/scripts/rls-scratch-check.mjs, apps/api/src/dkv/dkv.service.ts, apps/api/src/dkv/dkv.service.spec.ts, apps/api/src/dkv/dkv-scheduler.service.ts, apps/api/src/dkv/dkv-scheduler.service.spec.ts, apps/api/src/dkv/dkv.controller.ts, docs/mandantentrennung-zugriffsklassifikation.md + Container `tessera-ctl-db-1` laeuft (`docker ps` nennt ihn `Up`); sonst `docker start tessera-ctl-db-1`, IP frisch per `docker inspect` ermitteln — die Zahl 172.19.0.2 ist eine Beobachtung, keine Zusage. + + + +Vorab messen, nicht erinnern: `git rev-parse HEAD` (erwartet 5e0e408), `git status --short` leer, DB-IP per `docker inspect`, `migrate status` up to date (35 angewendet), `pg_proc` ohne `is_system_context`, Baseline-Testlauf mit Zahlen in die SUMMARY (erwartet 62/1028, Typpruefung 0, Werkzeug 203). + +**(1) Helfer** `apps/api/src/prisma/prisma-tenant.extension.ts`. Neue Funktion `export function forSystem(prisma: PrismaClient)` in exakt der Bauart von `forTenant()`: `$extends` mit `$allOperations`, EINE getaggte `$executeRaw`-Anweisung `SELECT set_config('app.system_context', 'true', true), set_config('app.current_tenant', '', true), set_config('app.current_user', '', true)` (alle drei Werte als Literale im Template-Text, keine Parameter — es fliesst nichts Variables ein), dann `$transaction([setContext, query(args)])` mit `results[1]`. In `forTenant()` die bestehende Anweisung um `, set_config('app.system_context', '', true)` als LITERAL erweitern (die Parameterliste bleibt `[tenantId, userId ?? '']`, damit die bestehenden Spec-Zusicherungen 51/138/159/181 unveraendert gelten); dasselbe Literal in `withTenantTransaction()` an dessen `tx.$executeRaw`-Anweisung anhaengen. Kopfkommentar um einen Abschnitt 'SYSTEMKONTEXT (Etappe 3c, 260914-eym)' ergaenzen: warum ein SCHWESTERHELFER statt eines vierten Parameters (Systemkontext ist eine EIGENE Klasse; der Detektor bekommt dafuer eine eigene Erkennungsform — die Umkehrung der 3b-Begruendung, ausdruecklich so benannt), warum alle drei Variablen in jeder Form gesetzt werden (kein Erben zwischen den Kontexten, `local=true` ist das erste Netz, der Reset das zweite), dass unter Systemkontext NUR gelesen werden kann (die Regel ist `FOR SELECT`; Schreiben scheitert an der Mandantenregel — gemessen 42501 / count 0 / P2025), und wer ihn rufen darf: ausschliesslich die in `FORSYSTEM_ALLOWED_CALL_SITES` (Detektor) genannten Stellen. + +**(2) Spec** `prisma-tenant.extension.spec.ts`: mindestens drei neue Tests im Stil der bestehenden — `forSystem()`: das Template nennt `app.system_context`, `app.current_tenant`, `app.current_user`, die Werte `'true'`, `''`, `''` stehen im Text, `values` ist leer, das `$transaction`-Feld hat genau zwei Eintraege; `forTenant()`: der Template-Text enthaelt `app.system_context` und setzt es auf den Leerstring; `withTenantTransaction()`: dito auf `tx`. + +**(3) Migration** `apps/api/prisma/migrations/20260914120000_rls_system_context_read/migration.sql`, Kopfform wie 20260911120000 (Kennung `260914-eym`, welche Dateien UNVERAENDERT bleiben und warum — Pruefsumme —, der Satz zum Schalter). Inhalt in dieser Reihenfolge: `CREATE OR REPLACE FUNCTION is_system_context() RETURNS BOOLEAN AS $$ SELECT COALESCE(current_setting('app.system_context', true) = 'true', false); $$ LANGUAGE sql STABLE;` mit Kommentar (COALESCE, weil `current_setting(..., true)` ohne Variable NULL liefert und NULL = 'true' NULL waere — die Regel muss dann FALSE sehen, nicht NULL; kein GRANT, Begruendung wie 3b). Dann fuer GENAU diese fuenf Tabellen, jede mit einem Satz, WELCHER Aufrufer sie ueber den Systemkontext liest: `CREATE POLICY system_read_policy ON "DkvModuleConfig" FOR SELECT USING (is_system_context());` (DkvService.loadActiveConfigsForScheduler), `"LdapConfig"` (LdapConfigService.getAllActiveConfigs und onApplicationBootstrap), `"LdapFieldMapping"` (dieselbe Methode ueber `include: { fieldMappings }` — WINDOWS-#27-Form), `"TenderMatch"` (TenderDigestScheduler.runDigest, Kandidatenabfrage), `"TenderSavedSearch"` (TenderMatchingService.matchDelta). Kein DROP POLICY, keine Aenderung an bestehenden Regeln — permissive Regeln werden ODER-verknuepft, deshalb erweitert `FOR SELECT` nur das Lesen. Abschluss 'Was diese Migration bewusst NICHT tut': kein `SmtpConfig` (der Startpfad des Mailmoduls wird in Aufgabe 2 ENTFERNT, nicht umgestellt — Transport je Versand nach Mandant), kein `Tenant`/`Tender` (keine Regel, nichts zu oeffnen), keine Schreibregel unter Systemkontext (Schreiben bleibt je Mandant), keine Aenderung am Schalter. + +**[BLOCKING] Migration lokal anwenden:** `cd apps/api && DATABASE_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/tessera" ./node_modules/.bin/prisma migrate deploy`, dann `migrate status` (up to date, 36 angewendet), dann `pg_proc` (`is_system_context` = 1) und `pg_policies` lesen: genau fuenf `system_read_policy`, alle `cmd = SELECT`, `permissive = PERMISSIVE`, `qual = is_system_context()`, `with_check` NULL; Gesamtzahl 34. Ausgabe woertlich in die SUMMARY. `schema.prisma`, Compose, `.env` bleiben unangetastet. + +**(4) Migrations-Spec** `apps/api/src/groups/migration-sql.spec.ts`: neuer describe-Block `rls_system_context_read migration.sql (Etappe 3c, 260914-eym)` nach dem 3b-Block, mit `readMigrationSql('_rls_system_context_read')` und `nonCommentLines`: Funktion mit COALESCE vorhanden; genau fuenf `CREATE POLICY system_read_policy`, je eine fuer die fuenf Tabellen; jede FOR SELECT; jede USING (is_system_context()); kein DROP POLICY; kein `SmtpConfig`/`Tenant`/`Tender` ausserhalb von Kommentaren; der Kopf nennt SmtpConfig als bewusst nicht enthalten. + +**(5) Werkzeug** `apps/api/scripts/rls-scratch-check.mjs`: +- Leser `readRlsSystemContextMigrationSql()` (Endung `_rls_system_context_read`, Muster `readRlsUserDimensionMigrationSql`), `extractIsSystemContextFunctionSql()` (Regex bis `LANGUAGE sql STABLE;`), `extractSystemReadPolicySql(migrationSql, tableName)` (Regex `CREATE POLICY system_read_policy ON ""[\s\S]*?;`). In `setupScratchDatabase` die Funktion SCHNEIDEN (Abbruch mit `fail(...)`, wenn Migration oder Funktion fehlt — nicht tippen) und `GRANT EXECUTE` an die Wegwerf-Rolle. +- `forTenantQuery` und `buildInlineExtendedClient` spiegelbildlich zum Helfer um das Literal `set_config('app.system_context', '', true)` erweitern; neue `forSystemQuery(prisma, queryFn)` und `buildInlineSystemClient(prisma)` spiegelbildlich zu `forSystem()`. +- Neuer Abschnitt `runSystemContextChecks(adminUrl, scratchRoleUrl, results)`, im Hauptlauf NACH `runUserDimensionChecks` und VOR `runConcurrencyProbe`. Zuerst vier Funktionsfaelle ueber die Wegwerf-Rolle in je einer Transaktion: `is-system-context-ungesetzt-false`, `is-system-context-leer-false` (nach `set_config('app.system_context','',true)`), `is-system-context-true-true`, `is-system-context-fremdwert-false` (Wert `'yes'`). Dann eine innere Routine `runSystemContextTableCheck(config)` (Muster `runSingleRulePersonalTableCheck`), in DIESER Aufgabe nur fuer `DkvModuleConfig` (slug `dkvmoduleconfig`, Mandantenregel aus `readRemainingTenantTablesMigrationSql()`, Systemregel aus der neuen Migration, Wegwerf-Tabelle mit ALLEN 16 skalaren Spalten, Spaltenvergleich `readSchemaModelScalarFieldNames('DkvModuleConfig')` als erste Pruefung mit Abbruch; Zeilen `cfg-a`/TENANT-A und `cfg-b`/TENANT-B ueber die Wartungsrolle). Neun Kennungen ueber den GENERIERTEN Client: `-wegwerftabelle-deckt-alle-spalten-des-generierten-clients`, `-ungebunden-null-zeilen` (findMany auf dem rohen Client, 0), `-systemkontext-sieht-beide-mandanten` (buildInlineSystemClient, tenantIds beider), `-systemkontext-insert-abgewiesen-42501` (create mit tenantId TENANT-A; erwartet `sqlStateOf(err) === '42501'`, jedes Gelingen und jeder andere Fehler FEHLGESCHLAGEN mit Meldung), `-systemkontext-updatemany-count-0`, `-systemkontext-deletemany-count-0`, `-fortenant-a-nach-systemkontext-nur-a` (buildInlineExtendedClient(prisma,'TENANT-A') auf DEMSELBEN `prisma` unmittelbar nach dem System-Aufruf; nur TENANT-A), `-is-system-context-unter-fortenant-false` (`$transaction([setContextA, $queryRaw SELECT is_system_context()])` -> false), `-pg-policies-genau-eine-system-read-policy-select` (unter der Wegwerf-Rolle `pg_policies` lesen: genau eine Zeile `system_read_policy` mit `cmd='SELECT'` fuer die Tabelle). Meldetexte nennen die gemessenen Werte. + +**(6) Detektor** `apps/api/src/prisma/rls-access-inventory.spec.ts` — fuenfte Erkennungsform: `FileAnalysis` um `systemModels: Set`, `totalForSystemCalls`, `systemAssignmentFormCalls`; in `analyzeSource` Zuweisungen `const = forSystem(` sammeln, `.` in `systemModels`, `totalForSystemCalls` per `/(? gemischt; nur ungebunden -> ungebunden; system ohne ungebunden -> system-gebunden; sonst gebunden); `findAccessSites` nimmt `systemModels` mit. Neue Konstante `FORSYSTEM_ALLOWED_CALL_SITES = new Map([['apps/api/src/dkv/dkv.service.ts', 1]])` mit Kopfkommentar (in Aufgabe 2 auf vier Dateien erweitert) und drei Tests: (i) jede Datei mit `totalForSystemCalls > 0` steht in der Map und die Zahl stimmt EXAKT (Fremddatei oder Abweichung -> rot, Meldung nennt Datei und beide Zahlen); (ii) veraltete Eintraege: jede Datei der Map existiert und hat genau die genannte Zahl (Muster 681); (iii) jedes `forSystem(` folgt der Zuweisungsform (`totalForSystemCalls === systemAssignmentFormCalls`, keine Ausnahmeliste — es gibt keinen begruendeten Fall). Testname 637 auf 'vier gueltigen Stand-Werte' und den Kopfkommentar der Datei fortschreiben. Drei Proben im describe der vierten Erkennung: Probe C (`const systemPrisma = forSystem(this.prisma)` + `systemPrisma.ldapConfig.findMany({ include: { fieldMappings: true } })` -> `systemModels` enthaelt `ldapConfig` UND `ldapFieldMapping`, beide weder in bound noch unbound, Stand `system-gebunden`), Probe D (dieselbe Datei mit einem `forTenant`-Zugriff auf dasselbe Modell -> Stand bleibt `system-gebunden`; mit einem `this.prisma`-Zugriff auf dasselbe Modell -> `gemischt`), Probe E (`forSystem(this.prisma).x.findMany()` ohne Zuweisung -> `totalForSystemCalls` 1, `systemAssignmentFormCalls` 0). + +**(7) DKV je Mandant.** `dkv.service.ts`: `loadAnyActiveConfigForScheduler()` durch `async loadActiveConfigsForScheduler()` ersetzen — `const systemPrisma = forSystem(this.prisma) as any; return systemPrisma.dkvModuleConfig.findMany({ where: { isActive: true }, select: CONFIG_SAFE_SELECT, orderBy: { tenantId: 'asc' } });` — Import `forSystem` ergaenzen, Kopfkommentar der Methode NEU (systemgebunden, warum viele statt einer, Verweis auf #21 als geschlossen, das Verstummen nach dem Scharfschalten ist damit strukturell ausgeschlossen, weil `system_read_policy` diese Tabelle oeffnet), den Klassen-Kopfkommentar ('Multi-tenant note (v1)') nachziehen. Der Single-Flight-Riegel `processing` bleibt unangetastet (Kommentar an Ort und Stelle: prozessweit, nicht je Mandant — Ledger-Eintrag in Aufgabe 3). `dkv-scheduler.service.ts`: Kopfkommentar neu (Auftrag je Mandant, Einmal-abfragen-viele-bedienen, Registry-Name `dkv-inbox-poll:`, was mit einem Mandanten identisch bleibt); `JOB_NAME_PREFIX = 'dkv-inbox-poll'`, private `jobNameFor(tenantId)`; Feld `activeTenantId` ERSATZLOS entfernen; `onModuleInit`: alle aktiven Configs laden, je Config `this.setInterval(config.pollIntervalMin, config.tenantId)`, bei leerer Liste weiterhin die Protokollzeile 'DKV scheduler: no active config found — cron job not registered', sonst 'DKV scheduler initialized: N tenant(s)'; Fehler weiter fangen und protokollieren; `setInterval(intervalMin: number, tenantId: string)` (tenantId PFLICHT) ersetzt/erzeugt nur den Auftrag dieses Mandanten (stop+delete unter dem Mandantennamen, Cron-Expression-Berechnung UNVERAENDERT, Tick `processInbox(tenantId)`), `stopJob(tenantId: string)` entfernt nur diesen; neue oeffentliche `registeredTenantIds(): string[]` aus `schedulerRegistry.getCronJobs()` gefiltert nach Praefix (fuer Tests und Diagnose). `dkv.controller.ts:86`: `stopJob(tenantId)`. NEUE `dkv-scheduler.service.spec.ts` mit Fake-Registry (Map-basiert: `addCronJob`, `getCronJob` wirft bei Unbekannt wie @nestjs/schedule, `deleteCronJob`, `getCronJobs`), Fake-DkvService (`loadActiveConfigsForScheduler`, `processInbox` als vi.fn), echtem `cron` (liegt unter apps/api/node_modules), `afterEach` stoppt jeden registrierten Auftrag — mindestens sechs Tests: (1) EIN aktiver Mandant, pollIntervalMin 15 -> genau ein Auftrag `dkv-inbox-poll:` mit `cronTime.source === '*/15 * * * *'` (Identitaet zu heute), (2) pollIntervalMin 120 -> `0 */2 * * *`, (3) `fireOnTick()` ruft `processInbox` genau mit dieser tenantId, (4) inaktive/keine Config -> kein Auftrag, Protokollzeile, (5) ZWEI Mandanten -> zwei Auftraege; `setInterval(30, t2)` ersetzt nur t2 (t1 behaelt seine Expression), `stopJob(t1)` entfernt nur t1 (Invariantentest aus dem assumption-delta-Block), (6) `loadActiveConfigsForScheduler` wirft -> Fehler gefangen, kein Auftrag, Start nicht blockiert. `dkv.service.spec.ts`: Mock-Fabrik um `forSystem: vi.fn((prisma) => prisma.__makeSystemClient())` ergaenzen, Fake um `__makeSystemClient()` mit `__systemCallLog` (Muster `__makeBoundClient`, `dkvModuleConfig.findMany` mit `where.isActive`-Filter ueber die Map); Test 6 umdrehen: der Planer-Startpfad erzeugt GENAU EINEN System-Aufruf (`dkvModuleConfig.findMany`) und KEINEN gebundenen; Test 7 bleibt (kein `encryptedInboxCreds` im Ergebnis) und prueft zusaetzlich: zwei aktive plus eine inaktive Config -> genau die zwei aktiven, nach tenantId sortiert. + +**(8) Klassifikation, nur die Zeile, die sonst die Inventar-Spec rot macht:** Bestandsaufnahme-Zeile `apps/api/src/dkv/dkv.service.ts | dkvModuleConfig` auf Stand `system-gebunden`, Begruendung fortschreiben (seit 260914-eym liest der Planer-Startpfad ueber `forSystem()`, alle uebrigen Zugriffe gebunden); im Bestandsaufnahme-Header die Stand-Werte um `system-gebunden` und die Erkennungsformen um die fuenfte ergaenzen (mit der Vorrangregel). Alles Weitere in Aufgabe 3. + +Baseline am Ende: Tests >= 1040 (1028 + mindestens 3 + 6 + 3 + 6 = 1046 erwartet; die Zahl 1040 laesst Spielraum fuer zusammengelegte Faelle), Typpruefung, Werkzeug `Alle N Pruefungen bestanden` mit N >= 216. Commit: `feat(quick-260914-eym): forSystem(), is_system_context(), Systemleseregel auf fuenf Tabellen, DKV-Planer je Mandant — ein Pfad (WINDOWS #21)`. `git status --short` danach leer. + + + cd /home/vicolab/projects/tessera-ctl && M=apps/api/prisma/migrations/20260914120000_rls_system_context_read/migration.sql && test -f "$M" && grep -q 'CREATE OR REPLACE FUNCTION is_system_context() RETURNS BOOLEAN' "$M" && grep -qF "COALESCE(current_setting('app.system_context', true) = 'true', false)" "$M" && for T in DkvModuleConfig LdapConfig LdapFieldMapping TenderMatch TenderSavedSearch; do grep -qF "CREATE POLICY system_read_policy ON \"$T\"" "$M" || { echo "FEHLT: Regel fuer $T"; exit 1; }; done && test 5 -eq "$(grep -v '^\s*--' "$M" | grep -c 'CREATE POLICY system_read_policy')" && test 5 -eq "$(grep -v '^\s*--' "$M" | grep -c 'FOR SELECT')" && test 0 -eq "$(grep -v '^\s*--' "$M" | grep -c 'DROP POLICY')" && test 0 -eq "$(grep -v '^\s*--' "$M" | grep -c 'SmtpConfig')" && test -z "$(git diff --name-only 5e0e408 -- apps/api/prisma/schema.prisma docker-compose.yml docker-compose.prod.yml docker-compose.dev.yml apps/api/package.json pnpm-lock.yaml apps/api/scripts/rls-preflight.mjs apps/api/src/user/admin-seed.service.ts)" && CH=$(git diff --name-only 5e0e408) || { echo "git diff fehlgeschlagen"; exit 1; } && test 0 -eq "$(printf '%s\n' "$CH" | grep -c '^\.env')" && E=apps/api/src/prisma/prisma-tenant.extension.ts && grep -q 'export function forSystem(prisma: PrismaClient)' "$E" && grep -qF "set_config('app.system_context', 'true', true)" "$E" && test 2 -eq "$(grep -cF "set_config('app.system_context', '', true)" "$E")" && test 1 -eq "$(grep -c 'const systemPrisma = forSystem(this.prisma)' apps/api/src/dkv/dkv.service.ts)" && test 0 -eq "$(cat 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.service.spec.ts | grep -c 'loadAnyActiveConfigForScheduler')" && test 0 -eq "$(grep -v '^\s*//\|^\s*\*' apps/api/src/dkv/dkv-scheduler.service.ts | grep -c 'activeTenantId')" && grep -q 'stopJob(tenantId)' apps/api/src/dkv/dkv.controller.ts && grep -q 'registeredTenantIds' apps/api/src/dkv/dkv-scheduler.service.ts && test -f apps/api/src/dkv/dkv-scheduler.service.spec.ts && test 6 -le "$(grep -c '^\s*it(' apps/api/src/dkv/dkv-scheduler.service.spec.ts)" && S=apps/api/src/prisma/rls-access-inventory.spec.ts && grep -q 'FORSYSTEM_ALLOWED_CALL_SITES' "$S" && grep -qF 'forSystem\(' "$S" && grep -q "'system-gebunden'" "$S" && grep -q 'systemModels' "$S" && grep -q 'runSystemContextChecks' apps/api/scripts/rls-scratch-check.mjs && grep -q 'extractSystemReadPolicySql' apps/api/scripts/rls-scratch-check.mjs && grep -q 'buildInlineSystemClient' apps/api/scripts/rls-scratch-check.mjs && grep -q '_rls_system_context_read' apps/api/src/groups/migration-sql.spec.ts && grep -qE '^\| apps/api/src/dkv/dkv.service.ts \| dkvModuleConfig \| muss-mandantengebunden \| system-gebunden \|' docs/mandantentrennung-zugriffsklassifikation.md && DB_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1) && test -n "$DB_IP" && (cd apps/api && DATABASE_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/tessera" ./node_modules/.bin/prisma migrate status 2>&1 | grep -q 'Database schema is up to date') && (cd apps/api && DATABASE_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/tessera" node -e "const {PrismaClient}=require('@prisma/client');const p=new PrismaClient();(async()=>{const f=(await p.\$queryRawUnsafe(\"SELECT count(*)::int AS c FROM pg_proc WHERE proname='is_system_context'\"))[0].c;const r=await p.\$queryRawUnsafe(\"SELECT tablename, cmd, permissive, qual, with_check FROM pg_policies WHERE policyname='system_read_policy' ORDER BY tablename\");const ok=f===1&&r.length===5&&r.every(x=>x.cmd==='SELECT'&&x.permissive==='PERMISSIVE'&&x.qual==='is_system_context()'&&x.with_check===null)&&r.map(x=>x.tablename).join(',')==='DkvModuleConfig,LdapConfig,LdapFieldMapping,TenderMatch,TenderSavedSearch';const n=(await p.\$queryRawUnsafe(\"SELECT count(*)::int AS c FROM pg_policies WHERE schemaname='public'\"))[0].c;console.log('pg_proc',f,'system_read_policy',r.length,'policies',n);await p.\$disconnect();process.exit(ok&&n===34?0:1)})()") && T=$(npm --prefix apps/api run test 2>&1 | grep -oE 'Tests [0-9]+ passed' | grep -oE '[0-9]+') && echo "TESTS=$T" && test "$T" -ge 1040 && npm --prefix apps/api run type-check >/dev/null 2>&1 && N=$(TESSERA_SCRATCH_ADMIN_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/postgres" node apps/api/scripts/rls-scratch-check.mjs 2>&1 | tee /tmp/claude-1000/-home-vicolab-projects-tessera-ctl/d06a4a73-407a-4ea6-b686-d1f72c209902/scratchpad/eym-t1-scratch.log | grep -oE 'Alle [0-9]+ Pruefungen bestanden' | grep -oE '[0-9]+') && echo "WERKZEUG=$N" && test "$N" -ge 216 && for K in is-system-context-ungesetzt-false is-system-context-leer-false is-system-context-true-true is-system-context-fremdwert-false dkvmoduleconfig-wegwerftabelle-deckt-alle-spalten-des-generierten-clients dkvmoduleconfig-ungebunden-null-zeilen dkvmoduleconfig-systemkontext-sieht-beide-mandanten dkvmoduleconfig-systemkontext-insert-abgewiesen-42501 dkvmoduleconfig-systemkontext-updatemany-count-0 dkvmoduleconfig-systemkontext-deletemany-count-0 dkvmoduleconfig-fortenant-a-nach-systemkontext-nur-a dkvmoduleconfig-is-system-context-unter-fortenant-false dkvmoduleconfig-pg-policies-genau-eine-system-read-policy-select; do grep -q "^${K}: bestanden" /tmp/claude-1000/-home-vicolab-projects-tessera-ctl/d06a4a73-407a-4ea6-b686-d1f72c209902/scratchpad/eym-t1-scratch.log || { echo "KENNUNG FEHLT ODER ROT: $K"; exit 1; }; done && test -z "$(git status --short)" && echo AUFGABE-1-OK + + Migration existiert, ist lokal angewendet (`pg_proc` kennt `is_system_context`, `pg_policies` zeigt genau fuenf `system_read_policy`/SELECT/PERMISSIVE auf den fuenf Tabellen, 34 Regeln gesamt), `schema.prisma`/Compose/.env/package.json/Lockfile/preflight/admin-seed unveraendert; `forSystem()` existiert, `forTenant()` und `withTenantTransaction()` setzen `app.system_context` auf den Leerstring; der DKV-Planer fuehrt einen Auftrag je aktivem Mandanten und verhaelt sich mit einem Mandanten identisch (Cron-Expression, Tick, Protokollzeile — sechs Tests); der Detektor kennt die fuenfte Form mit exakter Erlaubnisliste und vier Stand-Werten; das Werkzeug misst die vier Funktionsfaelle und die neun Wahrheiten fuer DkvModuleConfig; Tests >= 1040, Typpruefung sauber, Werkzeug N >= 216; committet, `git status` leer. + + + + Aufgabe 2: Die uebrigen Faelle — Mail je Versand (WINDOWS #30), ldap, digest, matching; vier weitere Tabellen im Werkzeug; Falsifizierung durch Rueckbau + apps/api/src/mail/mail.module.ts, apps/api/src/mail/mail.service.ts, apps/api/src/mail/mail.service.spec.ts, apps/api/src/settings/settings.service.ts, apps/api/src/settings/settings.service.spec.ts, apps/api/src/auth/auth.service.ts, apps/api/src/auth/auth.service.spec.ts, apps/api/src/ldap/ldap-config.service.ts, apps/api/src/ldap/ldap-config.service.spec.ts, apps/api/src/tenders/tender-digest.scheduler.ts, apps/api/src/tenders/tender-digest.scheduler.spec.ts, apps/api/src/tenders/tender-matching.service.ts, apps/api/src/tenders/tender-matching.service.spec.ts, apps/api/src/tenders/tender-notifications.integration.spec.ts, apps/api/scripts/rls-scratch-check.mjs, apps/api/src/prisma/rls-access-inventory.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md + Aufgabe 1 committet, Arbeitsbaum sauber, Container `tessera-ctl-db-1` laeuft. + + + + + + + +**(1) Mail — Transport je Versand nach Mandant des Empfaengers (vollstaendige Form, WINDOWS #30).** Entscheidung mit Messung: der Mandant ist an der EINZIGEN Versandstelle bekannt (`auth.service.ts` nutzt `user.tenantId` eine Zeile vor dem Versand), `sendWelcomeEmail` hat null Aufrufer, die Vorlage (`dkv-mail.service.ts`) ist 100 Zeilen — die Mindestform (`findFirst()` ueber den Systemkontext) haette #30 nur halb geschlossen (nicht mehr stumm, aber weiter der SMTP-Server EINES beliebigen Mandanten fuer alle, T-GWH-03). Deshalb: +- `mail.service.ts`: Konstruktor bekommt `SettingsService` und `ConfigService` (die Mailer-Abstraktion faellt weg). Private `resolveTransport(tenantId: string): Promise<{ source: 'tenant' | 'env'; options: nodemailer.TransportOptions-artig; from: string }>` — `source: 'tenant'` aus `settingsService.getDecryptedSmtpConfig(tenantId)` (host/port/`secure: encryption === 'ssl-tls'`/`requireTLS: encryption === 'starttls'`/auth nur mit username, `from: fromAddress`; entschluesseltes Kennwort nur hier, nie protokolliert — T-07-11/T-07-10), sonst `source: 'env'` mit der BISHERIGEN Kette aus `mail.module.ts` unveraendert uebernommen (MAIL_HOST/MAIL_PORT/MAIL_USER/MAIL_PASS, dann TESSERA_SMTP_HOST/PORT/USER/PASSWORD, dann `localhost`/1025, `from` aus TESSERA_SMTP_FROM oder `Tessera `, `secure` aus TESSERA_SMTP_SECURE). Private `sendViaTenantTransport(tenantId, { to, subject, text })`: `nodemailer.createTransport(options)`, `sendMail({ from, to, subject, text })`, Protokollzeile nennt `source` (tenant/env) und Empfaenger, Fehler wie bisher verschluckt und protokolliert (T-02-12, Anmeldeweg antwortet weiter 200), `transport.close()` im `finally` (WR-01). `sendPasswordResetEmail(email, token, tenantId, locale = 'de')` und `sendWelcomeEmail(email, username, tenantId, locale = 'de')` rufen beide diesen einen Pfad — Texte unveraendert. Kopfkommentar: warum je Versand (Pitfall 3 — ein Start-Transport kann nicht wechseln — UND Mandantentrennung: der Mandant des Empfaengers entscheidet, nie ein beliebiger), warum die Umgebungs-Kette nur der Rueckfall fuer Mandanten OHNE Konfiguration ist, was mit einem Mandanten identisch bleibt. +- `mail.module.ts`: die Mailer-Fabrik (`forRootAsync`) und der DB-Startpfad entfallen ersatzlos; `imports: [SettingsModule]`, `providers: [MailService]`, `exports: [MailService]`; Modulkommentar neu (kein Startpfad mehr, Prioritaeten je Versand: 1 SmtpConfig des Empfaenger-Mandanten, 2 MAIL_*, 3 TESSERA_SMTP_*, 4 localhost:1025; WINDOWS #30 geschlossen, sechster Fall der Hintergrunddienst-Falle EXISTIERT NICHT MEHR — deshalb keine `system_read_policy` auf SmtpConfig). `@nestjs-modules/mailer` bleibt in `package.json` (kein Lockfile-Eingriff in diesem Durchlauf; in der SUMMARY als nun unbenutzte Abhaengigkeit benennen). +- `settings.service.ts`: `loadAnySmtpConfigForStartupTransport()` samt Kopfkommentar LOESCHEN; im Kopfkommentar von `getDecryptedSmtpConfig` den Satz 'der einzige Versandpfad' um `MailService` (seit 260914-eym) ergaenzen. `settings.service.spec.ts`: den describe-Block des Startpfads (vier Tests) entfernen; der 'Wachhund je Anfrageweg' bleibt. +- `auth.service.ts`: `sendPasswordResetEmail(email, token, user.tenantId)`. `auth.service.spec.ts`: die Zusicherung auf drei Argumente (`'bob@example.com', expect.any(String), 't1'` — die tenantId der Testzeile `emailUser` pruefen, nicht raten). +- NEUE `mail.service.spec.ts` (nodemailer per `vi.mock` wie in `settings.service.spec.ts`, `createTransport` liefert `{ sendMail, close }`), mindestens vier Tests: (1) Mandant MIT SmtpConfig -> `getDecryptedSmtpConfig` genau einmal mit dieser tenantId, `createTransport` mit deren host/port/secure/requireTLS/auth, `from` = deren fromAddress, `close()` gerufen — Identitaet zu heute fuer den einen Mandanten; (2) Mandant OHNE SmtpConfig -> Umgebungs-Kette (MAIL_HOST vor TESSERA_SMTP_HOST vor localhost:1025 — drei ConfigService-Faelle in einem Test oder dreien), `from` aus TESSERA_SMTP_FROM bzw. Vorgabe; (3) zwei Mandanten nacheinander -> zwei verschiedene Transporte, keiner sieht die Zugangsdaten des anderen (T-GWH-03 geschlossen); (4) `sendMail` wirft -> kein Throw nach aussen, Fehler protokolliert, `close()` trotzdem gerufen. + +**(2) ldap** `ldap-config.service.ts`: Import `forSystem`. `getAllActiveConfigs()`: `const systemPrisma = forSystem(this.prisma) as any;` und `systemPrisma.ldapConfig.findMany({ where: { isActive: true }, include: { tenant: true, fieldMappings: true } })` — Kopfkommentar neu (systemgebunden; `LdapFieldMapping` ueber `include` mit abgedeckt, WINDOWS-#27-Form; `Tenant` ohne Regel; Verstummen nach dem Scharfschalten strukturell ausgeschlossen). `onApplicationBootstrap()`: das Lesen ueber `const systemPrisma = forSystem(this.prisma) as any;` (zweite Zuweisung, eigene Methode — der Detektor zaehlt zwei), die Schreibzeile je Altzeile ueber `const tenantPrisma = forTenant(this.prisma, config.tenantId) as any; await tenantPrisma.ldapConfig.update(...)` — Kommentar: unter Systemkontext ist Schreiben abgewiesen (gemessen P2025/42501), der Mandant steht in der gelesenen Zeile. `ldap-config.service.spec.ts`: Mock-Fabrik um `forSystem: vi.fn((p) => p.__systemClient ?? p)` ergaenzen (oder ein zweites unterscheidbares Objekt wie im Bindungsblock); Tests 301/306 UMDREHEN statt loeschen: `getAllActiveConfigs()` ruft `forSystem` genau einmal und `forTenant` nie; `onApplicationBootstrap()` mit leerer Liste ruft `forSystem` einmal, `forTenant` nie; NEU: mit einer Altzeile (Klartext-Kennwort, tenantId `t1`) ruft `forTenant` genau einmal mit `t1` und `update` traegt das verschluesselte Kennwort. + +**(3) digest** `tender-digest.scheduler.ts`: Import `forSystem`; die Kandidatenabfrage ueber `const systemPrisma = forSystem(this.prisma) as any;` — Kommentar (Etappe-3c-Uebergabe eingeloest: Systemkontext liest TenderMatch aller Mandanten NUR lesend; die Schleife bleibt je Kandidatenzeile gebunden, bewusst ohne Benutzer wie in 3b; der Sonderfall 'Nutzer mit Treffern unter zwei Mandanten' bleibt wie in (t4) beschrieben ungeloest). `tender-digest.scheduler.spec.ts`: Mock um `forSystem` (Fake `__makeSystemClient()` mit `__systemCallLog`, `tenderMatch.findMany` unveraendert), Test 364 bleibt (forTenant genau einmal), NEU: die Kandidatenabfrage laeuft ueber den System-Klienten (`__systemCallLog` enthaelt genau `tenderMatch.findMany`, nichts aus der Schleife). + +**(4) matching** `tender-matching.service.ts`: Import `forSystem`; `tenderSavedSearch.findMany()` ueber `const systemPrisma = forSystem(this.prisma) as any;` — Kommentar; `this.prisma.tender.findMany` (D-03) BLEIBT ungebunden mit unveraendertem Kommentar. `tender-matching.service.spec.ts`: Mock um `forSystem`, Test 433 bleibt (forTenant genau einmal, Katalog ungebunden), NEU: Profilabfrage ueber den System-Klienten, Katalogabfrage NICHT (weiter roher Client). `tender-notifications.integration.spec.ts`: Mock um `forSystem` (Fake nach demselben Muster), bestehende zwei Tests gruen. + +**(5) Werkzeug**: `runSystemContextChecks` um vier Tabellen ueber die innere Routine erweitern — `ldapconfig` (Mandantenregel aus `readRlsPoliciesMigrationSql()`; Wegwerf-Tabelle mit allen 15 skalaren Spalten, `groupFilterDns text[] NOT NULL DEFAULT '{}'`, `userExcludeList text[] NOT NULL DEFAULT '{}'`), `ldapfieldmapping` (Regel aus derselben Datei — Join auf LdapConfig, deshalb NACH ldapconfig und ohne DROP der Elternzeile; 6 Spalten; Seed je Mandant eine Zuordnung an `cfg-a`/`cfg-b`), `tendermatch` (Regel aus `readRemainingTenantTablesMigrationSql()`; 8 Spalten; Seed je Mandant eine Zeile mit `notifiedAt` NULL), `tendersavedsearch` (Regel aus `readRlsUserDimensionMigrationSql()`; 8 Spalten). Je Tabelle dieselben neun Kennungen. PLUS eine Relations-Kennung `ldapconfig-systemkontext-include-fieldmappings-beider-mandanten`: `buildInlineSystemClient(prisma).ldapConfig.findMany({ where: { isActive: true }, include: { fieldMappings: true } })` liefert beide Mandanten und je genau eine Zuordnung (die #27-Form unter Systemkontext). Erwartete Schlusszeile: `Alle 253 Pruefungen bestanden.` (Gate >= 250; die tatsaechliche Zahl in die SUMMARY). + +**(6) Detektor**: `FORSYSTEM_ALLOWED_CALL_SITES` auf `[['apps/api/src/dkv/dkv.service.ts', 1], ['apps/api/src/ldap/ldap-config.service.ts', 2], ['apps/api/src/tenders/tender-digest.scheduler.ts', 1], ['apps/api/src/tenders/tender-matching.service.ts', 1]]` — Summe 5; Kopfkommentar nennt die sechs Faelle und warum settings/mail und admin-seed NICHT darin stehen. + +**(7) Klassifikation, nur die Zeilen, die sonst die Inventar-Spec rot machen:** Stand `system-gebunden` fuer `ldap-config.service.ts`/`ldapConfig`, `/ldapFieldMapping`, `/tenant`, `tender-digest.scheduler.ts`/`tenderMatch`, `tender-matching.service.ts`/`tenderSavedSearch`; Stand `gebunden` fuer `settings.service.ts`/`smtpConfig` (Startpfad entfernt); Begruendungen fortschreiben (je ein Satz mit `260914-eym`); Klassen UNVERAENDERT. Alles Weitere in Aufgabe 3. + +**(8) Falsifizierung durch Rueckbau — jede Messung woertlich in die SUMMARY, danach `git checkout -- ` und erneut gruen:** (a) in der Migrationsdatei bei `"TenderMatch"` das `FOR SELECT` voruebergehend entfernen (Regel wird ALL) -> Werkzeug: `tendermatch-systemkontext-insert-abgewiesen-42501` FEHLGESCHLAGEN (Insert gelingt), Schlusszeile nennt Fehlschlaege; (b) die `system_read_policy` fuer `"TenderSavedSearch"` voruebergehend aus der Migrationsdatei entfernen -> `tendersavedsearch-systemkontext-sieht-beide-mandanten` und `…-pg-policies-genau-eine-…` FEHLGESCHLAGEN (das Werkzeug misst die geschnittene Regel, nicht die lebende Datenbank — die Datenbank bleibt bei 34 Regeln, per `pg_policies` belegen); (c) im Werkzeug `forSystemQuery`/`buildInlineSystemClient` auf `set_config(..., false)` stellen -> alle `…-fortenant-a-nach-systemkontext-nur-a` BLEIBEN gruen (der Reset in `buildInlineExtendedClient` traegt); zusaetzlich den Reset dort entfernen -> rot (Erben sichtbar); (d) Detektor: Zahl fuer `tender-matching.service.ts` auf 0 -> Spec rot mit Meldung, die Datei und beide Zahlen nennt; eine Fremddatei (`apps/api/src/user/admin-seed.service.ts`) voruebergehend mit `const systemPrisma = forSystem(this.prisma)` versehen -> Spec rot. Alle Rueckbauten zuruecknehmen, `git status --short` leer ausser den gewollten Aenderungen. + +Baseline am Ende: Tests >= 1044 (Aufgabe 1 plus mindestens 4 Mail, 2 ldap, 1 digest, 1 matching, minus 4 settings), Typpruefung, Werkzeug N >= 250, keine FEHLGESCHLAGEN. Commit: `feat(quick-260914-eym): Mail-Transport je Versand nach Mandant (WINDOWS #30), ldap/digest/matching ueber Systemkontext, vier Tabellen im Werkzeug, Erlaubnisliste vollstaendig`. `git status --short` danach leer. + + + cd /home/vicolab/projects/tessera-ctl && test 0 -eq "$(cat apps/api/src/settings/settings.service.ts apps/api/src/settings/settings.service.spec.ts apps/api/src/mail/mail.module.ts apps/api/src/mail/mail.service.ts | grep -c 'loadAnySmtpConfigForStartupTransport')" && test 0 -eq "$(grep -v '^\s*//\|^\s*\*' apps/api/src/settings/settings.service.ts | grep -c 'this\.prisma\.smtpConfig')" && test 0 -eq "$(cat apps/api/src/mail/mail.module.ts apps/api/src/mail/mail.service.ts | grep -v '^\s*//\|^\s*\*' | grep -c 'MailerModule\|MailerService')" && grep -q 'getDecryptedSmtpConfig(tenantId)' apps/api/src/mail/mail.service.ts && grep -q 'transport.close()' apps/api/src/mail/mail.service.ts && grep -q 'resolveTransport' apps/api/src/mail/mail.service.ts && grep -q 'sendPasswordResetEmail(email, token, user.tenantId)' apps/api/src/auth/auth.service.ts && test -f apps/api/src/mail/mail.service.spec.ts && test 4 -le "$(grep -c '^\s*it(' apps/api/src/mail/mail.service.spec.ts)" && test 2 -eq "$(grep -c 'const systemPrisma = forSystem(this.prisma)' apps/api/src/ldap/ldap-config.service.ts)" && test 0 -eq "$(grep -v '^\s*//\|^\s*\*' apps/api/src/ldap/ldap-config.service.ts | grep -c 'this\.prisma\.ldapConfig')" && grep -q 'forTenant(this.prisma, config.tenantId)' apps/api/src/ldap/ldap-config.service.ts && test 1 -eq "$(grep -c 'const systemPrisma = forSystem(this.prisma)' apps/api/src/tenders/tender-digest.scheduler.ts)" && test 0 -eq "$(grep -v '^\s*//\|^\s*\*' apps/api/src/tenders/tender-digest.scheduler.ts | grep -c 'this\.prisma\.tenderMatch')" && test 1 -eq "$(grep -c 'const systemPrisma = forSystem(this.prisma)' apps/api/src/tenders/tender-matching.service.ts)" && test 0 -eq "$(grep -v '^\s*//\|^\s*\*' apps/api/src/tenders/tender-matching.service.ts | grep -c 'this\.prisma\.tenderSavedSearch')" && grep -q 'this.prisma.tender.findMany' apps/api/src/tenders/tender-matching.service.ts && test 4 -eq "$(grep -rl --include=*.ts 'forSystem(' apps/api/src | grep -v '\.spec\.ts$' | grep -v 'prisma-tenant.extension.ts$' | wc -l | tr -d ' ')" && test 5 -eq "$(grep -rh --include=*.ts 'const systemPrisma = forSystem(this.prisma)' apps/api/src | grep -v spec | wc -l | tr -d ' ')" && git diff --quiet 5e0e408 -- apps/api/src/user/admin-seed.service.ts apps/api/prisma/schema.prisma apps/api/package.json pnpm-lock.yaml apps/api/scripts/rls-preflight.mjs && for F in ldap-config.service ldap-config.service tender-digest.scheduler tender-matching.service; do :; done && for P in 'apps/api/src/ldap/ldap-config.service.ts \| ldapConfig \| beides \| system-gebunden' 'apps/api/src/ldap/ldap-config.service.ts \| ldapFieldMapping \| beides \| system-gebunden' 'apps/api/src/ldap/ldap-config.service.ts \| tenant \| keine-mandantengebundene-tabelle \| system-gebunden' 'apps/api/src/tenders/tender-digest.scheduler.ts \| tenderMatch \| beides \| system-gebunden' 'apps/api/src/tenders/tender-matching.service.ts \| tenderSavedSearch \| beides \| system-gebunden' 'apps/api/src/settings/settings.service.ts \| smtpConfig \| muss-mandantengebunden \| gebunden'; do grep -qE "^\| ${P} \|" docs/mandantentrennung-zugriffsklassifikation.md || { echo "KLASSIFIKATION: Zeile fehlt oder falscher Stand: $P"; exit 1; }; done && DB_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1) && test -n "$DB_IP" && T=$(npm --prefix apps/api run test 2>&1 | grep -oE 'Tests [0-9]+ passed' | grep -oE '[0-9]+') && echo "TESTS=$T" && test "$T" -ge 1044 && npm --prefix apps/api run type-check >/dev/null 2>&1 && N=$(TESSERA_SCRATCH_ADMIN_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/postgres" node apps/api/scripts/rls-scratch-check.mjs 2>&1 | tee /tmp/claude-1000/-home-vicolab-projects-tessera-ctl/d06a4a73-407a-4ea6-b686-d1f72c209902/scratchpad/eym-t2-scratch.log | grep -oE 'Alle [0-9]+ Pruefungen bestanden' | grep -oE '[0-9]+') && echo "WERKZEUG=$N" && test "$N" -ge 250 && test 0 -eq "$(grep -c FEHLGESCHLAGEN /tmp/claude-1000/-home-vicolab-projects-tessera-ctl/d06a4a73-407a-4ea6-b686-d1f72c209902/scratchpad/eym-t2-scratch.log)" && for S in dkvmoduleconfig ldapconfig ldapfieldmapping tendermatch tendersavedsearch; do for K in ungebunden-null-zeilen systemkontext-sieht-beide-mandanten systemkontext-insert-abgewiesen-42501 systemkontext-updatemany-count-0 systemkontext-deletemany-count-0 fortenant-a-nach-systemkontext-nur-a is-system-context-unter-fortenant-false pg-policies-genau-eine-system-read-policy-select; do grep -q "^${S}-${K}: bestanden" /tmp/claude-1000/-home-vicolab-projects-tessera-ctl/d06a4a73-407a-4ea6-b686-d1f72c209902/scratchpad/eym-t2-scratch.log || { echo "KENNUNG FEHLT: ${S}-${K}"; exit 1; }; done; done && grep -q '^ldapconfig-systemkontext-include-fieldmappings-beider-mandanten: bestanden' /tmp/claude-1000/-home-vicolab-projects-tessera-ctl/d06a4a73-407a-4ea6-b686-d1f72c209902/scratchpad/eym-t2-scratch.log && test -z "$(git status --short)" && echo AUFGABE-2-OK + + Mailmodul ohne Startpfad und ohne Mailer-Fabrik, `MailService` baut je Versand einen Transport aus der SmtpConfig des Empfaenger-Mandanten mit Umgebungs-Rueckfall, `requestPasswordReset` reicht den Mandanten durch, vier Mail-Tests; ldap liest beide Pfade ueber den Systemkontext und schreibt je Altzeile gebunden; digest und matching lesen ueber den Systemkontext, ihre Schleifen bleiben gebunden, der Katalog bleibt ungebunden; genau vier Dateien mit genau fuenf `forSystem`-Zuweisungen, Erlaubnisliste exakt; Werkzeug misst alle fuenf Tabellen plus die Relations-Kennung ohne FEHLGESCHLAGEN, N >= 250; sieben Bestandsaufnahme-Zeilen mit neuem Stand; Rueckbau-Belege (a)–(d) in der SUMMARY; Tests >= 1044, Typpruefung sauber; committet, `git status` leer. + + + + Aufgabe 3: Den Aktenstand kohaerent machen — Kritikschrift, Klassifikation, Auftrag, Datenbankrolle, Ledger — und pushen + docs/mandantentrennung-zugriffsklassifikation.md, docs/mandantentrennung-etappe2-fehlerrichtung.md, docs/mandantentrennung-etappe3-auftrag.md, docs/mandantentrennung-datenbankrolle.md, .planning/WINDOWS.md + Aufgabe 2 committet; die Werkzeugausgabe von Aufgabe 2 liegt woertlich vor (Scratchpad-Log oder frischer Lauf). + +**(1) Kritikschrift** `docs/mandantentrennung-etappe2-fehlerrichtung.md`: neuer Abschnitt `## Systemkontext (Etappe 3c, 260914-eym)` VOR `## Etappe 2 — Abschluss`, Form wie der 3b-Regelschluss: Einleitung (was gebaut wurde, Schalter AUS); `### (y1) Die Messung` — woertliche Werkzeugausgabe der vier Funktionsfaelle, je Tabelle die drei Kern-Kennungen (sieht-beide, insert-42501, fortenant-nur-a), die Relations-Kennung, die Schlusszeile, dazu `pg_policies` der lebenden Datenbank fuer die fuenf Tabellen (Tabelle#Regelname#Befehl#USING#WITH CHECK, beide Regeln je Tabelle), Endstand Tests/Typpruefung/Werkzeug; `### (y2) Signaltabelle — beide Fehlerrichtungen je Regel` — zu streng (Systemkontext saehe nichts: `…-sieht-beide-mandanten`), zu locker (Systemkontext koennte schreiben: `…-insert-abgewiesen-42501`, `…-updatemany-count-0`; ein Anfrageweg koennte `forSystem` rufen: Erlaubnisliste mit exakten Zahlen, Rueckbau-Beleg), Erben (`…-fortenant-a-nach-systemkontext-nur-a`, Rueckbau (c)); `### (y3) Welcher Code Leere als Abwesenheit deutet` — die Frage aus dem Auftrag je Pfad mit Datei und Zeile beantwortet: dkv-scheduler (leere Liste -> kein Auftrag, Protokollzeile, nichts geloescht), ldap-sync.scheduler (leere Liste -> kein Sync-Lauf; der Loeschzweig in `ldap.service.ts` liegt INNERHALB eines gebundenen Laufs, den es dann nicht gibt), ldap onApplicationBootstrap (leer -> Nachverschluesselung ist Nichtstun), digest (leer -> return, `notifiedAt` bleibt NULL), matching (leer -> keine Treffer, keine Sofortmeldung), admin-seed (leer -> keine Reparatur; `Tenant` ohne Regel, Systemkontext dort gar nicht noetig) — Fazit: kein Pfad loescht oder deaktiviert auf Leere; die verbleibende Gefahr ist das STUMME Nichtstun, und genau die schliesst die Systemleseregel; `### (y4) Was dieser Durchlauf bewusst nicht loest` — Single-Flight-Riegel `processing` prozessweit (neuer Ledger-Eintrag), `sendWelcomeEmail` ohne Aufrufer und `@nestjs-modules/mailer` als unbenutzte Abhaengigkeit (Aufraeumen, kein Defekt), der Sonderfall Nutzer-unter-zwei-Mandanten im Digest (weiter (t4)), `rls-preflight.mjs` bekommt in Etappe 4 eine Pruefung `mit-systemkontext-sichtbar` (hier nicht gebaut, weil das Werkzeug unter der Wegwerf-Rolle dasselbe bereits misst); `### (y5) Was dieser Durchlauf bewusst nicht anfasst` — Schalter, schema.prisma, Compose/.env, SECURITY-DEFINER-Funktionen, admin-seed.service.ts, preflight, package.json/Lockfile, der Tick `processInbox`, die 3b-Regeln. Datierte Nachtraege `**Nachtrag (260914-eym):**` in (d4) (WINDOWS #21 geschlossen, Auftragsname jetzt `dkv-inbox-poll:` — die Erwaehnung in Zeile ~901 bleibt als historischer Stand mit Nachtrag), (s4) (WINDOWS #30 geschlossen durch Entfernen des Startpfads, nicht durch Systemkontext), (b4) (erster Punkt eingeloest) und im Abschluss-Abschnitt. + +**(2) Klassifikation** `docs/mandantentrennung-zugriffsklassifikation.md`: Uebersichtstabelle bekommt eine DRITTE Spalte `System` (Rohtreffer `systemPrisma\.[a-zA-Z]*\.` je Bereich, gleiche Grep-Form wie die beiden anderen, mit datiertem Absatz vor der Tabelle, der die neue Spalte und ihre Grenzen erklaert — Relationsziele zaehlt auch sie nicht); Zeilen tenders/ldap/dkv/settings mit nachgerechneten Werten (Erwartung 33/27/2, 1/27/2, 0/22/1, 0/3/0 — NACHRECHNEN) und je einem datierten Satz im Hinweis; alle uebrigen Zeilen bekommen `0` in der neuen Spalte; Summenzeile ABGELEITET (Erwartung 61/179/5). Klassen-Verteilung: Absatz `**Stand 260914-eym:**` — Paarzahl 72 und Klassen UNVERAENDERT, sieben Paare aendern nur ihren Stand (alle sieben aufzaehlen, sechs auf `system-gebunden`, eines auf `gebunden`), der neue Stand-Wert und seine Vorrangregel erklaert. Hintergrunddienst-Abschnitt (Ueberschrift 'sechs Fälle' bleibt): je Fall ein `**Regelschluss (260914-eym):**` — ldap (beide Methoden, Nachverschluesselung liest system, schreibt gebunden), digest, matching, admin-seed (gemessen: nur `Tenant`, nichts umgebaut, Datei unveraendert), fuenfter Fall (#21 geschlossen, Auftrag je Mandant), sechster Fall (#30 geschlossen durch Entfernen — 'der sechste Fall existiert nicht mehr'). Bestandsaufnahme-Header: die fuenf Erkennungsformen und die vier Stand-Werte (aus Aufgabe 1 pruefen, ggf. vervollstaendigen). Zeile `admin-seed.service.ts`/`tenant`: Begruendung um den 3c-Befund ergaenzen (Stand bleibt `ungebunden`). Abschnitt 'Was diese Etappe NICHT entscheidet': den 3c-Punkt als erledigt vermerken. + +**(3) Auftrag** `docs/mandantentrennung-etappe3-auftrag.md`: im 3c-Abschnitt einen Absatz `**Erledigt (260914-eym, ):**` in der Form des 3b-Vermerks: Migration, Helfer, fuenf Tabellen (SmtpConfig nicht — warum), DKV je Mandant, Mail je Versand, ldap/digest/matching, admin-seed nur dokumentiert, Endzahlen (Tests/Typpruefung/Werkzeug), Verweis auf den (y)-Abschnitt; der urspruengliche Text bleibt stehen. Im Abschnitt 'Was NICHT ohne den User geht' nichts aendern; unter 'Werkzeuge und Fallen' einen Punkt ergaenzen: 'Ein Mock-Fabrik ohne den neuen Export wirft erst beim ZUGRIFF (vitest-Proxy) — jede Spec, deren Pruefling `forSystem` importiert, braucht den Export im Mock.' + +**(4) Datenbankrolle** `docs/mandantentrennung-datenbankrolle.md`: in der Aufzaehlung 'was gebaut wurde' nach dem 3b-Punkt einen Punkt 'Eine dritte Sitzungsvariable fuer den Systemkontext' (Migration, Funktion mit COALESCE, `FOR SELECT`, fuenf Tabellen, `forSystem()` setzt die beiden anderen Variablen ausdruecklich leer, `forTenant()` umgekehrt); den Absatz ab 'Diese Stellen brauchen einen ausdruecklichen, benannten Systemkontext' um einen datierten Nachtrag ergaenzen (gebaut in 3c; die Vorher-Pruefung `ohne-kontext-leer` bleibt gueltig, weil `is_system_context()` ohne Variable false ist — Beleg `is-system-context-ungesetzt-false`; Etappe 4 ergaenzt die Vorher-Pruefung um `mit-systemkontext-sichtbar`). Die SECURITY-DEFINER-Kopfkommentare NICHT anfassen. + +**(5) Ledger** `.planning/WINDOWS.md` ausschliesslich ueber das Werkzeug: `node /home/vicolab/.claude/gsd-core/bin/gsd-tools.cjs windows fixed 21`, `windows fixed 30`, dann `windows append --kind deviation --phase quick-260914-eym --file apps/api/src/dkv/dkv.service.ts --description ""` — Text: der Single-Flight-Riegel `processing` in `DkvService.processInbox` ist EIN prozessweites Boolean, seit 260914-eym laeuft je aktivem Mandanten ein eigener Cron-Auftrag; ueberschneiden sich zwei Ticks verschiedener Mandanten, bricht der zweite still ab und der Mandant wartet bis zum naechsten Intervall (kein Datenverlust, Verzoegerung; mit einem Mandanten unveraendert). Zu schliessen: Riegel je Mandant (Set) mit Test 'zwei Mandanten gleichzeitig, beide werden bedient'. Danach Frontmatter-Zaehler gegen die Zeilen pruefen (erwartet open 15 / waived 1 / fixed 21 / total 37 — aus den Zeilen ZAEHLEN, nicht abschreiben). + +Baseline pruefen (Tests >= 1044, Typpruefung, Werkzeug N >= 250 — der Werkzeuglauf aus Aufgabe 2 gilt nur, wenn seither keine Datei unter apps/ geaendert wurde; `git diff --stat HEAD~1 -- apps` leer, sonst frisch laufen lassen). Erlaubnisliste gegen HEAD: `git diff --stat 5e0e408 -- . ':!.planning'` nennt genau 29 Dateien (die 30 aus `files_modified` ohne `.planning/WINDOWS.md`). Commit: `docs(quick-260914-eym): Etappe 3c abgeschlossen — Kritikschrift, Klassifikation, Auftrag, Datenbankrolle, WINDOWS #21/#30 geschlossen, Single-Flight-Riegel als Eintrag`. Dann `git push` (Push-URL zeigt auf localhost:3002; nicht ueber git.vicolab.de), `git status --short` leer, `git rev-parse HEAD` == `git rev-parse origin/main`. SUMMARY schreiben: Messungen, die drei Rueckbau-Belege (Werkzeug (a)/(b)/(c), Detektor (d)) WOERTLICH, die Abweichungen zum Auftrag (fuenf statt sechs Tabellen; ldap hat zwei Systemkontext-Leser; Mail-Startpfad entfernt statt umgestellt; Container musste gestartet werden), was ohne den User nicht geht (nichts Neues — Etappe 4 bleibt Rueckfrage). + + + cd /home/vicolab/projects/tessera-ctl && K=docs/mandantentrennung-zugriffsklassifikation.md && P=$(grep -cE '^\| apps/api/src/[^|]+ \| [a-zA-Z]+ \| (muss-mandantengebunden|bewusst-uebergreifend|keine-mandantengebundene-tabelle|beides) \| (gebunden|ungebunden|gemischt|system-gebunden) \|' "$K") && { test "$P" -eq 72 || { echo "PAARE: $P, erwartet 72"; exit 1; }; } && grep -qE "^## Klassen-Verteilung \(nach \(Datei, Modell\)-Fundstellen, ${P} Paare\)" "$K" && grep -qE "^\| \*\*Summe\*\* \| \*\*${P}\*\* \|$" "$K" && for C in muss-mandantengebunden keine-mandantengebundene-tabelle beides bewusst-uebergreifend; do N=$(grep -cE "^\| apps/api/src/[^|]+ \| [a-zA-Z]+ \| ${C} \|" "$K"); grep -qE "^\| ${C} \| ${N} \|$" "$K" || { echo "KLASSEN-VERTEILUNG: ${C} nennt nicht ${N}"; exit 1; }; done && test 6 -eq "$(grep -cE '^\| apps/api/src/[^|]+ \| [a-zA-Z]+ \| [a-z-]+ \| system-gebunden \|' "$K")" && grep -q '^\*\*Stand 260914-eym' "$K" && TU=0 && TB=0 && TS=0 && for d in apps/api/src/*/; do u=$(grep -ro "this\.prisma\.[a-zA-Z]*" "$d" 2>/dev/null | grep -v spec | wc -l | tr -d ' '); b=$(grep -ro "tenantPrisma\.[a-zA-Z]*\." "$d" 2>/dev/null | grep -v spec | wc -l | tr -d ' '); s=$(grep -ro "systemPrisma\.[a-zA-Z]*\." "$d" 2>/dev/null | grep -v spec | wc -l | tr -d ' '); TU=$((TU+u)); TB=$((TB+b)); TS=$((TS+s)); done && echo "ABGELEITET ${TU}/${TB}/${TS}" && { grep -qE "^\| \*\*Summe\*\* \| \*\*${TU}\*\* \| \*\*${TB}\*\* \| \*\*${TS}\*\* \|" "$K" || { echo "SUMMENZEILE nennt nicht ${TU}/${TB}/${TS}"; exit 1; }; } && grep -qE '^\| Bereich \| Ungebunden \| Gebunden \| System \|' "$K" && grep -q '^## Der Hintergrunddienst als Falle — sechs Fälle$' "$K" && test 6 -le "$(awk '/^## Der Hintergrunddienst als Falle/{f=1; next} /^## /{f=0} f' "$K" | grep -c 'Regelschluss (260914-eym)')" && D=docs/mandantentrennung-etappe2-fehlerrichtung.md && grep -q '^## Systemkontext (Etappe 3c, 260914-eym)$' "$D" && for Y in y1 y2 y3 y4 y5; do grep -qE "^### \(${Y}\)" "$D" || { echo "ABSCHNITT (${Y}) fehlt"; exit 1; }; done && awk '/^### \(y1\)/{f=1} /^### \(y2\)/{f=0} f' "$D" | grep -q 'is-system-context-ungesetzt-false: bestanden' && awk '/^### \(y1\)/{f=1} /^### \(y2\)/{f=0} f' "$D" | grep -q 'tendermatch-systemkontext-insert-abgewiesen-42501: bestanden' && awk '/^### \(y1\)/{f=1} /^### \(y2\)/{f=0} f' "$D" | grep -q '#system_read_policy#SELECT#is_system_context()#' && awk '/^### \(y3\)/{f=1} /^### \(y4\)/{f=0} f' "$D" | grep -q 'ldap.service.ts' && for SEC in '(d4)' '(s4)' '(b4)'; do awk -v s="### $SEC" 'index($0,s)==1{f=1; next} /^### /{f=0} /^## /{f=0} f' "$D" | grep -q 'Nachtrag (260914-eym)' || { echo "NACHTRAG fehlt in $SEC"; exit 1; }; done && awk '/^## Etappe 2 — Abschluss$/{f=1; next} /^## /{f=0} f' "$D" | grep -q '260914-eym' && grep -q 'Erledigt (260914-eym' docs/mandantentrennung-etappe3-auftrag.md && grep -q 'app.system_context' docs/mandantentrennung-datenbankrolle.md && grep -q 'is-system-context-ungesetzt-false' docs/mandantentrennung-datenbankrolle.md && DR=$(git diff 5e0e408 -- docs/mandantentrennung-datenbankrolle.md) || { echo "git diff datenbankrolle fehlgeschlagen"; exit 1; } && test -n "$DR" && test 0 -eq "$(printf '%s\n' "$DR" | grep '^[-+]' | grep -v '^[-+][-+]' | grep -ci 'SECURITY DEFINER')" && W=.planning/WINDOWS.md && grep -E '^\| 21 \| ' "$W" | grep -q '| fixed |' && grep -E '^\| 30 \| ' "$W" | grep -q '| fixed |' && grep -E '^\| 37 \| quick-260914-eym \| deviation \| apps/api/src/dkv/dkv.service.ts' "$W" | grep -q '| open |' && O=$(grep -cE '^\| [0-9]+ \| .* \| open \| ' "$W") && Fx=$(grep -cE '^\| [0-9]+ \| .* \| fixed \| ' "$W") && Wv=$(grep -cE '^\| [0-9]+ \| .* \| waived \| ' "$W") && grep -q "^open_count: ${O}$" "$W" && grep -q "^fixed_count: ${Fx}$" "$W" && grep -q "^waived_count: ${Wv}$" "$W" && grep -q "^total_count: $((O+Fx+Wv))$" "$W" && echo "LEDGER open=${O} fixed=${Fx} waived=${Wv}" && ST=$(git diff --stat 5e0e408 -- . ':!.planning') || { echo "git diff --stat fehlgeschlagen"; exit 1; } && test 29 -eq "$(printf '%s\n' "$ST" | grep -c '|')" && T=$(npm --prefix apps/api run test 2>&1 | grep -oE 'Tests [0-9]+ passed' | grep -oE '[0-9]+') && echo "TESTS=$T" && test "$T" -ge 1044 && npm --prefix apps/api run type-check >/dev/null 2>&1 && test -z "$(git status --short)" && test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" && echo AUFGABE-3-OK + + Neuer Abschnitt (y1)–(y5) mit woertlicher Werkzeugausgabe und `pg_policies`-Liste; Nachtraege in (d4)/(s4)/(b4)/Abschluss; Klassifikation mit dritter Spalte, abgeleiteter Summenzeile, Stand-Absatz 260914-eym, sechs Regelschluesse im Hintergrunddienst-Abschnitt, 72 Paare / Klassen unveraendert / sechs Zeilen `system-gebunden`; Auftrag (3c erledigt), Datenbankrolle (dritte Variable, preflight-Aussage, SECURITY-DEFINER-Kommentare unangetastet), Ledger (#21/#30 fixed, #37 neu, Zaehler aus Zeilen); Erlaubnisliste 29 Dateien gegen 5e0e408; Baseline gruen; committet, gepusht, `git status` leer, HEAD == origin/main; SUMMARY mit Rueckbau-Belegen und Abweichungen zum Auftrag. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Anfrageweg -> Systemkontext | Ein Request-Pfad, der `forSystem()` ruft, laese an JEDER Mandantenregel vorbei — die Grenze, die die Erlaubnisliste des Detektors zieht | +| Systemkontext -> Datenbank (Schreiben) | Nur Lesen ist geoeffnet; Schreiben bleibt hinter der Mandantenregel | +| forSystem -> forTenant auf gepoolten Verbindungen | Kein Kontext darf vom anderen erben | +| Hintergrunddienst -> Mandant | Einmal ueber alle lesen, je Mandant gebunden handeln — die Bauform aller sechs Faelle | +| Mandant A -> SMTP-Zugangsdaten Mandant B | Der Startpfad des Mailmoduls benutzte bisher EINEN beliebigen Server fuer alle | +| Wegwerf-Werkzeug -> ausgelieferte Migration | Das Werkzeug misst nur, was es aus der Migration schneidet | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-EYM-01 | Elevation of Privilege / Information Disclosure (ein Anfrageweg ruft faelschlich `forSystem` und liest alle Mandanten) | `forSystem()`, jeder Dienst | critical | mitigate | Aufgabe 1/2: `FORSYSTEM_ALLOWED_CALL_SITES` mit EXAKTER Zahl je Datei (4 Dateien, 5 Aufrufe), Fremddatei/Abweichung/veralteter Eintrag -> Spec rot; Zuweisungsform erzwungen; durch Rueckbau (d) belegt; Kopfkommentar des Helfers nennt die Liste | +| T-EYM-02 | Tampering (Schreiben unter Systemkontext an der Mandantenregel vorbei) | `system_read_policy` | critical | mitigate | Regel `FOR SELECT`; je Tabelle `…-insert-abgewiesen-42501`, `…-updatemany-count-0`, `…-deletemany-count-0` ueber den generierten Client; Rueckbau (a) `FOR SELECT` entfernt -> rot; ldap-Nachverschluesselung schreibt je Altzeile ueber `forTenant` | +| T-EYM-03 | Information Disclosure (Kontext-Erben: eine Verbindung traegt `app.system_context` in eine spaetere `forTenant`-Abfrage) | Helfer, Pool | high | mitigate | `set_config(..., true)` transaktionslokal (erstes Netz) UND ausdruecklicher Reset aller drei Variablen in jeder Form (zweites Netz); `…-fortenant-a-nach-systemkontext-nur-a`, `…-is-system-context-unter-fortenant-false`; Rueckbau (c) belegt, dass der Reset traegt | +| T-EYM-04 | Denial of Service (zu wenig statt zu viel: ein Systemleser ohne Regel liefert nach dem Scharfschalten 0 Zeilen und schweigt) | fuenf Tabellen, sechs Pfade | high | mitigate | Tabellen GEZAEHLT ueber Aufrufe und `include` (LdapFieldMapping ueber #27-Form, Relations-Kennung); je Tabelle `…-ungebunden-null-zeilen` UND `…-sieht-beide-mandanten` als Paar; Rueckbau (b) Regel entfernt -> rot; (y3) beantwortet je Pfad, dass Leere nie loescht | +| T-EYM-05 | Denial of Service (Live-Gehen morgen mit einem Mandanten durch diese Aenderung gestoert) | DKV-Planer, MailService, ldap/digest/matching | critical | mitigate | Schalter AUS (Gate: Compose/.env/DATABASE_URL unveraendert); je Pfad Identitaetstest fuer EINEN Mandanten (Cron-Expression, Tick, Protokollzeile; Transport aus derselben Config, gleicher Absender; ldap/digest/matching bestehende Tests unveraendert); Gesamtsuite >= Baseline nach jeder Aufgabe | +| T-EYM-06 | Information Disclosure / Spoofing (Kennwort-Zuruecksetzung ueber den SMTP-Server und Absender eines FREMDEN Mandanten, T-GWH-03) | `MailService` | high | mitigate | Startpfad entfernt; Transport je Versand aus `getDecryptedSmtpConfig(tenantId)` (gebunden); Test 'zwei Mandanten -> zwei Transporte, keiner sieht die Zugangsdaten des anderen'; Kennwort nur im Methodenrumpf, nie protokolliert | +| T-EYM-07 | Repudiation (Werkzeug misst ein getipptes Wunschbild statt der ausgelieferten Regel) | `rls-scratch-check.mjs` | high | mitigate | Funktion und Systemregeln aus der NEUEN Migration geschnitten, Mandantenregeln aus ihren jeweiligen Migrationen, Abbruch bei Fehlschlag; `pg_policies` live in Scratch UND lebender Datenbank gelesen; Rueckbau (b) zeigt, dass das Werkzeug der Datei folgt | +| T-EYM-08 | Tampering (Pruefsummen ausgelieferter Migrationen) | bestehende Migrationen | high | mitigate | Nur eine NEUE Migrationsdatei; Gate `git diff 5e0e408 -- apps/api/prisma` ausser der neuen Datei leer; kein DROP POLICY | +| T-EYM-09 | Denial of Service (zwei Mandanten-Ticks ueberschneiden sich, der zweite bricht am prozessweiten Single-Flight-Riegel still ab) | `DkvService.processInbox` | medium | accept (mit Aufzeichnung) | Verzoegerung bis zum naechsten Intervall, kein Datenverlust; mit einem Mandanten unveraendert; neuer WINDOWS-Eintrag #37 mit Loesungsweg (Riegel je Mandant) — der Tick bleibt in diesem Durchlauf unangetastet (Auftrag) | +| T-EYM-SC | Tampering | npm/pip/cargo installs | low | accept | Keine Paketinstallation; `package.json`/Lockfile in jedem Gate gegen 5e0e408 unveraendert; `@nestjs-modules/mailer` bleibt installiert, nur unbenutzt | + + + +- Aufgabe 1: Migration angewendet und live gelesen (fuenf `system_read_policy`/SELECT, 34 Regeln); Helfer mit drei Variablen in jeder Form; DKV je Mandant mit Identitaetstests; Detektor mit fuenfter Form und exakter Liste; Werkzeug 13 neue Kennungen gruen, N >= 216; Tests >= 1040; Typpruefung; Erlaubnisliste. +- Aufgabe 2: Mail je Versand, Startpfad weg; ldap/digest/matching ueber Systemkontext; genau 4 Dateien / 5 Zuweisungen; Werkzeug fuenf Tabellen plus Relations-Kennung, N >= 250, keine FEHLGESCHLAGEN; sieben Bestandsaufnahme-Zeilen; Rueckbau (a)–(d) belegt; Tests >= 1044. +- Aufgabe 3: (y1)–(y5) mit woertlicher Ausgabe; Klassifikation abgeleitet (dritte Spalte, Summen, 72 Paare, Klassen unveraendert, sechs Regelschluesse); Auftrag, Datenbankrolle, Ledger (#21/#30 fixed, #37 neu, Zaehler aus Zeilen); 29 Dateien gegen 5e0e408; gepusht. + + + +- `forSystem(prisma)` existiert, setzt alle drei Variablen, `forTenant()`/`withTenantTransaction()` setzen `app.system_context` leer; kein Erben, gemessen und durch Rueckbau falsifiziert. +- Migration mit `is_system_context()` und fuenf `system_read_policy … FOR SELECT` lokal angewendet; Schreiben unter Systemkontext abgewiesen (42501 / count 0 / P2025), gemessen; bestehende Migrationen, Schema, Compose, .env, Lockfile unveraendert; Schalter AUS. +- Alle sechs Faelle behandelt: DKV je Mandant (#21 fixed), Mail je Versand mit entferntem Startpfad (#30 fixed), ldap (zwei Leser, ein gebundener Schreiber), digest, matching, admin-seed dokumentiert; je Pfad ein Identitaetstest fuer einen Mandanten. +- Detektor: fuenfte Form, Stand `system-gebunden`, Erlaubnisliste mit exakten Zahlen, falsifiziert; Bestandsaufnahme und alle abgeleiteten Abschnitte konsistent, Inventar-Spec gruen. +- Werkzeug: `runSystemContextChecks` mit >= 50 neuen Kennungen, Schlusszeile >= 250, keine FEHLGESCHLAGEN. +- Baseline: Tests >= 1044 / 62 Dateien (plus zwei neue Spec-Dateien = 64), Typpruefung 0, drei Commits mit Scope `quick-260914-eym`, gepusht, `git status` leer. +- Alles, was bewusst offen bleibt, steht im Ledger (#37) oder in (y4) — nichts still weggelassen. + + + +Create `/home/vicolab/projects/tessera-ctl/.planning/quick/260914-eym-mandantentrennung-etappe-3c-systemkontex/260914-eym-SUMMARY.md` when done — mit den gemessenen Zahlen (Tests, Dateien, Typpruefung, Werkzeug N), der woertlichen `pg_policies`-Ausgabe, den vier Rueckbau-Belegen, den Abweichungen zum Auftrag (fuenf statt sechs Tabellen, zwei ldap-Leser, Mail-Startpfad entfernt, Container gestartet) und dem, was ohne den User nicht geht. +