Aus den echten Dokumenten der Abteilung, die bisher als Google Docs geführt wurden. Die Dokumente tragen zwei Angaben nebeneinander, "Foundation-Freigabe" und "Interne Freigabe". Den Zugriff steuert allein die interne Freigabe; die Foundation-Freigabe wird ein gewöhnliches Vorlagenfeld. Mit zwei sperrenden Achsen bekäme jede Sichtbarkeitsprüfung, jede Trefferzahl und jeder Negativtest eine zweite Dimension — Sichtbarkeit wird an genau einer Stelle entschieden, sonst ist sie nicht mehr nachweisbar. Das Aktenzeichen der Abteilung lautet AKT-PA-2026-62843, das Glossar schrieb AfiS-2026-0042 vor. Das Muster gehört zur Vorlage, nicht ins Programm: Präfix und Stellenzahl je Fallart, Nummernkreis je Präfix und Jahr. docs/08 kannte nur den Export einer ganzen Akte. Dokumente wie der Sonderarbeitsvertrag hängen aber an einer Person, nicht an einer Ermittlung, und brauchen einen eigenen Weg: Deckblatt, Dokument, Fußzeile, kein Verlauf. Stufe des Abrufers, Wasserzeichen und Protokollierung gelten dort genauso. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
225 lines
14 KiB
Markdown
225 lines
14 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.
|
|
|
|
## Fallakten werden nicht gelöscht
|
|
|
|
`03-auth-und-rechte.md` führte ein Recht `case.delete`, der Lebenszyklus in
|
|
`05-akten-und-vorlagen.md` kennt aber kein Löschen, nur `ARCHIVED` und `SEALED`. Dazu kommt
|
|
eine technische Sperre: eine Akte mit Verlaufseinträgen lässt sich gar nicht hart löschen,
|
|
weil `CaseEntry` nach harter Regel 2 unlöschbar ist und der Fremdschlüssel das Kaskadieren
|
|
blockiert.
|
|
|
|
Aufgelöst zugunsten des Lebenszyklus: **`case.delete` ist gestrichen.** Eine Akte, die weg
|
|
soll, wird archiviert (schreibgeschützt) oder versiegelt (nur Stufe 5 und Admin). Nichts
|
|
verschwindet je aus einer Akte. Das ist die konsequente Fortsetzung von Regel 2 auf die Ebene
|
|
darüber und passt zu einer Abteilung, die Korruption aufdeckt.
|
|
|
|
## Hochstufen ist bei der eigenen Stufe gedeckelt
|
|
|
|
Niemand klassifiziert eine Akte höher, als seine eigene Freigabestufe reicht — auch nicht,
|
|
um einen Fund zu schützen. Wer Stufe 3 hat, stuft höchstens auf 3 ein; für mehr muss jemand
|
|
mit entsprechender Stufe ran.
|
|
|
|
Die Alternative wäre gewesen, das Hochstufen zu erlauben und den Zugriff dabei zu verlieren.
|
|
Das erzeugt aber Akten, die ihr Urheber nicht mehr sehen kann, und macht die Frage „wer
|
|
konnte das lesen" im Nachhinein schwer beantwortbar.
|
|
|
|
## Zwei Freigaben im Kopf, aber nur eine sperrt
|
|
|
|
Die Dokumente der Abteilung tragen zwei Angaben nebeneinander: „Foundation-Freigabe 3" und
|
|
„Interne Freigabe: Restricted Access". Das Modell kennt nur eine Stufe je Akte.
|
|
|
|
Festgelegt: **den Zugriff steuert allein die interne Freigabe**, also `Case.classification`
|
|
aus den Zugangsrollen des Discord-Servers. Die Foundation-Freigabe ist ein gewöhnliches
|
|
Vorlagenfeld — sie steht im Kopf des Dokuments und im PDF, sperrt aber nichts.
|
|
|
|
Der Grund ist nicht Bequemlichkeit: mit zwei sperrenden Achsen bekäme jede
|
|
Sichtbarkeitsprüfung, jede Trefferzahl, jeder Facettenzähler und jeder Negativtest eine
|
|
zweite Dimension. Sichtbarkeit wird an genau einer Stelle entschieden, sonst ist sie nicht
|
|
mehr nachweisbar.
|
|
|
|
## Aktenzeichen mit Präfix je Fallart
|
|
|
|
Das Glossar schreibt `AfiS-<Jahr>-<4-stellig>` vor, die tatsächlichen Dokumente der
|
|
Abteilung tragen `AKT-PA-2026-62843` — Präfix nach Abteilung, fünfstellige Nummer.
|
|
|
|
Festgelegt: das Muster gehört zur **Vorlage**, nicht ins Programm. Jede Fallart bringt ihr
|
|
Präfix und ihre Stellenzahl mit, der Nummernkreis läuft je Präfix und Jahr. `CaseCounter`
|
|
bekommt dafür einen zusammengesetzten Schlüssel aus Präfix und Jahr statt nur dem Jahr.
|
|
Bestehende Aktenzeichen bleiben, wie sie sind.
|
|
|
|
## 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.
|