---
phase: quick-260921-m34
plan: 01
type: execute
wave: 1
depends_on: []
autonomous: true
requirements: [QUICK-M34-01]
files_modified:
- apps/api/src/prisma/prisma-tenant.extension.ts
- apps/api/src/auth/auth.service.ts
- apps/api/src/auth/auth.controller.ts
- apps/api/src/auth/strategies/jwt.strategy.ts
- apps/api/src/auth/strategies/local.strategy.ts
- apps/api/src/auth/interceptors/force-password-change.interceptor.ts
- apps/api/src/auth/types/auth-user.ts
- apps/api/src/bug-reports/bug-reports.service.ts
- apps/api/src/bug-reports/bug-reports.controller.ts
- apps/api/src/calendar/calendar.controller.ts
- apps/api/src/calendar/calendar.service.ts
- apps/api/src/calendar/providers/exchange.provider.ts
- apps/api/src/cert-manager/cert-manager.controller.ts
- apps/api/src/cert-manager/cert-manager.service.ts
- apps/api/src/dashboard/dashboard.controller.ts
- apps/api/src/dkv/dkv.controller.ts
- apps/api/src/dkv/dkv.service.ts
- apps/api/src/dkv/dkv-scheduler.service.ts
- apps/api/src/favorites/favorites.controller.ts
- apps/api/src/favorites/favorites.service.ts
- apps/api/src/groups/groups.controller.ts
- apps/api/src/groups/groups.service.ts
- apps/api/src/groups/module-grants.controller.ts
- apps/api/src/groups/module-grants.service.ts
- apps/api/src/inbox/exchange-inbox.provider.ts
- apps/api/src/inbox/imap.provider.ts
- apps/api/src/ldap/ldap.controller.ts
- apps/api/src/ldap/ldap.service.ts
- apps/api/src/ldap/ldap-config.service.ts
- apps/api/src/mail/mail.service.ts
- apps/api/src/module-registry/module-access.service.ts
- apps/api/src/module-registry/module-registry.controller.ts
- apps/api/src/module-registry/module-registry.service.ts
- apps/api/src/settings/settings.controller.ts
- apps/api/src/settings/settings.service.ts
- apps/api/src/tenant/tenant.controller.ts
- apps/api/src/tenders/tenders.controller.ts
- apps/api/src/tenders/tender-digest.scheduler.ts
- apps/api/src/tenders/tender-email-config.service.ts
- apps/api/src/tenders/tender-matching.service.ts
- apps/api/src/tenders/tender-notification-pref.service.ts
- apps/api/src/tenders/tender-rss-feed.service.ts
- apps/api/src/tenders/tender-saved-search.service.ts
- apps/api/src/tenders/tender-scheduler.service.ts
- apps/api/src/tenders/tender-triage.service.ts
- apps/api/src/user/admin-seed.service.ts
- apps/api/src/user/user.controller.ts
- apps/api/src/user/user.service.ts
- apps/api/src/auth/auth.controller.spec.ts
- apps/api/src/user/user.controller.spec.ts
- apps/web/src/test/setup.ts
estimate:
tokens: 260000
raw_tokens: 173000
tasks: 3
confidence: low
must_haves:
truths:
- "Jeder der 288 Befunde in apps/api hat am Ende genau ein Urteil: typisiert, auf unknown umgestellt, oder bleibt mit gemessener Begruendung (D-01)."
- "Kein Befund wird durch eine Behauptung stillgelegt: noNonNullAssertion bleibt bei hoechstens 56, ts-expect-error/ts-ignore bleibt bei 0, Lint-Unterdrueckungsmarker bleiben bei 1, 'as unknown as' bleibt bei hoechstens 33 (D-02)."
- "Das Verhalten der API ist unveraendert; jede Stelle, an der die neue Typisierung eine falsche Annahme im Bestandscode aufdeckt, wird als Befund gemeldet statt still korrigiert (D-03)."
- "Nach JEDER Aufgabe: pnpm type-check 4/4 und pnpm lint 5/5 mit 0 Fehlern der Schwere error (D-05)."
- "Nach JEDER Aufgabe: apps/api 72 Dateien / 1143 Tests gruen, apps/web 73 / 531 gruen (D-06)."
- "Der gemeinsame Aufrufer-Typ leitet jedes Feld aus den beiden Signierstellen und der Verbraucherpruefung ab; kein Feld wird so getypt, dass eine bestehende Berechtigungspruefung als tot erscheint (D-02, D-03)."
artifacts:
- "apps/api/src/auth/types/auth-user.ts — AuthUser, AuthenticatedRequest, UploadedFileLike, JwtPayload"
- "apps/api/src/prisma/prisma-tenant.extension.ts ohne interne Zusicherungen"
- "Urteilsregister der bleibenden Befunde im SUMMARY, plus je eine Begruendungszeile direkt an der Codestelle"
key_links:
- "forTenant()/forSystem() Rueckgabewert -> 105 Aufrufstellen: der Zusicherungsverzicht darf den Erkenner in rls-access-inventory.spec.ts nicht blind machen"
- "JwtStrategy.validate() -> AuthUser -> TenantGuard/RolesGuard: die Feldtypen entscheiden, ob Mandanten- und Rollenpruefungen weiterhin scharf sind"
- "FileInterceptor ohne storage-Option -> multer memoryStorage -> file.buffer ist ein Buffer: nur deshalb ist UploadedFileLike ueberpruefbar und keine Behauptung"
---
288 `any`-Befunde im Backend einzeln beurteilen und typisieren. Kein Aufraeumen nach Gefuehl: jeder Befund bekommt ein Urteil, jedes Urteil eine Begruendung, und die Zahl faellt nur um das, was tatsaechlich ehrlich getypt werden konnte (D-01).
Purpose: Der `any`-Rueckstand ist der letzte Posten des Rueckstands, den der Nutzer vor neuen Funktionen geraeumt haben will. Er hat keinen bekannten Fehler hinter sich — es ist reine Typarbeit. Der Wert liegt darin, dass die naechste Aenderung an Mandanten-, Rollen- und Upload-Wegen vom Compiler begleitet wird statt von Vertrauen.
Output: apps/api mit deutlich weniger `any`, einem gemeinsamen Aufrufer-Typ, und einem Register der Stellen, die bewusst stehen bleiben.
## Was vorab gemessen wurde (2026-09-21, Planungszeitpunkt)
Ausgangslage, mit `npx biome lint --reporter=json` gezaehlt (Feld `category`, nie durch Textsuche im Quelltext):
| Groesse | Wert |
|---|---|
| `lint/suspicious/noExplicitAny` in `apps/api/src` | **288** (48 Dateien, +1 in apps/web) |
| `lint/style/noNonNullAssertion` in `apps/api/src` | 56 |
| Befunde der Schwere `error` | 0 |
| `ts-expect-error` / `ts-ignore` | 0 |
| Lint-Unterdrueckungsmarker | 1 |
| `as unknown as` | 33 |
| `pnpm type-check` / `pnpm lint` | 4/4 und 5/5, Rueckgabewert 0 |
| `apps/api` Vitest / `apps/web` Vitest | 72 Dateien / 1143 Tests, 73 / 531 |
Die Befunde fallen in wenige wiederkehrende Formen. Sie wurden nicht geschaetzt, sondern durch Probeumbauten am echten Baum gemessen (jeder Probeumbau danach zurueckgenommen, Baum wieder sauber):
| Form | Anzahl | Messergebnis |
|---|---|---|
| `forTenant(...) as any` / `forSystem(...) as any` | **105** | Zusicherung an allen 105 Stellen entfernt -> `tsc` meldet **genau einen** Folgefehler. Die Zusicherung war nie noetig: `prisma.$extends(...)` liefert bereits einen vollstaendig getypten Klienten. |
| Gefolge davon (`: any[]`, `.map((g: any) => ...)`, `x as any[]`) | 22 | haengt am `any`-Klienten und faellt mit ihm |
| Innereien von `prisma-tenant.extension.ts` | 15 | `(prisma as any)` und die Handannotationen an `$allOperations` entfernt -> `tsc` sauber. 13 fallen, 2 (`tx: any`) sind noch offen. |
| `(req as any)` | 32 | sechs Controller auf einen getypten Request umgestellt -> `tsc` meldet **genau einen** Fehler, und der ist ein echter Befund (siehe unten) |
| `@Req() req: any` | 25 | dito |
| `@CurrentUser() user: any` | 13 | mit `AuthUser` getypt -> 8 Fehler im Produktivcode, 28 in Testdateien (Fixtures ohne `username`/`mustChangePassword`) |
| `catch (e: any)` | 18 | Umstellung auf `unknown` plus Eingrenzung an der Verwendungsstelle |
| node-forge in `cert-manager.service.ts` | 11 | gemischt; `@types/node-forge` ist installiert |
| `@UploadedFile()` / `@UploadedFiles()` und ihre Dienst-Gegenstuecke | 11 | `Express.Multer.File` existiert hier **nicht** (kein `@types/multer`, gemessen). Alle `FileInterceptor`-Aufrufe setzen **keine** `storage`-Option -> multer memoryStorage -> `file.buffer` ist ein Buffer. |
| `job as any` an `addCronJob` | 3 | Zusicherung entfernt -> `tsc` meldet 3 Fehler: das lokale `job` hat die Form `{ start(): void }`, `addCronJob` verlangt einen echten `CronJob`. **Nicht aufloesbar ohne Verhaltensaenderung.** |
| Rest (httpntlm, imap, nodemailer, calendar, web-setup, ...) | 33 | einzeln zu beurteilen |
## Zwei Befunde, die die Messung schon jetzt aufgedeckt hat (D-03)
1. `apps/api/src/tenders/tender-matching.service.ts:159` — die Handannotation `(match: { tender: unknown })` verengt den Wert **faelschlich** auf `unknown`, sobald der Klient richtig getypt ist. Sie existierte nur, um unter dem `any`-Klienten TS7006 zu vermeiden. Loeschen ist der richtige Umgang, keine Zusicherung.
2. `apps/api/src/dashboard/dashboard.controller.ts:74` — gibt `req.user?.role` (moeglicherweise `undefined`) an etwas weiter, das `Role` verlangt. Der Code nimmt an, dass ein Aufrufer immer vorhanden ist. Das ist als Befund zu melden, nicht stumm zu reparieren.
## Was am Ende erwartet wird, und warum
Erwartung: **etwa 20 bis 40 verbleibende Befunde** in `apps/api` (Ausgang 288). Herleitung, nicht Wunsch:
- Aufgabe 1 nimmt rund 136 (105 gemessen + 13 gemessen + rund 18 Gefolge).
- Aufgabe 2 nimmt rund 90 (13 + 25 + 32 + 1 + 11 + rund 8 Umfeld).
- Aufgabe 3 findet rund 62 vor und loest davon vielleicht 35; der Rest bleibt.
Bleiben werden voraussichtlich: die drei Cron-Stellen (gemessen nicht aufloesbar), ein Teil der node-forge-Stellen, an denen die mitgelieferten Typen die Bibliothek falsch beschreiben, sowie einzelne Stellen an Fremdbibliotheken ohne Typen. **Null ist ausdruecklich nicht das Ziel.** Wer die letzten Stellen erzwingt, tauscht eine ehrliche Warnung gegen eine unehrliche Behauptung — genau das verbietet D-02. Eine kleinere Zahl mit sauberen Urteilen ist das bessere Ergebnis.
## Keine laufende Umgebung noetig
Fuer reine Typarbeit ist kein Stapel noetig: alle Pruefungen dieses Plans sind `tsc`, Biome und die beiden Testlaeufe. Es wird **kein** `docker compose` gestartet. Sollte wider Erwarten eine Laufzeitfrage auftauchen, ist das ein Grund, sie als Befund zu melden (D-03), nicht ein Grund, einen Stapel hochzufahren.
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
@.planning/STATE.md
@CLAUDE.md
@apps/api/src/prisma/prisma-tenant.extension.ts
@apps/api/src/auth/strategies/jwt.strategy.ts
@apps/api/src/auth/decorators/current-user.decorator.ts
@apps/api/src/tenant/tenant.guard.ts
@apps/api/src/bug-reports/bug-reports.service.ts
Aufgabe 1: Die Mandantenbindung entzaubern — 105 Zusicherungen, die nie noetig waren
apps/api/src/prisma/prisma-tenant.extension.ts, apps/api/src/auth/auth.service.ts, apps/api/src/bug-reports/bug-reports.service.ts, apps/api/src/dkv/dkv.service.ts, apps/api/src/favorites/favorites.service.ts, apps/api/src/groups/groups.service.ts, apps/api/src/groups/module-grants.service.ts, apps/api/src/ldap/ldap-config.service.ts, apps/api/src/ldap/ldap.service.ts, apps/api/src/module-registry/module-access.service.ts, apps/api/src/module-registry/module-registry.service.ts, apps/api/src/settings/settings.service.ts, apps/api/src/tenant/tenant.controller.ts, apps/api/src/tenders/tender-digest.scheduler.ts, apps/api/src/tenders/tender-email-config.service.ts, apps/api/src/tenders/tender-matching.service.ts, apps/api/src/tenders/tender-notification-pref.service.ts, apps/api/src/tenders/tender-rss-feed.service.ts, apps/api/src/tenders/tender-saved-search.service.ts, apps/api/src/tenders/tender-triage.service.ts, apps/api/src/user/admin-seed.service.ts, apps/api/src/user/user.controller.ts, apps/api/src/user/user.service.ts
apps/api/src/prisma/prisma-tenant.extension.ts (Kopfkommentar Zeile 1-196 erklaert, warum die Array-Form der Transaktion Pflicht ist — daran wird nichts geaendert), apps/api/src/prisma/rls-access-inventory.spec.ts (der Erkenner, der diese Aufrufstellen zaehlt), apps/api/src/groups/groups.service.ts Zeile 55-75 (zeigt das Gefolge: der `any`-Klient erzwingt `any[]` und `(g: any)`), apps/api/src/tenders/tender-matching.service.ts Zeile 135-170
Der groesste Block ist zugleich der harmloseste, und deshalb steht er zuerst: er beweist die Methode an der breitesten Stelle, bevor irgendetwas Sicherheitsrelevantes angefasst wird.
Schritt A — die Innereien des Erweiterungsmoduls. In `prisma-tenant.extension.ts` sind die Zusicherungen `(prisma as any)` in `forTenant`, `forSystem` und `withTenantTransaction` unnoetig: `PrismaClient` traegt `$executeRaw` und `$transaction` bereits. Entferne sie. Entferne ebenso die Handannotation an `$allOperations` — der Parameter wird von Prisma hergeleitet, die Annotation `{ args: any; query: (args: any) => any }` ersetzt eine korrekte Herleitung durch drei `any`. Stelle `.then((results: any[]) => results[1])` auf `unknown[]` um; der Ergebnistyp der Aufrufstellen kommt aus Prismas Erweiterungstypen, nicht aus diesem Rueckgabewert (gemessen: `tsc` bleibt danach sauber). Fuer `withTenantTransaction` bleibt `fn: (tx: any)`: pruefe, ob `Prisma.TransactionClient` hier passt, und wenn ja, ziehe die vier Aufrufstellen in `groups.service.ts` und `favorites.service.ts` mit. Wenn `tsc` das nicht traegt, ist `bleibt` mit gemessener Begruendung das richtige Urteil (D-01).
Am Kopfkommentar (Zeile 1-196) wird nichts geaendert. Er dokumentiert Messungen zu Verbindungen und Transaktionen, nicht zu Typen.
Schritt B — die 105 Aufrufstellen. Die Form ist ueberall `const tenantPrisma = forTenant(this.prisma, tenantId) as any;` beziehungsweise `const systemPrisma = forSystem(this.prisma) as any;`. Entferne die Zusicherung. Gemessen: `tsc` meldet danach genau einen Folgefehler, naemlich den aus Schritt C.
Wichtig fuer die Mandantentrennung: der Erkenner in `rls-access-inventory.spec.ts` sucht nach `const = forTenant(` beziehungsweise `const = forSystem(`. Das Entfernen der nachgestellten Zusicherung beruehrt diesen Praefix nicht. Aendere die Zuweisungsform nicht, fasse keine zwei Aufrufe zusammen, und verschiebe keinen Aufruf in eine andere Datei — die Erlaubnisliste `FORSYSTEM_ALLOWED_CALL_SITES` haelt je Datei eine exakte Zahl fest, jede Abweichung macht die Spec rot. Das ist die eigentliche Schutzwirkung dieser Aufgabe und darf nicht beschaedigt werden (T-M34-03).
Schritt C — das Gefolge. Mit einem richtig getypten Klienten werden die Handannotationen, die nur seinetwegen dastanden, von Hilfe zu Schaden. Entferne sie:
- `const groups: any[] = await tenantPrisma.group.findMany(...)` in `groups.service.ts:63` und `const masters: any[] = ...` in `dkv.service.ts:755` — samt des erklaerenden Kommentars darueber, der jetzt nicht mehr stimmt.
- die `.map((g: any) => ...)`, `.map((u: any) => ...)`, `.map((a: any) => ...)`, `(a: any, b: any) =>` und `for (const g of groupGrants as any[])`-Stellen in `groups.service.ts`, `module-grants.service.ts`, `ldap-config.service.ts`, `tenders.controller.ts:270`.
- **Der gemessene Befund:** `tender-matching.service.ts:159` traegt `(match: { tender: unknown })`. Diese Handannotation verengt den Wert falsch, sobald der Klient getypt ist, und erzeugt den einen Fehler aus Schritt B. Loesche die Annotation, damit der hergeleitete Typ durchkommt. Setze hier **keine** Zusicherung.
Fuer jede Stelle, an der das Entfernen einer Annotation einen neuen `tsc`-Fehler erzeugt, gilt: der Fehler ist ein Befund. Pruefe, was der Code tatsaechlich annimmt. Wenn die Annahme falsch war, melde sie im SUMMARY und lass das Verhalten unangetastet (D-03). Ersetze sie **nicht** durch eine Zusicherung, eine Ausrufezeichen-Behauptung oder einen Unterdrueckungskommentar (D-02) — die Zaehlwerte in `` fangen genau das ab.
Keine Formatierung ueber den Bestand hinaus, keine Versionsspruenge, keine neuen Abhaengigkeiten, und die Testdatei-Ausnahme in `biome.json` bleibt unberuehrt (D-04).
cd /home/vicolab/projects/tessera-ctl && npx biome lint --reporter=json --max-diagnostics=2000 apps/api/src | python3 -c "import json,sys; d=json.load(sys.stdin)['diagnostics']; a=sum(1 for x in d if x.get('category')=='lint/suspicious/noExplicitAny'); n=sum(1 for x in d if x.get('category')=='lint/style/noNonNullAssertion'); e=sum(1 for x in d if x.get('severity')=='error'); print('any',a,'nonnull',n,'error',e); sys.exit(0 if a<=155 and n<=56 and e==0 else 1)"
cd /home/vicolab/projects/tessera-ctl && test "$(grep -rho 'ts-expect-error' apps/api/src --include='*.ts' | wc -l)" = 0 && test "$(grep -rho 'ts-ignore' apps/api/src --include='*.ts' | wc -l)" = 0 && test "$(grep -rho 'biome-ignore' apps/api/src --include='*.ts' | wc -l)" = 1 && test "$(grep -rho 'as unknown as' apps/api/src --include='*.ts' | wc -l)" -le 33 && echo "keine stillgelegten Stellen"
cd /home/vicolab/projects/tessera-ctl && pnpm type-check 2>&1 | grep -q '4 successful, 4 total' && pnpm lint 2>&1 | grep -q '5 successful, 5 total' && echo "type-check 4/4, lint 5/5"
cd /home/vicolab/projects/tessera-ctl && pnpm --dir apps/api run test 2>&1 | tee /tmp/m34-api.log | tail -5 && grep -q 'Test Files 72 passed (72)' /tmp/m34-api.log && grep -q 'Tests 1143 passed (1143)' /tmp/m34-api.log && echo "api 72/1143 gruen"
cd /home/vicolab/projects/tessera-ctl && pnpm --dir apps/web run test 2>&1 | tee /tmp/m34-web.log | tail -5 && grep -q 'Test Files 73 passed (73)' /tmp/m34-web.log && grep -q 'Tests 531 passed (531)' /tmp/m34-web.log && echo "web 73/531 gruen"
cd /home/vicolab/projects/tessera-ctl && pnpm --dir apps/api exec vitest run src/prisma/rls-access-inventory.spec.ts 2>&1 | tail -4
Die Zaehlung liegt bei hoechstens 155 (Ausgang 288), `noNonNullAssertion` bei hoechstens 56, `error`-Befunde bei 0, keine neuen Unterdrueckungsmarker. `pnpm type-check` 4/4 und `pnpm lint` 5/5. Beide Testsuiten unveraendert gruen (72/1143 und 73/531), `rls-access-inventory.spec.ts` ausdruecklich gruen. Der Kopfkommentar von `prisma-tenant.extension.ts` ist unveraendert. Jede Stelle, an der ein neuer `tsc`-Fehler auftrat, steht als Befund im SUMMARY.
Aufgabe 2: Wer ruft hier eigentlich an — ein gemeinsamer Typ fuer Aufrufer, Request und Upload
apps/api/src/auth/types/auth-user.ts, apps/api/src/auth/auth.controller.ts, apps/api/src/auth/auth.service.ts, apps/api/src/auth/strategies/jwt.strategy.ts, apps/api/src/auth/strategies/local.strategy.ts, apps/api/src/auth/interceptors/force-password-change.interceptor.ts, apps/api/src/bug-reports/bug-reports.controller.ts, apps/api/src/bug-reports/bug-reports.service.ts, apps/api/src/calendar/calendar.controller.ts, apps/api/src/cert-manager/cert-manager.controller.ts, apps/api/src/cert-manager/cert-manager.service.ts, apps/api/src/dashboard/dashboard.controller.ts, apps/api/src/dkv/dkv.controller.ts, apps/api/src/favorites/favorites.controller.ts, apps/api/src/groups/groups.controller.ts, apps/api/src/groups/module-grants.controller.ts, apps/api/src/ldap/ldap.controller.ts, apps/api/src/module-registry/module-registry.controller.ts, apps/api/src/settings/settings.controller.ts, apps/api/src/tenders/tenders.controller.ts, apps/api/src/user/user.controller.ts, apps/api/src/auth/auth.controller.spec.ts, apps/api/src/user/user.controller.spec.ts
apps/api/src/auth/strategies/jwt.strategy.ts (die Quelle des Aufrufer-Objekts), apps/api/src/auth/auth.service.ts Zeile 165-200 und 370-395 (die beiden Stellen, die das Token signieren — sie bestimmen, welche Felder es ueberhaupt gibt), apps/api/src/tenant/tenant.guard.ts (der Verbraucher, dessen Pruefungen scharf bleiben muessen), apps/api/src/bug-reports/bug-reports.service.ts Zeile 55-90 (`SessionUser` und `UploadedPng` — das Vorbild im Bestand fuer schmale, nur die gelesenen Felder beschreibende Schnittstellen), apps/api/prisma/schema.prisma Zeile 23-53 (Role-Aufzaehlung und User-Modell)
- `AuthUser` beschreibt genau die fuenf Felder, die `JwtStrategy.validate()` zurueckgibt, und keines mehr.
- Jedes Feld ist aus den beiden Signierstellen in `auth.service.ts` belegbar; kein Feld ist erfunden.
- `AuthenticatedRequest` beschreibt `user` (von Passport gesetzt) und `tenantId` (von `TenantGuard` gesetzt, dort auch auf `null` gesetzt) mit den Optionalitaeten, die diese beiden Setzer tatsaechlich erzeugen.
- `UploadedFileLike` beschreibt ausschliesslich die Felder, die der Code liest: `buffer`, `originalname`, `mimetype`, `size`.
- Keine bestehende Berechtigungspruefung wird durch die Typisierung tot, und keine wird entfernt.
Das ist die Aufgabe mit Sicherheitsfolgen. Sie faellt den einen Typ, an dem jede Mandanten- und Rollenentscheidung der API haengt. Deshalb wird hier nichts geraten.
Schritt A — die Typen anlegen, in `apps/api/src/auth/types/auth-user.ts`. Vorbild ist ausdruecklich `SessionUser`/`UploadedPng` in `bug-reports.service.ts`: schmale Schnittstellen, die nur beschreiben, was gelesen wird. Ziehe `SessionUser` und `UploadedPng` danach auf die neuen Typen zurueck, damit es keine zwei konkurrierenden Beschreibungen desselben Objekts gibt.
- `AuthUser` — leite jedes einzelne Feld aus `JwtStrategy.validate()` (Zeile 27-38) ab und belege es an den **beiden** Signierstellen `auth.service.ts:171` und `auth.service.ts:376`. Felder: `id`, `username`, `role`, `tenantId`, `mustChangePassword`.
- Zu `role`: die Aufzaehlung `Role` aus `@prisma/client` ist der ehrliche Typ, weil der Signierer genau den Spaltenwert schreibt. Gemessen: sechs Controller vertragen `role: Role` ohne einen einzigen Fehler. Wenn `tsc` irgendwo doch anschlaegt, weil eine Stelle gegen eine Zeichenkette ausserhalb der Aufzaehlung vergleicht, ist das ein Befund nach D-03 und wird gemeldet, nicht mit einer Zusicherung beruhigt (T-M34-02).
- **Zu `tenantId` — das ist die sicherheitsrelevante Entscheidung dieses Plans.** Die Messung: `tenantId: string | undefined` erzeugt acht Fehler im Produktivcode, `tenantId: string` keinen. Das ist **kein** Argument fuer die bequemere Variante. Pruefe stattdessen die Belege: die Spalte `User.tenantId` ist in `schema.prisma` **nicht optional**; der Bestand beschreibt dasselbe Objekt in `SessionUser` bereits als `tenantId: string`; und `TenantGuard` haelt fuer SUPER_ADMIN einen Zweig ohne Mandanten vor. Entscheide auf dieser Grundlage und schreibe die Begruendung als Kommentar an den Typ. Wenn die Wahl auf `string` faellt, halte im selben Kommentar ausdruecklich fest, dass der SUPER_ADMIN-Zweig in `TenantGuard` ein Schutzzweig bleibt und **nicht** wegtypisiert oder entfernt werden darf, weil er sonst bei der naechsten Aenderung als toter Code geloescht wird (T-M34-01). Entferne an `TenantGuard` in dieser Aufgabe nichts.
- `AuthenticatedRequest` — erweitert `Request` aus `express` um `user` und `tenantId`. `TenantGuard` setzt `tenantId` auf eine Zeichenkette **oder** auf `null` und laeuft auf oeffentlichen Wegen gar nicht; die Optionalitaeten muessen das abbilden.
- `UploadedFileLike` — `Express.Multer.File` gibt es in diesem Baum **nicht** (gemessen: `@types/multer` ist nicht installiert), und D-04 verbietet, es nachzuinstallieren. Eine eigene schmale Schnittstelle ist hier trotzdem keine Behauptung, sondern belegbar: kein einziger `FileInterceptor`/`FilesInterceptor`-Aufruf setzt eine `storage`-Option, damit gilt multer memoryStorage, damit ist `file.buffer` ein Buffer. Pruefe das nach und beschreibe **nur** die gelesenen Felder. Nimm kein Feld auf, das kein Aufrufer liest.
- `JwtPayload` — fuer `jwt.strategy.ts:27` (`payload: any`). Die Nutzlast ist an den beiden Signierstellen vollstaendig belegt; beschreibe sie danach. `mustChangePassword` wird dort bereits bewusst mit `=== true` gegen alte Token abgesichert (260921-fi3, D-01) — dieses Verhalten bleibt woertlich erhalten.
Schritt B — anwenden. Ersetze `@CurrentUser() user: any` (13 Stellen), `@Req() req: any` (25), `(req as any)` (32), `@Res() res: any` (1), `@UploadedFile()`/`@UploadedFiles()` samt der Dienst-Gegenstuecke `file?: any` und `files?: any[]` in `cert-manager.service.ts` (11). Zieh das Umfeld mit: `resolveTargetTenantId(currentUser: any)`, `resolveTargetUser(currentUser: any)`, `login(user: any)`, `validateUser(): Promise`, `local.strategy` `validate(): Promise`, `normalizePath(request: any)`, `intercept(): Observable` (dort ist `unknown` der ehrliche Typ), `dkv.controller` `_requireTenant(req: any)`.
Ein Nebengewinn, der mitzunehmen ist: in `cert-manager.service.ts` stehen heute rund 25 Zusicherungen der Form `file.buffer as Buffer` und `file.originalname as string`, die es nur gibt, weil `file` ein `any` ist. Mit der schmalen Schnittstelle fallen sie weg — der Zaehlwert `as unknown as` darf dabei nicht steigen.
**Der gemessene Befund:** `dashboard.controller.ts:74` gibt einen moeglicherweise fehlenden Aufrufer-Wert an etwas weiter, das ihn zwingend verlangt. Das ist eine falsche Annahme im Bestandscode. Melde sie im SUMMARY mit Datei, Zeile und dem, was der Code annimmt. Verhalten unveraendert lassen (D-03): keine neue Pruefung einbauen, die vorher nicht da war, und keinen Wert erfinden.
Schritt C — die Testfixtures. Gemessen: 28 `tsc`-Fehler in `auth.controller.spec.ts` und `user.controller.spec.ts`, weil die Fixtures Teilobjekte wie `{ id, tenantId, role }` uebergeben. Ergaenze die fehlenden Felder in den Fixtures. Das ist reine Fixture-Pflege: die Zahl der Testdateien und Tests darf sich **nicht** aendern (D-06), und keine Zusicherung in einer Testdatei darf den Fehler stattdessen zudecken. Die Testdatei-Ausnahme in `biome.json` bleibt unberuehrt (D-04).
cd /home/vicolab/projects/tessera-ctl && npx biome lint --reporter=json --max-diagnostics=2000 apps/api/src | python3 -c "import json,sys; d=json.load(sys.stdin)['diagnostics']; a=sum(1 for x in d if x.get('category')=='lint/suspicious/noExplicitAny'); n=sum(1 for x in d if x.get('category')=='lint/style/noNonNullAssertion'); e=sum(1 for x in d if x.get('severity')=='error'); print('any',a,'nonnull',n,'error',e); sys.exit(0 if a<=75 and n<=56 and e==0 else 1)"
cd /home/vicolab/projects/tessera-ctl && test "$(grep -rho 'ts-expect-error' apps/api/src --include='*.ts' | wc -l)" = 0 && test "$(grep -rho 'ts-ignore' apps/api/src --include='*.ts' | wc -l)" = 0 && test "$(grep -rho 'biome-ignore' apps/api/src --include='*.ts' | wc -l)" = 1 && test "$(grep -rho 'as unknown as' apps/api/src --include='*.ts' | wc -l)" -le 33 && echo "keine stillgelegten Stellen"
cd /home/vicolab/projects/tessera-ctl && git diff --stat -- apps/api/src/tenant/tenant.guard.ts > /tmp/m34-guard.txt || exit 1; cat /tmp/m34-guard.txt; test ! -s /tmp/m34-guard.txt && echo "TenantGuard unberuehrt"
cd /home/vicolab/projects/tessera-ctl && pnpm type-check 2>&1 | grep -q '4 successful, 4 total' && pnpm lint 2>&1 | grep -q '5 successful, 5 total' && echo "type-check 4/4, lint 5/5"
cd /home/vicolab/projects/tessera-ctl && pnpm --dir apps/api run test 2>&1 | tee /tmp/m34-api.log | tail -5 && grep -q 'Test Files 72 passed (72)' /tmp/m34-api.log && grep -q 'Tests 1143 passed (1143)' /tmp/m34-api.log && echo "api 72/1143 gruen"
cd /home/vicolab/projects/tessera-ctl && pnpm --dir apps/web run test 2>&1 | tee /tmp/m34-web.log | tail -5 && grep -q 'Test Files 73 passed (73)' /tmp/m34-web.log && grep -q 'Tests 531 passed (531)' /tmp/m34-web.log && echo "web 73/531 gruen"
Die Zaehlung liegt bei hoechstens 75. `apps/api/src/auth/types/auth-user.ts` existiert und traegt die Begruendung jedes Feldes als Kommentar, mit Verweis auf die Signierstellen und auf `schema.prisma`. `tenant.guard.ts` ist unveraendert. `SessionUser`/`UploadedPng` in `bug-reports.service.ts` sind auf die neuen Typen zurueckgezogen, es gibt keine zweite Beschreibung desselben Objekts. `noNonNullAssertion` hoechstens 56, `as unknown as` hoechstens 33, keine neuen Unterdrueckungsmarker, 0 `error`-Befunde. Beide Suiten gruen mit unveraenderten Zahlen. Der Befund aus `dashboard.controller.ts:74` steht im SUMMARY.
Aufgabe 3: Die Randschicht — Fehlerfaenger auf unknown, und ein Urteil fuer jede Stelle, die bleibt
apps/api/src/calendar/calendar.service.ts, apps/api/src/calendar/providers/exchange.provider.ts, apps/api/src/cert-manager/cert-manager.service.ts, apps/api/src/dkv/dkv-scheduler.service.ts, apps/api/src/groups/groups.service.ts, apps/api/src/groups/module-grants.service.ts, apps/api/src/inbox/exchange-inbox.provider.ts, apps/api/src/inbox/imap.provider.ts, apps/api/src/ldap/ldap.service.ts, apps/api/src/mail/mail.service.ts, apps/api/src/auth/auth.service.ts, apps/api/src/tenders/tender-digest.scheduler.ts, apps/api/src/tenders/tender-email-config.service.ts, apps/api/src/tenders/tender-notification-pref.service.ts, apps/api/src/tenders/tender-saved-search.service.ts, apps/api/src/tenders/tender-scheduler.service.ts, apps/api/src/tenders/tender-triage.service.ts, apps/api/src/user/admin-seed.service.ts, apps/api/src/user/user.service.ts, apps/web/src/test/setup.ts
apps/api/src/cert-manager/cert-manager.service.ts Zeile 220-290 und 490-545 (node-forge-Umgang), apps/api/src/dkv/dkv-scheduler.service.ts Zeile 120-140 (der require-Umweg fuer CronJob aus 07-04), apps/api/src/inbox/imap.provider.ts Zeile 20-70, apps/api/src/groups/groups.service.ts Zeile 95-110 (ein typischer Fehlerfaenger mit Prisma-Fehlercode-Pruefung)
Der Rest, Form fuer Form. Hier ist die Erwartung ausdruecklich gemischt: ein Teil wird getypt, ein Teil bleibt — und das Bleiben ist ein vollwertiges Ergebnis, kein Versagen (D-01).
Schritt A — die 18 Fehlerfaenger. `catch (e: any)` wird zu `catch (e: unknown)` plus Eingrenzung an der Verwendungsstelle. Fast alle lesen `err.code` (Prisma-Fehlercodes wie P2025, P2002) oder `err.message`. Grenze ein, statt zu behaupten: eine Pruefung auf Objektform und Feld, oder `instanceof Error` fuer `message`. Der Bestand hat dafuer bereits ein Vorbild in `tender-matching.service.ts:123` (`(err as Error).message`) — das ist die schwaechere Variante; wo eine echte Eingrenzung ohne Aufwand moeglich ist, nimm die echte. Entscheidend: **welcher Zweig genommen wird, darf sich nicht aendern**. Ein Fehlerfaenger, der heute bei einem fremden Fehlerobjekt in den einen Zweig laeuft, muss das danach auch tun (D-03). Wo der gefangene Wert gar nicht gelesen wird, ist die Bindung ersatzlos zu streichen — genau das hat 260921-bi2 an einer Stelle bereits so gemacht.
Schritt B — die Stellen mit Fremdbibliotheken, einzeln beurteilt:
- **`addCronJob(..., job as any)`** in `dkv-scheduler.service.ts:136`, `tender-digest.scheduler.ts:86`, `tender-scheduler.service.ts:135`. **Gemessen: bleibt.** Ohne Zusicherung meldet `tsc` an allen drei Stellen, dass das lokale `job` die Form `{ start(): void }` hat, waehrend `addCronJob` einen vollstaendigen `CronJob` verlangt. Ursache ist der `require()`-Umweg aus 07-04 (pnpm-Isolation, `cron` ist eine mittelbare Abhaengigkeit). Das aufzuloesen hiesse, die Beschaffung der Klasse zu aendern — das waere eine Verhaltensaenderung und ist hier verboten (D-03), und eine neue Abhaengigkeit ist ebenfalls verboten (D-04). Trage das Urteil samt dieser Begruendung als kurzen Kommentar an jeder der drei Stellen ein.
- **node-forge in `cert-manager.service.ts`** (11 Stellen: `p7: any` viermal, `cert.publicKey as any`, `cert.siginfo as any`, `sanExt as any`, `(n: any)`, `null as any` zweimal). `@types/node-forge` **ist** installiert. Pruefe Stelle fuer Stelle, ob der mitgelieferte Typ passt. Wo er passt: typisieren. Wo die mitgelieferten Typen die Bibliothek nachweislich falsch beschreiben — die beiden `null as any` tragen bereits den Vermerk, dass node-forge 1.4.0 einen fehlenden Schluessel akzeptiert, die Typen das aber ausschliessen — ist `bleibt` das ehrliche Urteil. Erzwinge dort nichts: eine Umdeutung ueber zwei Stufen waere schlimmer als das `any`, weil sie dieselbe Luecke verdeckt und zusaetzlich so aussieht, als sei sie geprueft (D-02).
- **httpntlm** (`exchange-inbox.provider.ts:3` und `:241`, `exchange.provider.ts:6`) und **`authProvider`-Rueckrufe** (`exchange.provider.ts:157`, `:313`). Die Handschnittstelle fuer `post` existiert schon; sie kann enger werden, weil der Code genau weiss, welche Optionen er uebergibt und welche Antwortfelder er liest. Beschreibe nur diese. Der Fehlerparameter eines Rueckrufs ist ehrlich `Error | null`.
- **imap** (`stream as any`, `node as any` zweimal, `} as any`) und **`nodemailer.createTransport(resolved.options as any)`**: einzeln pruefen. Wo eine Eingrenzung reicht, eingrenzen; sonst Urteil `bleibt` mit Begruendung.
- **`(response as any).cookie(...)`** in `auth.service.ts:384`: `Response` aus `express` ist in dieser Datei bereits importiert und wird an der Schwesterstelle `auth.service.ts:181` ohne Zusicherung benutzt. Pruefe, ob die Zusicherung schlicht ueberfluessig ist.
- **`data as any`** in `calendar.service.ts:206` und `:255`: Prisma-JSON-Eingaben. `Prisma.InputJsonValue` ist der vorgesehene Typ; pruefe, ob er traegt.
- **`let created: any` / `const updateData: any`** in `user.service.ts:110` und `:161`: Prismas erzeugte Typen decken beides ab.
- **`apps/web/src/test/setup.ts:8`** (`expect.extend(matchers as any)`): der einzige Befund ausserhalb von apps/api. Die Datei heisst `setup.ts` und faellt deshalb **nicht** unter die Testdatei-Ausnahme in `biome.json` — diese Ausnahme wird nicht erweitert (D-04). Beurteile die Stelle: passen die jest-dom-Matcher-Typen auf Vitests `expect.extend`, oder ist das ein bekannter Versatz zwischen den beiden Typwelten? Urteil eintragen, so oder so.
Schritt C — das Urteilsregister. Fuehre im SUMMARY eine Tabelle mit **jeder** Stelle, die stehen bleibt: Datei, Zeile, Form, Begruendung. Das ist der Zweck der ganzen Aufgabe — ein spaeterer Durchlauf soll diese Stellen nicht noch einmal aufreissen, sondern nachlesen koennen, warum sie so sind. Ergaenze zusaetzlich je eine kurze Begruendungszeile direkt an der Codestelle, damit die Auskunft auch dort steht, wo jemand sie zuerst sucht. Diese Zeilen sind normale erklaerende Kommentare; sie duerfen **keinen** Marker enthalten, der die Pruefung stilllegt — die Zaehlwerte in `` fangen das ab, und der Befund soll ja sichtbar bleiben (D-01, D-05).
Halte ausserdem im SUMMARY fest: Ausgangszahl 288, Endzahl, und die Aufteilung der 288 auf die drei Urteile.
cd /home/vicolab/projects/tessera-ctl && npx biome lint --reporter=json --max-diagnostics=2000 apps/api/src | python3 -c "import json,sys; d=json.load(sys.stdin)['diagnostics']; a=sum(1 for x in d if x.get('category')=='lint/suspicious/noExplicitAny'); n=sum(1 for x in d if x.get('category')=='lint/style/noNonNullAssertion'); e=sum(1 for x in d if x.get('severity')=='error'); print('any',a,'nonnull',n,'error',e); sys.exit(0 if a<=45 and n<=56 and e==0 else 1)"
cd /home/vicolab/projects/tessera-ctl && npx biome lint --reporter=json --max-diagnostics=2000 apps/api/src apps/web/src | python3 -c "import json,sys; d=json.load(sys.stdin)['diagnostics']; [print(x['location']['path']+':'+str(x['location']['start']['line'])) for x in d if x.get('category')=='lint/suspicious/noExplicitAny']" | sort > /tmp/m34-rest.txt; wc -l < /tmp/m34-rest.txt; echo "--- jede dieser Zeilen muss im Urteilsregister des SUMMARY stehen ---"; cat /tmp/m34-rest.txt
cd /home/vicolab/projects/tessera-ctl && test "$(grep -rho 'ts-expect-error' apps/api/src apps/web/src --include='*.ts' --include='*.tsx' | wc -l)" = 0 && test "$(grep -rho 'ts-ignore' apps/api/src apps/web/src --include='*.ts' --include='*.tsx' | wc -l)" = 0 && test "$(grep -rho 'biome-ignore' apps/api/src --include='*.ts' | wc -l)" = 1 && test "$(grep -rho 'as unknown as' apps/api/src --include='*.ts' | wc -l)" -le 33 && echo "keine stillgelegten Stellen"
cd /home/vicolab/projects/tessera-ctl && git diff --stat HEAD -- biome.json > /tmp/m34-biome.txt || exit 1; cat /tmp/m34-biome.txt; test ! -s /tmp/m34-biome.txt && echo "biome.json unberuehrt"
cd /home/vicolab/projects/tessera-ctl && git diff --stat HEAD -- package.json apps/api/package.json apps/web/package.json pnpm-lock.yaml > /tmp/m34-deps.txt || exit 1; cat /tmp/m34-deps.txt; test ! -s /tmp/m34-deps.txt && echo "keine neuen Abhaengigkeiten, keine Versionsspruenge"
cd /home/vicolab/projects/tessera-ctl && pnpm type-check 2>&1 | grep -q '4 successful, 4 total' && pnpm lint 2>&1 | grep -q '5 successful, 5 total' && echo "type-check 4/4, lint 5/5"
cd /home/vicolab/projects/tessera-ctl && pnpm --dir apps/api run test 2>&1 | tee /tmp/m34-api.log | tail -5 && grep -q 'Test Files 72 passed (72)' /tmp/m34-api.log && grep -q 'Tests 1143 passed (1143)' /tmp/m34-api.log && echo "api 72/1143 gruen"
cd /home/vicolab/projects/tessera-ctl && pnpm --dir apps/web run test 2>&1 | tee /tmp/m34-web.log | tail -5 && grep -q 'Test Files 73 passed (73)' /tmp/m34-web.log && grep -q 'Tests 531 passed (531)' /tmp/m34-web.log && echo "web 73/531 gruen"
Die Zaehlung in `apps/api` liegt bei hoechstens 45. Jede verbleibende Stelle aus der Ausgabe der zweiten Pruefung steht mit Datei, Zeile und Begruendung im Urteilsregister des SUMMARY und traegt eine Begruendungszeile im Code. `biome.json`, die `package.json`-Dateien und `pnpm-lock.yaml` sind unveraendert. `noNonNullAssertion` hoechstens 56, `as unknown as` hoechstens 33, keine Unterdrueckungsmarker ueber den einen Bestandsmarker hinaus, 0 `error`-Befunde. `pnpm type-check` 4/4, `pnpm lint` 5/5, beide Suiten gruen mit unveraenderten Zahlen. Das SUMMARY nennt Ausgangszahl 288, Endzahl und die Aufteilung auf die drei Urteile.
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser -> API (JWT-Cookie) | Das Sitzungstoken traegt Identitaet, Rolle und Mandant. `JwtStrategy.validate()` ist die Stelle, an der daraus ein Objekt wird, das jede spaetere Berechtigungsentscheidung traegt. |
| API -> PostgreSQL (RLS) | `forTenant()`/`forSystem()` setzen die Sitzungsvariablen, an denen die Zeilenregeln haengen. Wer hier die Bindung verliert, sieht fremde Mandanten oder gar nichts. |
| Browser -> API (multipart) | Hochgeladene Dateien werden als Zertifikate, CSV und Bilder weiterverarbeitet. |
| Aufrufer-Objekt -> TenantGuard/RolesGuard | Die Typen der Felder entscheiden mit, ob eine bestehende Pruefung als lebendig oder als tot gelesen wird. |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-M34-01 | Elevation of Privilege | `apps/api/src/auth/types/auth-user.ts` -> `tenant.guard.ts` | high | mitigate | `tenantId` wird nicht nach Bequemlichkeit getypt. Aufgabe 2 verlangt die Herleitung aus drei Belegen (Spalte `User.tenantId` in `schema.prisma`, Bestandstyp `SessionUser`, SUPER_ADMIN-Zweig in `TenantGuard`) und einen Kommentar am Typ, der festhaelt, dass der Schutzzweig in `TenantGuard` nicht wegtypisiert werden darf. `` erzwingt zusaetzlich, dass `tenant.guard.ts` in dieser Aufgabe unveraendert bleibt (`git diff --stat` leer). |
| T-M34-02 | Elevation of Privilege | `role`-Feld, alle Rollenvergleiche | medium | mitigate | `role` als `Role`-Aufzaehlung macht jeden Vergleich gegen eine Zeichenkette ausserhalb der Aufzaehlung zum Compilerfehler. Jeder solche Fehler ist nach D-03 ein zu meldender Befund und darf nicht per Zusicherung beruhigt werden; die Zaehlwerte fuer `as unknown as` und Ausrufezeichen-Behauptungen in `` fangen den Umweg ab. |
| T-M34-03 | Tampering | `forTenant()`/`forSystem()`, 105 Aufrufstellen | high | mitigate | Die Zuweisungsform `const X = forTenant(` bleibt woertlich erhalten — nur die nachgestellte Zusicherung faellt. Der Erkenner `rls-access-inventory.spec.ts` und die exakten Zahlen in `FORSYSTEM_ALLOWED_CALL_SITES` sind die Kontrolle; Aufgabe 1 laesst diese Spec zusaetzlich einzeln laufen. Kein Aufruf wird zusammengefasst, verschoben oder hinzugefuegt. |
| T-M34-04 | Information Disclosure | `UploadedFileLike`, `cert-manager`/`user`/`bug-reports`/`dkv`-Uploads | medium | mitigate | Die Schnittstelle beschreibt ausschliesslich gelesene Felder und ist an der Konfiguration belegt (kein `storage`-Argument -> memoryStorage -> `buffer` ist ein Buffer). Sie ersetzt keine Pruefung: Groessengrenzen bleiben in den Interceptor-Optionen, die PNG-Signaturpruefung in `bug-reports.service.ts` bleibt, und D-03 verbietet jede Verhaltensaenderung an diesen Wegen. |
| T-M34-05 | Repudiation | Urteilsregister | low | mitigate | Ohne Register waere nach diesem Lauf nicht nachvollziehbar, welche Stelle geprueft und bewusst gelassen wurde und welche nur uebersehen wurde. Aufgabe 3 erzeugt das Register aus der maschinellen Restliste, nicht aus dem Gedaechtnis; die zweite Pruefung in `` druckt genau diese Liste aus. |
| T-M34-06 | Denial of Service | Fehlerfaenger, Umstellung auf `unknown` | medium | mitigate | Eine falsche Eingrenzung koennte einen Fehler kuenftig in einen anderen Zweig laufen lassen und damit einen Hintergrundlauf abbrechen, der heute weiterlaeuft. Aufgabe 3 schreibt ausdruecklich fest, dass die Zweigwahl unveraendert bleiben muss; die 1143 Tests in `apps/api` decken die Fehlerwege der betroffenen Dienste ab und muessen gruen bleiben. |
| T-M34-SC | Tampering | Lieferkette | low | accept | Dieser Plan installiert nichts. D-04 verbietet neue Abhaengigkeiten und Versionsspruenge; `` in Aufgabe 3 erzwingt, dass `package.json` und `pnpm-lock.yaml` unveraendert bleiben. Damit entsteht keine neue Lieferkettenflaeche, und das Paket-Echtheitstor ist nicht anwendbar. |
Nach jeder einzelnen Aufgabe, nicht nur am Ende (D-05, D-06):
1. `pnpm type-check` meldet `4 successful, 4 total`.
2. `pnpm lint` meldet `5 successful, 5 total`, Rueckgabewert 0.
3. Biome-JSON ueber `apps/api/src`: `severity == error` ist 0; `lint/style/noNonNullAssertion` hoechstens 56; `lint/suspicious/noExplicitAny` unter der Schranke der jeweiligen Aufgabe (155 / 75 / 45).
4. `grep`-Zaehlungen ueber `apps/api/src`: `ts-expect-error` 0, `ts-ignore` 0, Lint-Unterdrueckungsmarker 1, `as unknown as` hoechstens 33.
5. `apps/api` Vitest: 72 Dateien, 1143 Tests, alle gruen. `apps/web` Vitest: 73 Dateien, 531 Tests, alle gruen.
6. `biome.json`, `package.json` (alle drei), `pnpm-lock.yaml` unveraendert.
Gezaehlt wird ausschliesslich ueber das Feld `category` im JSON-Bericht von Biome, nie durch Textsuche nach `any` im Quelltext — eine Textsuche zaehlt Kommentare mit und waere damit selbst verfaelschend.
Vor dem Festschreiben: die erzeugten Dateien und Commit-Texte auf rohe Steuerzeichen pruefen. Das Schreibwerkzeug wandelt Folgen der Form Backslash-u-vier-Ziffern in das tatsaechliche Zeichen um; das ist heute fuenfmal passiert und hat Commits scheitern lassen. Solche Folgen, falls ueberhaupt noetig, ueber `python3` mit `chr(92)` erzeugen und die Rohbytes danach kontrollieren.
- Die Zahl der `any`-Befunde in `apps/api` ist von 288 auf hoechstens 45 gefallen, erwartet auf 20 bis 40.
- Jede verbleibende Stelle hat ein Urteil mit Begruendung, im SUMMARY und im Code.
- Kein Befund wurde durch Zusicherung, Ausrufezeichen-Behauptung, Unterdrueckungskommentar oder eine unbelegte Handschnittstelle stillgelegt (D-02) — nachgewiesen ueber die Zaehlwerte, nicht behauptet.
- Kein Verhalten der API hat sich geaendert (D-03); jede aufgedeckte falsche Annahme steht als Befund im SUMMARY, mindestens die beiden schon gemessenen (`tender-matching.service.ts:159`, `dashboard.controller.ts:74`).
- Der gemeinsame Aufrufer-Typ existiert, ist an den Signierstellen belegt, und `tenant.guard.ts` ist unveraendert.
- `pnpm type-check` 4/4 und `pnpm lint` 5/5 nach jeder Aufgabe, nicht nur am Ende (D-05).
- Beide Testsuiten gruen mit unveraenderten Zahlen (D-06).
- Drei Commits, einer je Aufgabe, jeder fuer sich gruen.