Files
AfiS-Aktensystem/docs/13-entscheidungen.md
T
HarlekinandClaude Opus 5 d160741237 Phase 3, erster Teil: Vorlagen-Bausteine und Schwärzung
Entschieden und festgehalten: jede Akte trägt Kurzvermerke und Dokumente, die
Fallakte zusätzlich ihre Abschnitte. Die Trennung aus docs/05 bleibt damit
bestehen.

Eine Vorlage ist jetzt eine geordnete Liste von Bausteinen. Manche nehmen eine
Eingabe entgegen, andere liefern festen Text mit Platzhaltern. Damit tragen
dieselbe Struktur ein Vorfallsbericht, der fast nur aus Eingaben besteht, und
ein Sonderarbeitsvertrag, der fast nur aus Paragraphen und Unterschriftenzeilen
besteht. Eine neue Dokumentart bleibt ein Datensatz, kein Deployment.

Der Schwärzungs-Parser gibt Segmente zurück, nicht den vollen Text mit einem
Sichtbarkeits-Flag: was der Client ausblenden müsste, hat er dann schon. Die
Balkenlänge wird auf ein Raster von acht Zeichen gerundet, sonst verrät sie,
wie lang der geschwärzte Name war.

Ein Test vergleicht den TypeScript-Parser gegen die SQL-Funktion
strip_redactions aus der Migration. Laufen die beiden auseinander, findet die
Suche einen Begriff, den die Ansicht verbirgt — und der Treffer allein verrät
ihn dann.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 12:39:35 +02:00

18 KiB

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.

Offen: Dokumentarten über die Fallakte hinaus

Noch nicht entschieden, noch nichts gebaut. Geprüft vor Phase 3, weil die Antwort den Zuschnitt von Phase 3 und 4 bestimmt.

Langfristig sollen weitere Dokumentarten dazukommen: Vorfallsberichte, Bußgelder, Sonderarbeitsverträge, Beurteilungen. Die Frage ist, ob das Modell das trägt, ohne dass für jede neue Art Code geschrieben wird.

Was heute schon trägt

Eine neue Fallart braucht keine Zeile Code. CaseTemplate.fields ist ein JSON-Feld, Vorlagen sind versioniert, und das Rendern der Abschnitte wird datengetrieben gebaut. Eine neue Vorlage anzulegen ist eine Zeile in der Datenbank, kein Deployment. Mit dem Präfix je Vorlage bekommt jede Fallart auch ihren eigenen Nummernkreis.

Was heute nicht trägt

CaseSection hängt fest an caseId. Alles, was kein Ermittlungsfall ist, hat keinen Platz. Ein Bußgeld gegen einen Mitarbeiter ist keine Ermittlung. Ein Sonderarbeitsvertrag erst recht nicht.

PersonnelFile.fields ist ein nacktes JSON-Feld ohne Vorlage. Ein Vertrag, der dort landet, hätte nichts von dem, was eine Fallakte selbstverständlich hat: keine Versionierung, keinen Diff, keine Begründungspflicht, kein minClearance je Feld, keinen Eintrag im Zugriffsprotokoll. Genau die Eigenschaften, um derentwillen das System gebaut wird.

Würde man Bußgelder und Verträge behelfsweise als Case führen, stünden sie in der Fallliste, in der Fallzählung und in der Suche nach Ermittlungen. Das ist kein Schönheits- fehler, sondern eine falsche Aussage über den Bestand.

Zwei Bauformen von Dokument, nicht eine

Die beiden vorliegenden Beispiele sind grundverschieden aufgebaut:

Vorfallsbericht Sonderarbeitsvertrag
Aufbau beschriftete Felder, ausgefüllt fester Vertragstext mit Platzhaltern
Vorlage liefert Feldliste den Wortlaut selbst, samt Paragraphen
Nutzer trägt ein Werte [Name], [Datum], [Tätigkeit]
Ausgabe eine Seite mehrere Seiten mit Unterschriftenzeilen

Die Feldtypen aus docs/05-akten-und-vorlagen.md decken den Bericht vollständig ab (multiselect für die Berichtsart, select für die Gefahreneinschätzung, date, person, longtext). Für den Vertrag reichen sie nicht: fester Fließtext, Überschriften und Unterschriftenzeilen sind keine Formularfelder. Dafür braucht die Vorlage einen Textkörper mit Platzhaltern statt einer Feldliste.

Entschieden

Jede Akte ist ein Behälter, der Inhalt sind Beiträge: Kurzvermerk oder Dokument. Das gilt für Fallakten, Personalakten und alles, was später dazukommt.

Die dritte Ebene bleibt: eine Fallakte behält zusätzlich ihre Abschnitte, also Vorlagenfelder direkt an der Akte, die „was wissen wir jetzt" beantworten. Die Trennung aus docs/05-akten-und-vorlagen.md gilt damit weiter. Eine Fallakte hat also:

Ebene Was
Kopfdaten Aktenzeichen, Titel, Status, Freigabestufe, Wiedervorlage
Abschnitte aktueller Sachstand aus der Vorlage der Fallart
Verlauf Kurzvermerke, chronologisch
Dokumente aus einer Vorlage erzeugt, versioniert, einzeln exportierbar

Empfehlung für den Aufbau

Eine gemeinsame Dokumentebene, additiv statt als Umbau:

  • DocumentTemplate — was heute CaseTemplate ist, plus scope (Fall, Person, eigenständig), numberPrefix, und kind (Formular oder Textkörper)
  • Document — eine Ausfertigung, mit Vorlage, Klassifizierung, Nummer und einem Besitzer: Fall, Personalakte oder nichts
  • DocumentSection und DocumentSectionVersion — dieselben Zusicherungen wie bei CaseSection: versioniert, minClearance je Feld, Diff, Protokoll

Case, CaseSection und CaseEntry bleiben unangetastet. Eine Fallakte kann Dokumente tragen, eine Personalakte auch, und ein Bußgeld steht für sich.

Damit ist eine neue Dokumentart wieder das, was sie sein soll: ein Datensatz, kein Deployment.

Zu klären, bevor gebaut wird: ob die Abschnitte einer Fallakte langfristig ebenfalls über diese Ebene laufen sollen. Dafür spräche, dass es dann nur einen Mechanismus gibt; dagegen, dass docs/04 und docs/05 CaseSection ausdrücklich benennen und der Umbau bestehende Akten anfasst.

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.