Files
AfiS-Aktensystem/docs/13-entscheidungen.md
T
HarlekinandClaude Opus 5 9c966b471e Tests schreiben nicht mehr in die Entwicklungsdatenbank
Die Testläufe hatten dort 70 erfundene Rollen, 112 Nutzer und 40 Akten
hinterlassen. Die Rollenverwaltung zeigte deshalb "Gast" siebzehnmal, echte
Rollen doppelt und in willkürlicher Reihenfolge.

Aufräumen allein reicht nicht: CaseEntry, CaseEntryVersion und AuditLog lassen
sich nach harter Regel 2 nicht löschen. Was ein Test dort anlegt, bleibt für
immer stehen — in Phase 2 hätte das die Fallliste unbrauchbar gemacht.

Tests laufen deshalb gegen <datenbank>_test auf demselben Server. pnpm test legt
sie an, migriert und seedet sie vorher. Über TEST_DATABASE_URL lässt sich ein
anderes Ziel setzen.

Dazu die Abschnitte der Rollenverwaltung in der Reihenfolge, in der die
Abteilung ihre Rollen aufzählt: Ränge, Zugehörigkeit, Zugang. Innerhalb eines
Abschnitts entscheidet die Position auf dem Discord-Server, oben die höchste.
Damit steht die Hierarchie an einer Stelle, die ohnehin gepflegt wird.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 21:11:40 +02:00

178 lines
11 KiB
Markdown

