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>
178 lines
11 KiB
Markdown
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.
|