Das Formular der Personalabteilung ist übernommen, nicht durch eine Bildschirmansicht ersetzt: schwarzer Rahmen, Kopf mit Kennung und Klassifizierungsbalken, Raster aus fünf Spalten, Unterschriftenzeilen, Fußzeile. Es steht bewusst außerhalb des Design-Systems — es wird gedruckt und muss auf Papier aussehen wie das Blatt daneben. Datengetrieben bleibt es trotzdem. Kein Feld steht im Programm: sheetTitle und sheetSubtitle an der Vorlage, span für die Zellbreite, rows für das Lichtbild, dateKey für das Datum unter einer Unterschrift, slot für den Klassifizierungsbalken. Eine weitere Dokumentart mit layout = DOSSIER braucht keine Zeile Code. Der Balken zeigt die Foundation-Stufe aus der neuen Tabelle FoundationClearance. Sie ist Inhalt, kein Zugriff — was jemand sehen darf, entscheidet weiterhin allein die interne Stufe. Ohne Auswahl bleibt der Balken neutral, statt eine Einstufung zu behaupten. Die Dienstnummer ist entfallen, es gibt sie nicht. Der Status ist zu mehreren Etiketten geworden: "Gekündigt" und "Verhaftet" schließen einander nicht aus. Unbekannte Schlüssel werden verworfen und die Reihenfolge kommt aus der Konfiguration, nicht aus der Eingabe. PersonnelAssignment hängt AfiS-Mitglieder an eine Person — Aktenführung, Ermittler, Ansprechpartner — und ist zugleich der ausnahmsweise Zugang, wie CaseParticipant bei der Fallakte. Betroffen zu sein gibt weiterhin keinen Zugang: das ist eine Eigenschaft der Person, die Zuordnung eine Entscheidung der Abteilung. Fall und Person lassen sich von beiden Seiten verknüpfen und schon beim Anlegen einer Fallakte. Alle drei Wege laufen in addParticipant zusammen. Noch offen: das Lichtbild. Feldtyp und Platz stehen, hochladen kommt mit der Prüfkette in Phase 5 — ein Bild als Data-URL im Abschnittswert verstiesse gegen harte Regel 4. 102 Tests im Web-Paket, davon sechs auf dem gerenderten Blatt selbst. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
27 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 Anmeldungcategory— 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.
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 heuteCaseTemplateist, plusscope(Fall, Person, eigenständig),numberPrefix, undkind(Formular oder Textkörper)Document— eine Ausfertigung, mit Vorlage, Klassifizierung, Nummer und einem Besitzer: Fall, Personalakte oder nichtsDocumentSectionundDocumentSectionVersion— dieselben Zusicherungen wie beiCaseSection: versioniert,minClearanceje 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.
Gebaut in Phase 4
Umgesetzt wie empfohlen, additiv. Drei neue Modelle: Document, DocumentSection,
DocumentSectionVersion. Case, CaseSection und CaseEntry blieben unberührt.
Die offene Frage — ob die Abschnitte einer Fallakte langfristig ebenfalls über diese Ebene
laufen sollen — ist verneint. Dafür hätte gesprochen, dass es dann nur einen Mechanismus
gibt. Dagegen sprach mehr: docs/04 und docs/05 benennen CaseSection ausdrücklich, der
Umbau fasst bestehende Akten an, und die Regeln sind ohnehin dieselben. Statt die Tabellen
zusammenzulegen, liegen die Regeln jetzt einmal in lib/sections/render.ts und werden von
beiden Diensten aufgerufen. Dieselbe Darstellung, dieselben Zusicherungen, zwei Tabellen.
Zwei Umbenennungen kamen dazu, beide von Hand migriert, damit die vorhandenen Zeilen stehen bleiben:
| Vorher | Jetzt | Warum |
|---|---|---|
CaseTemplate |
DocumentTemplate |
Eine Vorlage beschreibt jetzt auch Dokumente. Ein Sonderarbeitsvertrag als CaseTemplate wäre eine falsche Aussage |
CaseCounter |
NumberSeries |
Aktenzeichen und Dokumentnummern laufen über denselben Zähler, getrennt durch das Präfix aus der Vorlage |
DocumentTemplate.uses ist eine Liste, kein Einzelwert: dieselbe Vorlage kann an mehreren
Stellen sinnvoll sein. Der Vermerk hängt an einer Fallakte und an einer Personalakte, der
Bußgeldbescheid an einer Person oder für sich allein. layout bestimmt allein das
Aussehen und wird beim Anlegen in das Dokument kopiert — ein späterer Umbau der Vorlage darf
ein ausgefertigtes Dokument nicht nachträglich anders aussehen lassen.
Ein Dokument hat höchstens einen Besitzer
caseId und personnelFileId sind beide optional, eine CHECK-Bedingung lässt nicht zu, dass
beide gesetzt sind. Zwei Besitzer wären eine Aussage über die Sichtbarkeit, die niemand
prüfen könnte: die Prüfung müsste sich für einen der beiden entscheiden, und welcher das
wäre, stünde nirgends.
Die Sichtbarkeit eines Dokuments hat deshalb zwei Bedingungen, die beide zutreffen müssen: der Besitzer muss lesbar sein, und das Dokument selbst muss es sein. Ein Vertrag der Stufe 1 an einer Personalakte der Stufe 4 bleibt verborgen — sonst verriete die Dokumentenliste die Existenz der Person.
Betroffen zu sein gibt keinen Zugang, zugeordnet zu sein schon
Die betroffene Person darf ihre eigene Akte nicht allein deshalb lesen, weil sie es ist. Der Gedanke lag nahe: die eigene Akte kennt man doch. Er ist trotzdem falsch. Eine Beurteilung auf Stufe 4 über einen Mitarbeiter der Stufe 1 wäre sonst genau für den einen Menschen lesbar, vor dem sie geschützt werden soll.
Den ausnahmsweisen Zugang gibt es trotzdem, nur über einen anderen Weg: PersonnelAssignment
trägt ein AfiS-Mitglied als Aktenführung, Ermittler oder Ansprechpartner an einer Person ein,
und wer dort steht, liest die Akte unabhängig von seiner Stufe. Das ist dieselbe Bauart wie
CaseParticipant bei der Fallakte: er entsteht nur durch eine bewusste Eintragung von jemandem,
der personnel.edit hat, und wird protokolliert.
Die beiden Wege dürfen nicht verwechselt werden. Der eine ist eine Eigenschaft der Person, der andere eine Entscheidung der Abteilung.
Rückverweise zählen nur, was sichtbar ist
Auf einer Personalakte stehen die Fälle, in denen die Person vorkommt. Diese Liste läuft
durch visibleCaseWhere, und die Zahl daneben entsteht aus dem gefilterten Ergebnis, nie
aus einem eigenen count. Eine Personalakte der Stufe 1, auf der „3 Fälle" steht, verriete
sonst die Existenz von Ermittlungen, die der Leser nicht sehen darf.
Auch die Rollengruppe verschwindet mit: stünde dort „Betroffener" ohne Eintrag darunter, wäre die Ermittlung gegen die Person verraten, ohne dass ein Aktenzeichen sichtbar wird.
Eine Person kann auf zwei Wegen an einem Fall hängen — über ihre Personalakte oder über ihr Discord-Konto. Beide zählen, sonst fehlen Fälle.
Der Name eines Beteiligten gehört zur Akte
listParticipants zeigt den Namen einer beteiligten Person auch dann, wenn der Leser deren
Personalakte nicht sehen darf. Nur der Verweis auf die Personalakte entfällt dann.
Das ist Absicht: wer ein Ermittlungsverfahren lesen darf, muss wissen, gegen wen es läuft. Der Name ist an dieser Stelle Inhalt der Fallakte, nicht Inhalt der Personalakte. Verborgen bleibt, was darüber hinaus über die Person aktenkundig ist.
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.
Vier neue Rechte für Dokumente
document.create, document.edit, document.finalize und document.retract. Die
Alternative wäre gewesen, Dokumente an case.edit und personnel.edit zu hängen — dann
hätte aber jeder, der eine Personalakte pflegen darf, auch Bußgeldbescheide ausstellen
können. Das ist nicht dieselbe Befugnis.
document.retract steht bei den zurückgehaltenen Rechten, zusammen mit den drei
Eintragsrechten. Gemeinsames Merkmal: sie greifen in bereits Geschriebenes ein.
Im Auslieferungszustand hat keine Rolle diese Rechte. Bis der Leiter sie im Adminbereich verteilt, legt nur der Administrator Dokumente an.
Die Personalakte ist ein Aktenblatt, kein Formular der Anwendung
Die Abteilung führt Personalakten bislang als ausgefülltes Blatt: schwarzer Rahmen, Kopf mit Kennung und Klassifizierungsbalken, ein Raster aus fünf Spalten, Unterschriftenzeilen, Fußzeile. Dieses Blatt ist übernommen worden, nicht durch eine Bildschirmansicht ersetzt.
Es steht damit bewusst außerhalb des Design-Systems aus docs/10-design-system.md. Kein
Farb-Token der Oberfläche kommt darin vor, die Maße stehen in Millimetern, und die Farben sind
fest. Der Grund ist einfach: das Blatt wird gedruckt und weitergereicht, und auf Papier muss es
aussehen wie das Blatt daneben. Ein Formular, das sich dem Bildschirmthema anpasst, ist kein
Formular mehr.
Es ist trotzdem datengetrieben
Kein einziges Feld steht im Programm. Das Blatt entsteht aus DocumentTemplate wie jedes
andere Dokument:
| Was | Woher |
|---|---|
| Überschriften im Kopf | sheetTitle, sheetSubtitle an der Vorlage |
| Welche Felder, in welcher Reihenfolge | fields |
| Breite einer Zelle im Raster | span, 1 bis 5 Spalten |
| Zeilen, über die das Lichtbild reicht | rows am Feld vom Typ photo |
| Datum unter einer Unterschrift | dateKey am Feld vom Typ signature |
| Inhalt des Klassifizierungsbalkens | Feld mit slot: 'classbar' |
Damit trägt dieselbe Bauform später einen Vorfallsbericht oder einen Bußgeldbescheid: eine
neue Vorlage mit layout = DOSSIER und passenden Beschriftungen genügt.
Der Umbruch in Zeilen ist keine freie Anordnung, sondern folgt der Reihenfolge der Felder:
solange die Lichtbildzelle mitläuft, ist eine Zeile vier Spalten breit, danach fünf. Genau so
verhält sich das Blatt der Abteilung. web/tests/aktenblatt.test.ts schreibt das Raster fest.
Der Klassifizierungsbalken ist Inhalt, kein Zugriff
Er zeigt die Foundation-Stufe (Vertraulich bis Thaumiel) aus FoundationClearance, ein
Feld der Vorlage. Was jemand sehen darf, entscheidet allein Document.classification gegen die
interne Stufe des Lesers. Das ist die Fortschreibung von „Zwei Freigaben im Kopf, aber nur eine
sperrt" auf das Blatt: die Stufe im Balken ist eine Aussage der Akte über sich selbst, keine
Zugriffsregel. Ohne gesetzte Stufe bleibt der Balken neutral dunkel, statt eine Einstufung zu
behaupten, die niemand vorgenommen hat.
Das Lichtbild fehlt noch
photo ist als Feldtyp vorhanden und das Blatt hat seinen Platz dafür, aber hochladen lässt
sich noch nichts. Der Wert eines Lichtbildfeldes soll die Kennung eines geprüften Anhangs
sein, und die Prüfkette — Magic Bytes, ClamAV, Ablage außerhalb der Datenbank — ist Phase 5.
Ein Bild als Data-URL in einem Abschnittswert abzulegen, wie es die Vorlage der Abteilung tut,
verstößt gegen harte Regel 4 und wäre nachträglich schwer wieder herauszubekommen.
Status als Etiketten, nicht als einzelner Wert
„Gekündigt" und „Verhaftet" schließen einander nicht aus, ein einzelnes Statusfeld zwingt aber
zur Wahl. PersonnelFile.statusTags ist deshalb eine Liste von Schlüsseln aus
PersonnelStatusTag, einer im Adminbereich pflegbaren Tabelle.
Zwei Eigenschaften sind nicht offensichtlich und deshalb festgeschrieben:
- Unbekannte Schlüssel werden verworfen. Ein manipuliertes Formular kann keinen erfundenen Status setzen, sonst wäre die Liste im Adminbereich nur eine Empfehlung.
- Die Reihenfolge kommt aus der Konfiguration, nicht aus der Eingabe. Auf jeder Akte steht dieselbe Abfolge, und zwei Akten mit denselben Etiketten sehen gleich aus.
Die Dienstnummer ist entfallen: es gibt sie in der Abteilung nicht. Sie steckte im Suchvektor und in einem Trigram-Index, beide sind mit ihr verschwunden.
Verknüpfen von beiden Seiten
Eine Person an einen Fall zu hängen ist ein Vorgang, aber zwei Blickrichtungen. Auf der
Fallakte sucht man die Person, auf der Personalakte den Fall, und beim Anlegen einer Fallakte
steht die betroffene Person gleich im Formular. Alle drei Wege laufen in addParticipant
zusammen — dieselbe Prüfung, derselbe Protokolleintrag.
Der Unterschied liegt in der Auswahl: auf der Fallakte eine Liste der sichtbaren Personen, auf der Personalakte ein Eingabefeld für das Aktenzeichen. Eine Liste aller sichtbaren Fälle auf einer Personalakte wäre am falschen Ort — sie beantwortet nicht die Frage, die man dort hat.