Files
tessera-ctl/.planning/quick/260914-eym-mandantentrennung-etappe-3c-systemkontex/260914-eym-PLAN.md
T

90 KiB
Raw Blame History

phase, plan, type, wave, depends_on, autonomous, requirements, files_modified, estimate, must_haves
phase plan type wave depends_on autonomous requirements files_modified estimate must_haves
quick-260914-eym 01 execute 1
true
ETAPPE-3C
WINDOWS-21
WINDOWS-30
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
tokens raw_tokens tasks confidence
280000 280000 3 low
truths artifacts key_links
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 (`<slug>-fortenant-a-nach-systemkontext-nur-a`, `<slug>-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: `<slug>-ungebunden-null-zeilen` (0 Zeilen), `<slug>-systemkontext-sieht-beide-mandanten`, `<slug>-systemkontext-insert-abgewiesen-42501`, `<slug>-systemkontext-updatemany-count-0`, `<slug>-systemkontext-deletemany-count-0`, `<slug>-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:<tenantId>`, `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 <Name> = 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).
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
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.

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

@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 <id> und windows append --kind deviation --phase quick-260914-eym --file <Datei> --description "<ein Absatz ASCII>".
  • 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 <assumption_delta_decision> 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: <threat_model> 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).

<assumption_delta_decision> 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:<tenantId>) 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'. </assumption_delta_decision>

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 "<T>"[\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: <slug>-wegwerftabelle-deckt-alle-spalten-des-generierten-clients, <slug>-ungebunden-null-zeilen (findMany auf dem rohen Client, 0), <slug>-systemkontext-sieht-beide-mandanten (buildInlineSystemClient, tenantIds beider), <slug>-systemkontext-insert-abgewiesen-42501 (create mit tenantId TENANT-A; erwartet sqlStateOf(err) === '42501', jedes Gelingen und jeder andere Fehler FEHLGESCHLAGEN mit Meldung), <slug>-systemkontext-updatemany-count-0, <slug>-systemkontext-deletemany-count-0, <slug>-fortenant-a-nach-systemkontext-nur-a (buildInlineExtendedClient(prisma,'TENANT-A') auf DEMSELBEN prisma unmittelbar nach dem System-Aufruf; nur TENANT-A), <slug>-is-system-context-unter-fortenant-false ($transaction([setContextA, $queryRaw SELECT is_system_context()]) -> false), <slug>-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<string>, totalForSystemCalls, systemAssignmentFormCalls; in analyzeSource Zuweisungen const <Name> = forSystem( sammeln, <Name>.<Modell> in systemModels, totalForSystemCalls per /(?<!function )forSystem\(/g; Systemnamen als Anker-Empfaenger der vierten Form (Relationsziele auf System-Klienten in systemModels) — dafuer scanRelationKeys so umbauen, dass es die Ziel-Menge direkt bekommt statt isBound. STAND_TOKENS um system-gebunden; computeStandByKey mit der Vorrangregel aus den truths (ungebunden+anderes -> gemischt; nur ungebunden -> ungebunden; system ohne ungebunden -> system-gebunden; sonst gebunden); findAccessSites nimmt systemModels mit. Neue Konstante FORSYSTEM_ALLOWED_CALL_SITES = new Map<string, number>([['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:<tenantId>, 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:<t> 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 '^\sit(' 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 -- <Datei> 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 '^\sit(' 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, <Commit-Kuerzel der drei Commits>):** 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 "<ein Absatz ASCII>" — 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.

<threat_model>

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
</threat_model>
- 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.

<success_criteria>

  • 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. </success_criteria>
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.