# Getroffene Entscheidungen
Stellen, an denen die Fachdokumente sich widersprechen, lückenhaft sind oder gegen eine
Eigenschaft von Postgres laufen. Jede Zeile nennt den Konflikt, die Entscheidung und den Grund.
Wo eine Entscheidung fachlich anders ausfallen kann, steht das ausdrücklich dabei.
## Struktur
| Konflikt | Entscheidung |
|---|---|
| `CLAUDE.md` und `README.md` verweisen auf `docs/`, die Dateien lagen im Wurzelverzeichnis | Nach `docs/` verschoben |
| `02-architektur.md` zeigt `app/` und `lib/` im Wurzelverzeichnis, `TASKS.md` Phase 0 verlangt einen pnpm-Workspace mit `web/` und `worker/` | Workspace mit `web/`, `worker/`, `pdf/` und `packages/db/`. Die Struktur aus `02-architektur.md` gilt innerhalb von `web/` |
| Der Worker braucht denselben Prisma-Client wie die Web-App | Eigenes Workspace-Paket `packages/db` (`@afis/db`), von `web` und `worker` benutzt. Migrationen und Seed liegen dort |
## Datenmodell
| Konflikt | Entscheidung |
|---|---|
| `CaseParticipant` hat den zusammengesetzten Primärschlüssel `[caseId, role, userId, personnelFileId]`, aber `userId` und `personnelFileId` sind beide optional. Postgres lässt NULL in einem Primärschlüssel nicht zu | Eigener Schlüssel `id`, Eindeutigkeit über zwei **partielle** Unique-Indizes plus eine CHECK-Bedingung, dass mindestens eines der beiden Felder gesetzt ist |
| `04-datenmodell.md` nennt zugleich `CREATE SEQUENCE case_number_2026` und die Tabelle `CaseCounter(year, lastNumber)` mit `SELECT … FOR UPDATE` | `CaseCounter`. Eine Sequenz pro Jahr müsste jedes Jahr von Hand angelegt werden, und der Abteilungsleiter ist ausdrücklich nicht technisch |
| Das rohe SQL benutzt `search_vector` und `updated_at` in Schlangenschrift, die Prisma-Modelle Höckerschrift | Prisma-Voreinstellung behalten: Tabellen in Höckerschrift-Pascal, Spalten in Höckerschrift. Das SQL quotet entsprechend (`"searchVector"`, `"updatedAt"`) |
| Kein Modell für Kanäle, obwohl `ChatMessage.channelId` existiert und `06-chat-und-realtime.md` Kanäle mit eigener Mindeststufe verlangt | Modell `Channel` ergänzt, dazu `ChatReaction` für den festen Reaktionssatz |
| `/api/health` soll den Discord-Gateway prüfen, aber die Web-App fragt nie live bei Discord an | Modell `ServiceHeartbeat`. Der Worker schreibt alle 30 Sekunden seinen Zustand, `web` liest ihn |
## Der wichtigste Widerspruch
`04-datenmodell.md` verlangt `CREATE RULE entry_version_no_update … DO INSTEAD NOTHING` auf
`CaseEntryVersion`. `05-akten-und-vorlagen.md` verlangt unter „Umklassifizierung wirkt
rückwirkend", dass beim Anheben von `minClearance` **alle bestehenden Versionen den neuen Wert
erben**. Beides zusammen geht nicht: die Regel würde genau die Nachführung verhindern, die das
Leck schließt.
Aufgelöst durch einen Trigger statt einer Regel. `CaseEntryVersion` bleibt anhängend, aber
`minClearance` darf nachgeführt werden, und nur nach oben. Jede andere Änderung wirft einen
Fehler. Das erhält beide Absichten: der Text ist unveränderlich, die Klassifizierung wirkt
rückwirkend.
Ein zweiter Trigger schützt `CaseEntry.authorId`, `createdAt`, `caseId` und `type` auf
Datenbankebene, nicht nur in der Anwendung.
## Suche
`08-suche-und-export.md` beschreibt einen `search_vector` je Akte aus Titel, Abschnitten und
Verlaufseinträgen. Ungeklärt bleibt, was mit Inhalten oberhalb der Stufe des Suchenden
passiert. Ein gemeinsamer Vektor über alle Abschnitte würde einen Nutzer mit Stufe 1 einen
Begriff finden lassen, der nur in einem als `[[c4]]…[[/c4]]` geschwärzten Bereich steht.
Das verstößt gegen harte Regel 6.
**Vorläufig aufgenommen wird nur, was jeder lesen darf, der die Akte überhaupt sehen darf:**
Aktenzeichen und Titel (Gewicht A), Abschnitte und Verlaufseinträge mit `minClearance = 0`
(B und C), Schwärzungs-Auszeichnungen vorher entfernt. Zurückgezogene Einträge fallen heraus.
Das ist sicher, aber für hohe Stufen zu sparsam: ein Nutzer mit Stufe 5 findet geheime Inhalte
nicht über die Volltextsuche. **Phase 7 muss das auflösen**, sinnvollerweise über getrennte
Vektorspalten je Stufe. Diese Entscheidung ist bewusst konservativ getroffen, weil ein Leck
hier schwerer zu bemerken ist als eine Lücke.
## Rollen auf dem Discord
Die Dokumente kennen genau eine Sorte Rolle: `RoleMapping` trägt Stufe **und** Rechte, die
Auflösung nimmt das Maximum der Stufen und die Vereinigung der Rechte. Auf dem Server gibt es
aber drei Sorten mit verschiedener Bedeutung:
| Sorte | Beispiele | Wirkung |
|---|---|---|
| Zugehörigkeit | `AfiS`, `Personalabteilung` | Türsteher. Ohne eine davon kein Login |
| Rang | `Director of internal Security` … `Probationary Agent` | Bestimmt die **Rechte** |
| Zugang | `Executive Oversight` … `Restricted Access` | Bestimmt die **Freigabestufe** |
Das bestehende Modell trägt das fast: Zugangsrollen bekommen eine Stufe und keine Rechte,
Rangrollen Rechte und Stufe 0, Maximum und Vereinigung ergeben das Richtige. Was fehlt, ist
der Türsteher. `RoleMapping` bekommt deshalb in Phase 1 zwei Felder:
- `grantsMembership` — diese Rolle berechtigt zur Anmeldung
- `category` — nur für die Gruppierung im Adminbereich, damit dort drei Abschnitte stehen
statt einer Liste aus achtzehn Rollen
Anmeldeprüfung damit: Mitglied der Guild **und** (eine Rolle mit `grantsMembership` **oder**
in `BOOTSTRAP_ADMIN_IDS`). Notfall-Admins umgehen die AfiS-Rolle, müssen aber Mitglied der
Guild bleiben — sonst bliebe die Hintertür auch nach einem Rauswurf offen.
Rangrollen bekommen im Auslieferungszustand **keine** Rechte. Das Rollen-Mapping entsteht
vollständig im Adminbereich, nichts wird nach Rollennamen geraten.
### Umzug vom Test- auf den echten Server
Entwickelt wird gegen einen Test-Discord mit nachgebauten Rollen. Rollen-IDs sind pro Server
eindeutig, beim Umzug ist das gesamte `RoleMapping` deshalb wertlos. Das ist kein Fehler,
sondern eine Anforderung an Phase 1: die Adminseite muss ein neu erkanntes `DISCORD_GUILD_ID`
verkraften und beim Übernehmen anbieten, die alte Zuordnung **anhand der Rollennamen**
vorzuschlagen. `RoleMapping.label` wird dafür ohnehin aus Discord gespiegelt.
## Der Ausschluss schlägt auch den Admin
`03-auth-und-rechte.md` nennt die Reihenfolge der Zugriffsprüfung: erst der
`CaseExclusion`, dann der Admin. Das Codebeispiel im selben Dokument gibt für Admins aber
`{}` zurück und übergeht den Ausschluss damit.
Umgesetzt ist die Reihenfolge, nicht das Beispiel. Der Ausschluss deckt gerade den Fall ab,
dass gegen einen hochrangigen Mitarbeiter ermittelt wird — und der Administrator ist einer.
Ein Admin, der sich selbst wieder Zugriff verschaffen will, muss dafür den Ausschluss
aufheben, und das steht dann im Protokoll.
## Versiegelte Akten im Where-Fragment
`05-akten-und-vorlagen.md` sagt, `SEALED` sei „nur mit Recht 'admin' oder Stufe 5 lesbar".
Das steht in `lib/access/caseFilter.ts` und nicht in einer Prüfung danach, sonst
verschwände die Akte zwar aus der Ansicht, aber nicht aus der Trefferzahl.
## Sprache der Fachbegriffe
`CLAUDE.md` verlangt eine deutsche Oberfläche. Die Begriffe der Domäne heißen auf dem Discord
aber englisch, und genau so werden sie im Rollenspiel gesprochen. Sie bleiben deshalb
englisch: Rollennamen, Dienstgrade und die Bezeichnungen der Freigabestufen.
Aus `docs/01-produkt.md` wird damit `Sensitive Access` statt `Geheim`. Die Bezeichnungen sind
Daten in `ClearanceLevelConfig`, nicht Oberflächentexte in `lib/i18n/de.ts` — sie sind im
Adminbereich änderbar, und ein Text, den der Leiter ändern kann, gehört nicht in eine
Übersetzungsdatei. `lib/i18n/de.ts` behält nur das Gerüst („Freigabestufe", „Geschwärzt,
ab Stufe 4 sichtbar").
## Tests laufen gegen eine eigene Datenbank
`CaseEntry`, `CaseEntryVersion` und `AuditLog` lassen sich nach harter Regel 2 nicht löschen.
Was ein Test dort anlegt, bleibt für immer stehen. Bei gemeinsamer Datenbank verstopfen die
Testakten binnen weniger Läufe die Fallliste, und aufräumen lässt sich das nicht mehr.
Tests laufen deshalb gegen `<datenbank>_test` auf demselben Server. `pnpm test` legt sie an,
migriert und seedet sie vorher (`pnpm db:test:prepare`). Über `TEST_DATABASE_URL` lässt sich
ein anderes Ziel setzen.
Das ist kein theoretisches Problem gewesen: die Testläufe der Phase 1 hatten in der
Entwicklungsdatenbank 70 erfundene Rollen, 112 Nutzer und 40 Akten hinterlassen, was die
Rollenverwaltung unbrauchbar machte.
## Prisma 7
Die Dokumente nennen nur „Prisma + PostgreSQL 16". Eingesetzt wird Prisma 7, die
aktuelle Fassung. Sie unterscheidet sich in drei Punkten, die den Aufbau betreffen:
| Änderung | Folge hier |
|---|---|
| Verbindungsdaten stehen nicht mehr im Schema | `packages/db/prisma.config.ts`. Migrationen laufen über `DIRECT_DATABASE_URL`, also an pgbouncer vorbei |
| Keine Rust-Query-Engine mehr, stattdessen Treiber-Adapter | `@prisma/adapter-pg` über `pg`. Der Treiber wird für `LISTEN/NOTIFY` ohnehin gebraucht, und die Images müssen keine Engine nachladen |
| Neuer Generator `prisma-client` statt `prisma-client-js` | Der Client wird als ECMAScript-Module nach `packages/db/src/generated` erzeugt statt nach `node_modules/.prisma`. Nötig mit `importFileExtension = "js"`, sonst schreibt er endungslose Importe, die Node im ESM-Modus nicht auflöst |
Der erzeugte Client ist ein Bauartefakt und liegt nicht im Repository.
`pnpm --filter @afis/db run build` erzeugt ihn, die CI tut das vor lint, typecheck und test.
Für `prisma migrate deploy` braucht es weiterhin eine Rust-Schema-Engine. Sie wird beim
Bauen des Web-Images heruntergeladen und liegt unter `/app/migrator` im Image. Beim
Containerstart wird nichts nachgeladen: ein Container, der nur startet, wenn gerade eine
Registry erreichbar ist, wäre auf einem Homeserver die falsche Bauart.
## Betrieb
| Konflikt | Entscheidung |
|---|---|
| `11-betrieb.md` verlangt „Jeder Dienst braucht einen Healthcheck", die abgedruckte `compose.yaml` hat für `db-backup` und `offsite-backup` keinen | Ergänzt. `db-backup` über seinen HTTP-Port 8080, `offsite-backup` über Cron-Prozess und `restic version` |
| Das Startkommando `npx prisma migrate deploy` würde im Container aus dem Netz nachladen | Auf die im Image unter `/app/migrator` mitgelieferte Prisma-CLI umgestellt, zusammen mit Schema, Migrationsordner und Schema-Engine |
| `pnpm deploy` zieht die optionalen Peers von `@prisma/client` mit ins Image (CLI, Studio, TypeScript, rund 240 MB) | `--no-optional` beim Bündeln. Zur Laufzeit wird nichts davon gebraucht |
## Protokollierung
`09-performance-und-ux.md` verlangt eine Puffertabelle für Audit-Einträge, die alle zwei
Sekunden gebündelt geschrieben wird, zugleich aber, dass schreibende Aktionen ihren Eintrag
**in derselben Transaktion** erzeugen. Umgesetzt wird beides getrennt: schreibende Aktionen
protokollieren synchron in der Transaktion, Lesezugriffe ab Stufe 3 laufen über einen Puffer.
Der Puffer liegt im Prozessspeicher, nicht in einer Tabelle — eine Puffertabelle hätte
dieselbe Verlustgefahr bei einem Absturz, aber zusätzliche Schreiblast. Kommt in Phase 2.
## Vorlagen im Seed
`CLAUDE.md` beschreibt `pnpm db:seed` als „Testdaten inkl. Vorlagen", `TASKS.md` ordnet die
vier Startvorlagen aber Phase 3 zu. Der Seed legt vorerst nur an, was die Anwendung zum Laufen
braucht: die sechs Freigabestufen, die Änderungsrichtlinie und den Aktenzeichen-Zähler.
Die Vorlagen kommen mit dem Vorlagen-Editor in Phase 3.