--- phase: quick-260914-eym plan: 01 subsystem: mandantentrennung tags: [rls, systemkontext, forSystem, dkv, mail, ldap, tenders, windows-21, windows-30] status: complete requires: [quick-260911-nke] provides: [forSystem, is_system_context, system_read_policy, dkv-auftrag-je-mandant, mail-transport-je-versand] affects: [etappe-4] tech-stack: added: [] patterns: [Systemkontext-Schwesterhelfer forSystem(prisma), FOR-SELECT-Systemleseregel, Erlaubnisliste mit exakter Zahl je Datei, Transport je Versand nach Mandant] key-files: created: - apps/api/prisma/migrations/20260914120000_rls_system_context_read/migration.sql - apps/api/src/dkv/dkv-scheduler.service.spec.ts - apps/api/src/mail/mail.service.spec.ts modified: - 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.controller.ts - apps/api/src/mail/mail.module.ts - apps/api/src/mail/mail.service.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 decisions: - "forSystem(prisma) als Schwesterhelfer statt viertem Parameter — eigene Zugriffsklasse, eigene Erkennungsform im Detektor (Umkehrung der 3b-Begruendung)" - "system_read_policy FOR SELECT auf genau fuenf Tabellen; SmtpConfig bekommt keine, weil der Mail-Startpfad entfernt statt umgestellt wurde" - "DKV-Planer: Auftrag je Mandant (promote, nicht add-alongside) — Einzahl-Feld activeTenantId ersatzlos entfernt" - "Mail: Transport je Versand nach Mandant des Empfaengers; Umgebungs-Kette nur Rueckfall fuer Mandanten ohne SmtpConfig" - "admin-seed nur dokumentiert (liest ausserhalb der Schleife nur Tenant, keine Regel) — Datei unveraendert" - "Single-Flight-Riegel processInbox bleibt prozessweit — WINDOWS #37 statt Umbau (Auftrag: Tick unangetastet)" metrics: duration: "1 Sitzung (2026-09-14, ca. 11:10-12:05)" completed: 2026-09-14 actuals: tokens: 58868 tasks: 3 commits: 3 plan_head_before: 02016e19ebdbdf703465fa55baa945bf71f0b334 --- # Quick 260914-eym: Mandantentrennung Etappe 3c — Systemkontext fuer die Hintergrunddienste — Summary Benannter Systemkontext `forSystem(prisma)` mit `is_system_context()` und je einer nur lesenden `system_read_policy FOR SELECT` auf fuenf Tabellen; alle sechs Hintergrunddienst-Faelle behandelt (DKV-Planer je Mandant, Mail-Transport je Versand mit entferntem Startpfad, ldap/digest/matching ueber den Systemkontext, admin-seed dokumentiert); Detektor mit fuenfter Erkennungsform und falsifizierbarer Erlaubnisliste; Werkzeug 203 -> 253; WINDOWS #21 und #30 geschlossen, #37 neu. Der Schalter bleibt AUS. ## Commits ``` 939c812 docs(quick-260914-eym): Etappe 3c abgeschlossen — Kritikschrift, Klassifikation, Auftrag, Datenbankrolle, WINDOWS #21/#30 geschlossen, Single-Flight-Riegel als Eintrag 6e2a641 feat(quick-260914-eym): Mail-Transport je Versand nach Mandant (WINDOWS #30), ldap/digest/matching ueber Systemkontext, vier Tabellen im Werkzeug, Erlaubnisliste vollstaendig 3d64567 feat(quick-260914-eym): forSystem(), is_system_context(), Systemleseregel auf fuenf Tabellen, DKV-Planer je Mandant — ein Pfad (WINDOWS #21) ``` `git log --oneline 02016e1..HEAD` (oben, drei Commits). `git rev-list --count 02016e1..HEAD` = 3. Gepusht: `git push` -> `5e0e408..939c812 main -> main`; `git fetch -q && git status -sb | head -1` -> `## main...origin/main`; `git rev-parse HEAD` == `git rev-parse origin/main`. `git status --porcelain` vor dem Schreiben dieser SUMMARY: leer (keine Ausgabe). Hinweis zum Branch: das Projekt committet seit jeher direkt auf `main` (alle Quick-Tasks, Plan-Gate `HEAD == origin/main`, Auftrag "plain `git push`") — dem Projekt-Workflow gefolgt; `git.allow_default_branch_commits` ist in `.planning/config.json` nicht gesetzt. ## Gemessene Zahlen (beobachtet, nicht abgeschrieben) | Messpunkt | Baseline (HEAD 5e0e408/02016e1) | nach Aufgabe 1 (3d64567) | nach Aufgabe 2 (6e2a641) | Ende (939c812) | |---|---|---|---|---| | `npm --prefix apps/api run test` — Test Files | 62 | 63 | 64 | 64 | | Tests | 1028 | 1051 | 1054 | 1054 | | `npm --prefix apps/api run type-check` Exit | 0 | 0 | 0 | 0 | | `rls-scratch-check.mjs` Schlusszeile | Alle 203 Pruefungen bestanden. | Alle 216 Pruefungen bestanden. | Alle 253 Pruefungen bestanden. | 253 (kein apps-Diff seit Aufgabe 2, Gate `git diff --stat HEAD~1 -- apps` leer) | | `prisma migrate status` | 35 Migrationen, up to date | 36 Migrationen, up to date | 36 | 36 | | `pg_proc` `is_system_context` | 0 | 1 | 1 | 1 | | `pg_policies` public gesamt / `system_read_policy` | 29 / 0 | 34 / 5 | 34 / 5 | 34 / 5 | | `git diff --stat 5e0e408 -- . ':!.planning'` Dateien | — | — | — | 29 (`29 files changed, 2499 insertions(+), 491 deletions(-)`) | | Ledger (aus Zeilen gezaehlt) | open 16 / waived 1 / fixed 19 / total 36 | — | — | open 15 / waived 1 / fixed 21 / total 37 (Frontmatter identisch) | Plan-Erwartung vs. beobachtet: Tests erwartet >= 1040 / >= 1044, beobachtet 1051 / 1054; Werkzeug erwartet >= 216 / >= 250 (abgeleitet 216 / 253), beobachtet exakt 216 / 253; Uebersichtstabelle erwartet 61/179/5 (tenders 33/27/2, ldap 1/27/2, dkv 0/22/1, settings 0/3/0), mit der Gate-Schleife nachgerechnet: identisch. Umgebung: Container `tessera-ctl-db-1` lief beim Einstieg bereits (`Up 25 minutes (healthy)`, vom Planer gestartet); IP per `docker inspect` 172.19.0.2; DB-Zugang `tessera:tessera_dev`; Prisma-Binary `apps/api/node_modules/.bin/prisma`. Schalter-Gate: `git diff --name-only 5e0e408` nennt keine Compose-, `.env`-, `schema.prisma`-, `package.json`-, Lockfile-, `rls-preflight.mjs`- oder `admin-seed.service.ts`-Datei (in jedem der drei Gates geprueft). ## [BLOCKING] Migration lokal angewendet — woertliche Ausgabe `cd apps/api && DATABASE_URL="postgresql://tessera:tessera_dev@172.19.0.2:5432/tessera" ./node_modules/.bin/prisma migrate deploy`: ``` The following migration(s) have been applied: migrations/ └─ 20260914120000_rls_system_context_read/ └─ migration.sql All migrations have been successfully applied. ``` `migrate status`: `36 migrations found in prisma/migrations` / `Database schema is up to date!` `pg_proc` / `pg_policies` (Tabelle#Regelname#Befehl#PERMISSIV#USING#WITH CHECK): ``` pg_proc is_system_context = 1 DkvModuleConfig#system_read_policy#SELECT#PERMISSIVE#is_system_context()# DkvModuleConfig#tenant_isolation_policy#ALL#PERMISSIVE#("tenantId" = current_tenant_id())# LdapConfig#system_read_policy#SELECT#PERMISSIVE#is_system_context()# LdapConfig#tenant_isolation_policy#ALL#PERMISSIVE#("tenantId" = current_tenant_id())# LdapFieldMapping#system_read_policy#SELECT#PERMISSIVE#is_system_context()# LdapFieldMapping#tenant_isolation_policy#ALL#PERMISSIVE#("ldapConfigId" IN ( SELECT "LdapConfig".id FROM "LdapConfig" WHERE ("LdapConfig"."tenantId" = current_tenant_id())))# SmtpConfig#tenant_isolation_policy#ALL#PERMISSIVE#("tenantId" = current_tenant_id())# TenderMatch#system_read_policy#SELECT#PERMISSIVE#is_system_context()# TenderMatch#tenant_isolation_policy#ALL#PERMISSIVE#("tenantId" = current_tenant_id())# TenderSavedSearch#system_read_policy#SELECT#PERMISSIVE#is_system_context()# TenderSavedSearch#tenant_isolation_policy#ALL#PERMISSIVE#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))# system_read_policy gesamt = 5 policies public gesamt = 34 ``` ## Werkzeug — die vier Funktionsfaelle (woertlich, Lauf nach Aufgabe 2) ``` is-system-context-ungesetzt-false: bestanden — ohne gesetzte Variable: is_system_context() = false (Rohwert null) — die Vorher-Pruefung ohne-kontext-leer in rls-preflight.mjs bleibt gueltig is-system-context-leer-false: bestanden — nach set_config('app.system_context', '', true): false is-system-context-true-true: bestanden — nach set_config('app.system_context', 'true', true): true is-system-context-fremdwert-false: bestanden — nach set_config('app.system_context', 'yes', true): false ``` Je Tabelle (dkvmoduleconfig, ldapconfig, ldapfieldmapping, tendermatch, tendersavedsearch) neun Kennungen gruen, plus `ldapconfig-systemkontext-include-fieldmappings-beider-mandanten: bestanden — ... liefert 2 Zeile(n): ["TENANT-A:1","TENANT-B:1"]`. Die vollstaendigen Zeilen stehen in `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt (y1). ## Falsifizierung durch Rueckbau — woertliche Ausgaben Jeder Rueckbau wurde ausgefuehrt, das rote Ergebnis protokolliert, die Datei restauriert (`git checkout -- ` fuer committete Dateien; fuer die in Aufgabe 2 noch uncommitteten Dateien Werkzeug/Detektor per Kopie mit identischem SHA-256-Praefix `a1f845ba787cac52` bzw. `d9595ef09df57fce`) und der gruene Zustand erneut gemessen (Werkzeug 253, Detektor 30/30, `git status --short` danach nur die gewollten Aufgabe-2-Dateien). **(a) `FOR SELECT` bei `"TenderMatch"` in der Migrationsdatei entfernt** (Regel wird ALL): ``` tendermatch-systemkontext-insert-abgewiesen-42501: FEHLGESCHLAGEN — system.tenderMatch.create({"id":"tm-system-schreibversuch","tenderId":"tender-2","savedSearchId":"ss-a","userId":"user-a","tenantId":"TENANT-A"}) ist NICHT fehlgeschlagen — angelegt: "tm-system-schreibversuch" tendermatch-systemkontext-updatemany-count-0: FEHLGESCHLAGEN — system.tenderMatch.updateMany({ where: {}, data: {"notifiedChannel":"SYSTEM-SCHREIBVERSUCH"} }) liefert count=3 tendermatch-systemkontext-deletemany-count-0: FEHLGESCHLAGEN — system.tenderMatch.deleteMany({}) liefert count=3; Zeilen danach (Wartungsrolle): 0 tendermatch-fortenant-a-nach-systemkontext-nur-a: FEHLGESCHLAGEN — bound(TENANT-A).tenderMatch.findMany() unmittelbar nach system.findMany() auf demselben Client liefert 0 Zeile(n) aus [] tendermatch-pg-policies-genau-eine-system-read-policy-select: FEHLGESCHLAGEN — pg_policies fuer "TenderMatch" (system_read_policy): [{"policyname":"system_read_policy","cmd":"ALL","permissive":"PERMISSIVE","qual":"is_system_context()"}] 5 von 253 Pruefungen fehlgeschlagen. ``` Plan erwartete: `…-insert-abgewiesen-42501` rot. Beobachtet: 5 rot — der Insert gelingt, danach auch updateMany/deleteMany (count 3, Zeilen 0), deshalb liefert der Folgeschritt `…-nur-a` 0 Zeilen, und `pg_policies` zeigt `ALL`. Die Kernaussage (Insert GELINGT ohne `FOR SELECT`) ist woertlich belegt. **(b) `system_read_policy` fuer `"TenderSavedSearch"` aus der Migrationsdatei entfernt:** ``` tendersavedsearch-system-read-policy-aus-migration-gefunden: FEHLGESCHLAGEN — CREATE POLICY system_read_policy ON "TenderSavedSearch" nicht in der Systemkontext-Migration (20260914120000) gefunden 1 von 245 Pruefungen fehlgeschlagen. ``` Lebende Datenbank waehrend des Rueckbaus (per `pg_policies`): `policies public gesamt = 34 | system_read_policy auf TenderSavedSearch = 1`. Plan erwartete: `…-sieht-beide-mandanten` und `…-pg-policies-genau-eine-…` rot. Beobachtet: die innere Routine bricht fuer diese Tabelle mit einer eigenen roten Extraktions-Kennung ab (Muster `runSingleRulePersonalTableCheck`: nicht raten, wenn die Regel in der Migration fehlt), die neun Kennungen der Tabelle laufen nicht (253 -> 245). Die Aussage des Plans — das Werkzeug misst die geschnittene Regel, nicht die lebende Datenbank, und die Datenbank bleibt bei 34 — ist belegt; die Form des Rotwerdens ist strenger als erwartet, nicht lockerer. Beide Zahlen (erwartet 2 rote Kennungen von 253; beobachtet 1 rote von 245) stehen hier und in (y2). **(c) `local=false` in `forSystemQuery`/`buildInlineSystemClient` (Werkzeug):** `Alle 253 Pruefungen bestanden.` — alle fuenf `…-fortenant-a-nach-systemkontext-nur-a` bleiben gruen, der Reset in `buildInlineExtendedClient` traegt. **Zusaetzlich den Reset dort entfernt:** ``` dkvmoduleconfig-fortenant-a-nach-systemkontext-nur-a: FEHLGESCHLAGEN — bound(TENANT-A).dkvModuleConfig.findMany() unmittelbar nach system.findMany() auf demselben Client liefert 2 Zeile(n) aus ["TENANT-A","TENANT-B"] ldapconfig-fortenant-a-nach-systemkontext-nur-a: FEHLGESCHLAGEN — bound(TENANT-A).ldapConfig.findMany() unmittelbar nach system.findMany() auf demselben Client liefert 2 Zeile(n) aus ["TENANT-A","TENANT-B"] ldapfieldmapping-fortenant-a-nach-systemkontext-nur-a: FEHLGESCHLAGEN — bound(TENANT-A).ldapFieldMapping.findMany() unmittelbar nach system.findMany() auf demselben Client liefert 2 Zeile(n) aus ["TENANT-A","TENANT-B"] tendermatch-fortenant-a-nach-systemkontext-nur-a: FEHLGESCHLAGEN — bound(TENANT-A).tenderMatch.findMany() unmittelbar nach system.findMany() auf demselben Client liefert 2 Zeile(n) aus ["TENANT-A","TENANT-B"] tendersavedsearch-fortenant-a-nach-systemkontext-nur-a: FEHLGESCHLAGEN — bound(TENANT-A).tenderSavedSearch.findMany() unmittelbar nach system.findMany() auf demselben Client liefert 2 Zeile(n) aus ["TENANT-A","TENANT-B"] 5 von 253 Pruefungen fehlgeschlagen. ``` (`…-is-system-context-unter-fortenant-false` blieb gruen, weil diese Kennung ihre Transaktion mit eigenem Reset-Literal baut, nicht ueber `buildInlineExtendedClient`.) **(d) Detektor — Zahl fuer `tender-matching.service.ts` auf 0:** ``` AssertionError: apps/api/src/tenders/tender-matching.service.ts: gemessen 1 forSystem(-Aufruf(e), erlaubt sind genau 0: expected [ Array(1) ] to deeply equal [] AssertionError: apps/api/src/tenders/tender-matching.service.ts: Erlaubnisliste nennt 0, gemessen 1 — der Eintrag ist ueberholt: expected [ Array(1) ] to deeply equal [] Tests 2 failed | 28 passed (30) ``` **Fremddatei `admin-seed.service.ts` voruebergehend mit `forSystem(` versehen:** ``` AssertionError: apps/api/src/user/admin-seed.service.ts: 2 forSystem(-Aufruf(e), Datei steht NICHT in FORSYSTEM_ALLOWED_CALL_SITES — ein Anfrageweg darf den Systemkontext nie rufen: expected [ Array(1) ] to deeply equal [] AssertionError: apps/api/src/user/admin-seed.service.ts: 1 forSystem(-Aufruf(e) ausserhalb der Zuweisungsform: expected [ Array(1) ] to deeply equal [] AssertionError: apps/api/src/user/admin-seed.service.ts: 1 include:/select:/_count:-Angabe(n) ausserhalb eines erkannten Modellaufrufs: expected [ Array(1) ] to deeply equal [] Tests 3 failed | 27 passed (30) ``` Danach `git checkout -- apps/api/src/user/admin-seed.service.ts`, `git diff --quiet 5e0e408 -- apps/api/src/user/admin-seed.service.ts` -> unveraendert. Ausserdem ein nicht geplanter Beleg fuer den Veraltet-Wachhund: in Aufgabe 1 war die Erlaubnisliste bereits mit `dkv.service.ts: 1` gefuellt, bevor `dkv.service.ts` umgestellt war — die Spec wurde rot (`Erlaubnisliste nennt 1, gemessen 0 — der Eintrag ist ueberholt`) und erst mit der Umstellung gruen. ## Identitaet mit einem Mandanten (morgen alpha, BYPASSRLS) — je Pfad als Test - dkv (`dkv-scheduler.service.spec.ts`, 7 Tests): ein aktiver Mandant, pollIntervalMin 15 -> genau ein Auftrag `dkv-inbox-poll:t1`, `cronTime.source === '*/15 * * * *'`, `isActive` true; 120 -> `0 */2 * * *`; `fireOnTick()` ruft `processInbox('t1')` genau einmal; inaktive/keine Config -> kein Auftrag, Protokollzeile `DKV scheduler: no active config found — cron job not registered`; zwei Mandanten -> zwei Auftraege, `setInterval(30,'t2')` laesst das t1-Objekt identisch, `stopJob('t1')` entfernt nur t1; werfender Startpfad -> `DKV scheduler init failed: db down`, kein Auftrag; `stopJob` unbekannt -> No-Op. - mail (`mail.service.spec.ts`, 4 Tests): Mandant MIT SmtpConfig -> `getDecryptedSmtpConfig('t1')` genau einmal, `createTransport({host:'smtp-a.example.invalid',port:465,secure:true,requireTLS:false,auth:{user:'user-a',pass:'geheim-a'}})`, `from` = `noreply@a.example.invalid`, `close()` einmal; ohne SmtpConfig -> MAIL_* vor TESSERA_SMTP_* vor `localhost:1025`, `from` aus TESSERA_SMTP_FROM bzw. `Tessera `; zwei Mandanten -> zwei Transporte, keiner enthaelt das Kennwort des anderen; `sendMail` wirft -> kein Throw, Protokoll ohne Kennwort, `close()` trotzdem. - ldap/digest/matching: bestehende Verhaltenstests unveraendert gruen (ldap 92, tenders 413 Tests in den Bereichen), plus je eine Zusicherung `forSystem` genau einmal und `forTenant` genauso oft wie bisher (ldap: `getAllActiveConfigs` forSystem 1 / forTenant 0; Bootstrap leer: forSystem 1 / forTenant 0; Bootstrap mit Altzeile t1: forTenant genau einmal mit `t1`, `update` traegt `aa11:bb22:`; digest: `__systemCallLog` genau `[tenderMatch.findMany]`, forTenant weiter genau einmal; matching: `__systemCallLog` genau `[tenderSavedSearch.findMany]`, `tender.findMany` weiter auf dem rohen Client). ## Deviations from Plan ### Auto-fixed Issues **1. [Rule 3 - Blocking] Gate-Zaehlung `const systemPrisma = forSystem(this.prisma)` traf die Proben der Detektor-Spec** - **Found during:** Aufgabe 2, Gate-Lauf - **Issue:** Das Gate zaehlt `grep -rh … | grep -v spec` — `-h` laesst den Dateinamen weg, `spec` steht nicht im Zeilentext, deshalb zaehlten die drei Proben C/D1/D2 mit (8 statt 5). Der Plan verlangt die Probe C mit genau diesem Text UND das Gate mit genau 5 — in sich widerspruechlich. - **Fix:** Empfaengername in den drei Proben auf `sysPrisma` geaendert (Regex des Detektors ist `const\s+(\w+)\s*=\s*forSystem\(` — die Probe prueft weiterhin dieselbe Form und belegt zusaetzlich, dass der Name nicht hartkodiert ist); Gate unveraendert, Zahl unveraendert (5). - **Files modified:** `apps/api/src/prisma/rls-access-inventory.spec.ts` - **Commit:** 6e2a641. Als Falle in `docs/mandantentrennung-etappe3-auftrag.md` ("Werkzeuge und Fallen") eingetragen. **2. [Rule 3 - Blocking] Kopfkommentar `dkv-scheduler.service.ts` nannte `forSystem()` als Text** - **Found during:** Aufgabe 2, Gate `test 4 -eq ` - **Issue:** Das Gate zaehlt Dateien mit dem Text `forSystem(` auch in Kommentaren; der in Aufgabe 1 geschriebene Kopfkommentar nannte den Helfer. - **Fix:** Kommentar umformuliert ("systemgebunden ueber den Systemkontext-Helfer"). Datei steht in `files_modified` des Plans (Aufgabe-1-Liste), Aenderung im Aufgabe-2-Commit. - **Files modified:** `apps/api/src/dkv/dkv-scheduler.service.ts` - **Commit:** 6e2a641 **3. [Rule 3 - Blocking] Spec-Kopfkommentar nannte den geloeschten Methodennamen** - **Found during:** Aufgabe 2 (eigener Assert vor dem Gate) - **Issue:** Das Gate verlangt null Treffer `loadAnySmtpConfigForStartupTransport` in vier Dateien, auch in Kommentaren; mein erster Entwurf des Spec-Kopfkommentars nannte ihn. - **Fix:** Umschrieben ("der ungebundene Startpfad des Mailmoduls"). - **Files modified:** `apps/api/src/settings/settings.service.spec.ts` - **Commit:** 6e2a641 **4. [Rule 1 - Bug] `pg_policies`-Form in (y1)** - **Found during:** Aufgabe 3, Gate - **Issue:** Ich hatte die Zeilen mit sechs Spalten (inkl. PERMISSIV) geschrieben; das Gate erwartet die 3b-Form `Tabelle#Regelname#Befehl#USING#WITH CHECK`. - **Fix:** Zeilen auf die 3b-Form gebracht (die PERMISSIV-Eigenschaft steht im Satz davor). - **Files modified:** `docs/mandantentrennung-etappe2-fehlerrichtung.md` - **Commit:** 939c812 ### Abweichungen zum Auftrag (bewusst, im Plan so vorgesehen) - **Fuenf statt sechs Tabellen:** SmtpConfig traegt keine `system_read_policy`, weil der Mail-Startpfad ENTFERNT wurde (Transport je Versand nach Mandant des Empfaengers), nicht auf den Systemkontext umgestellt. - **ldap hat ZWEI Systemkontext-Leser:** `getAllActiveConfigs()` und die Nachverschluesselung in `onApplicationBootstrap()` (je eigene Zuweisung, der Detektor zaehlt 2); die Schreibzeile je Altzeile laeuft ueber `forTenant(this.prisma, config.tenantId)`. - **Mail-Startpfad entfernt statt umgestellt:** `MailerModule.forRootAsync` und `loadAnySmtpConfigForStartupTransport()` samt vier Spec-Tests geloescht; `@nestjs-modules/mailer` bleibt in `package.json`/Lockfile installiert, ist aber unbenutzt (kein Lockfile-Eingriff in diesem Durchlauf). - **Container:** musste NICHT gestartet werden — er lief beim Einstieg bereits (vom Planer gestartet, `Up 25 minutes (healthy)`). - **Rueckbau (b):** rot in strengerer Form als im Plan beschrieben (Extraktions-Abbruch statt zwei rote Messkennungen), siehe oben. - **Doppelte Kennung im Werkzeug:** `dkvmoduleconfig-ungebunden-null-zeilen` gibt es jetzt zweimal (einmal aus `runDkvAreaChecks`, einmal aus dem neuen Abschnitt) — der Plan schreibt den Namen vor; beide gruen, das Gate greift per `^…: bestanden`. ## Was bewusst offen bleibt - **WINDOWS #37 (neu, open):** Der Single-Flight-Riegel `processing` in `DkvService.processInbox` ist EIN prozessweites Boolean. Seit je aktivem Mandanten ein eigener Cron-Auftrag laeuft, bricht bei Ueberschneidung zweier Ticks verschiedener Mandanten der zweite still ab (Warnzeile `already processing`) und wartet bis zum naechsten Intervall — kein Datenverlust, Verzoegerung; mit einem Mandanten unveraendert (T-EYM-09, accept mit Aufzeichnung). Loesungsweg: Riegel je Mandant (`Set`) mit Test "zwei Mandanten gleichzeitig, beide werden bedient". - `sendWelcomeEmail` hat weiterhin null Aufrufer; `@nestjs-modules/mailer` unbenutzt in `package.json` — Aufraeumen, kein Defekt. - Digest-Sonderfall "Nutzer mit Treffern unter zwei Mandanten" (`distinct: ['userId']`) bleibt wie in (t4) beschrieben. - `rls-preflight.mjs` bekommt in Etappe 4 die Pruefung `mit-systemkontext-sichtbar`; `ohne-kontext-leer` bleibt gueltig (Beleg `is-system-context-ungesetzt-false`, Rohwert `null` -> `false`). - Etappe 3a (Anmeldenamen pro Mandant) und Etappe 4 (Scharfschalten) — unveraendert offen. ## Was ohne den User nicht geht Nichts Neues. Wie im Auftrag: 3a Weg (i) vs. (ii) (wie der Mandant beim Login bestimmt wird) und Etappe 4 (Scharfschalten, `DATABASE_URL` auf `tessera_app`) bleiben Rueckfragen. Dieser Durchlauf hat den Schalter nicht angefasst: keine Compose-Datei, keine `.env`, nichts auf einem Server, nichts in Active Directory. ## Threat Flags Keine neue Angriffsflaeche ausserhalb des `` des Plans: kein neuer Netzwerk-Endpunkt, kein neuer Auth-Pfad, keine Schemaaenderung an Vertrauensgrenzen (nur zusaetzliche, nur lesende Regeln plus eine Funktion ohne SECURITY DEFINER). T-EYM-01 bis T-EYM-08 mitigiert wie geplant (Belege oben), T-EYM-09 accept mit Ledger-Eintrag #37, T-EYM-SC: keine Paketinstallation, `package.json`/Lockfile unveraendert gegen 5e0e408. ## Known Stubs Keine. `sendWelcomeEmail` ist kein Stub (vollstaendig implementiert, nur ohne Aufrufer — seit vor diesem Durchlauf). ## Self-Check: PASSED - Dateien: `apps/api/prisma/migrations/20260914120000_rls_system_context_read/migration.sql`, `apps/api/src/dkv/dkv-scheduler.service.spec.ts`, `apps/api/src/mail/mail.service.spec.ts` — FOUND (im Commit-Baum, `git diff --stat 5e0e408` nennt 29 Dateien). - Commits 3d64567, 6e2a641, 939c812 — FOUND (`git log --oneline 02016e1..HEAD`), gepusht (`HEAD == origin/main`).