Files
AfiS-Aktensystem/docs/13-entscheidungen.md
T
HarlekinandClaude Opus 5 1d637bc85e Abteilungen als Ebene über der Personalakte
Punkt 3 der Wünsche.

Die Abteilung stand als Freitext in der Personalakte. Damit liess sich nichts
fragen: „SHD", „Shd" und „Sicherheitsdienst" waren drei Abteilungen. Sie ist
jetzt ein eigenes Modell, im Adminbereich verwaltbar, und die drei vorhandenen
Freitexte sind in der Migration zu Abteilungen geworden.

**Eine Fallakte trägt keine Abteilung.** Sie gehört zu einer, wenn eine
beteiligte Person ihr angehört — abgeleitet über CaseParticipant und die
Personalakte. Eine eingetragene Zuordnung wäre nach zwei Wochen falsch: Leute
wechseln, Beteiligte kommen dazu, und niemand pflegt das nach. Dasselbe gilt
für die Dokumente: ein Dokument unmittelbar an der Abteilung wäre halb
verdrahtet, weil die Vorlagen über TemplateUse nur Fall- und Personalakte
kennen.

Die Abteilungsseite zeigt Mitglieder, abgeleitete Fälle und Dokumente,
Vermerke, Chat und ein Kreisdiagramm der Fallarten; die Übersicht eines über
alle Abteilungen. Das Diagramm ist serverseitig gerendertes SVG — eine
Bibliothek wäre das Teuerste auf der Seite gewesen. Die Segmente sind
Abstufungen von --steel und nicht die Stufenfarben: ein Tortenstück in Oxidrot
würde als „streng geheim" gelesen.

Die Zusammenfassung ist die gefährliche Stelle. Eine Abteilungsseite fasst
zusammen, was einzeln längst geschützt ist, und eine Zusammenfassung, die nicht
filtert, verrät mehr als jede Einzelansicht. Deshalb läuft hier jede Liste und
jede Zahl durch dieselben Fragmente wie überall; ein Test prüft, dass die Summe
der Diagrammanteile mit der Länge der Liste übereinstimmt. Wo jemand
Personalakten gar nicht sehen darf, steht null und keine Null.

Vermerke erben Regel 2 unverändert: kein DELETE, Version bei jeder Änderung,
Grabstein bei jeder Zurücknahme, als Regel und Trigger in der Datenbank. Ohne
Korrekturkette, Anhänge und Karenzzeit — was Beweiswert hat, gehört in eine
Fallakte.

Nebenbei: das Zugriffsprotokoll hängt jetzt am dafür vorgesehenen Recht
audit.read statt an admin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 19:57:28 +02:00

66 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

Nachtrag: Channel ist inzwischen wieder entfernt, siehe „Freie Kanäle sind entfallen" weiter unten. | /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.

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.

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.

Dateien

Eigene Signaturtabelle statt file-type

docs/07-dateien-und-sicherheit.md nennt die Bibliothek file-type. Gebaut wurde eine eigene Tabelle in web/lib/files/magic.ts, aus drei Gründen:

  • Die Liste der erlaubten Typen ist kurz und geschlossen. Bei einer Positivliste hilft es nicht, hundert Formate erkennen zu können — was nicht in der Tabelle steht, fliegt ohnehin raus.
  • Der eigentliche Schutz ist nicht die Erkennung, sondern die Neukodierung danach. Ein Bild, das durch sharp gelaufen ist, besteht nur noch aus Pixeln.
  • Eine Abhängigkeit weniger, die auf einem Heimserver aktuell gehalten werden muss.

Die Tabelle ist streng: die Signatur muss an Position 0 stehen. Ein Vorspann vor %PDF- ist der klassische Weg, eine Datei für zwei Programme zugleich lesbar zu machen; die PDF-Spezifikation erlaubt ihn, wir nicht.

text/plain ist das Schlupfloch, und deshalb eng

Reiner Text hat keine Signatur. Er wird zuletzt geprüft und nur angenommen, wenn der ganze Inhalt gültiges UTF-8 ohne Steuerzeichen ist. Sonst wäre text/plain der Weg, durch den jedes Binärformat passt.

Eine Folge davon: eine Datei, die sowohl lesbarer Text als auch ein PDF sein will, wird als Text erkannt. Sie wird deshalb unter der Endung des erkannten Typs ausgeliefert — aus bericht.pdf wird bericht.txt. Damit landet sie beim Doppelklick in keinem PDF-Betrachter. Die Endung folgt grundsätzlich dem erkannten Typ, nie dem hochgeladenen Namen.

Ein ausgefallener Scanner macht eine Datei nicht sauber

Drei Ausgänge statt zwei:

Zustand Bedeutung
BLOCKED Als Schadsoftware oder unzulässiger Typ erkannt. Endgültig
FAILED Die Prüfung konnte nicht abgeschlossen werden — Scanner weg, qpdf fehlt, Zeitüberschreitung
CLEAN Die ganze Kette bestanden

Der Unterschied zwischen BLOCKED und FAILED ist der zwischen „ist gefährlich" und „wissen wir nicht". Nur das Zweite lässt sich von einem Administrator freigeben, und diese Freigabe wiederholt die Kette ohne den Scanner: die Typprüfung und die Neukodierung laufen dabei mit. Übersprungen wird nur, was ausgefallen war.

Damit gibt es keinen Schalter, der die Prüfung abschaltet. In der Entwicklung ohne clamav-Container landet jeder Upload auf FAILED und wird von Hand freigegeben — unbequem, aber ehrlich. Eine Umgebungsvariable, die Dateien ungeprüft durchwinkt, wäre genau die Art Abkürzung, die irgendwann in der Produktion steht.

Das Lichtbild ist eine Kennung, kein Bild

Der Wert eines photo-Feldes ist die Kennung eines geprüften Anhangs. Die Vorlage der Abteilung legt das Bild als Data-URL ab; im Abschnittswert wäre das eine ungeprüfte Datei mitten im Aktentext, die sich nachträglich kaum wieder herausbekommen liesse.

Aufgelöst wird serverseitig: der Browser bekommt eine Adresse oder gar nichts, nie eine Kennung zum Ausprobieren.

Die Verarbeitung läuft im Web-Dienst

compose.yaml hängt das Dateivolumen und den clamd-Socket an web, nicht an worker — die Prüfkette läuft deshalb dort, angestossen nach der Antwort auf den Upload. Der Nutzer sieht den Anhang sofort mit „Wird geprüft"; der Fortschrittsbalken zeigt allein die Übertragung. Kein Balken wartet auf einen Virenscanner, siehe docs/09-performance-und-ux.md.

Die Oberflächentexte sind nach Bereichen getrennt

lib/i18n/de.ts war ein einziges Objekt. Eine Client-Komponente zieht aber die ganze Datei ins Bündel, in der ihr Text steht — jede Ansicht trug damit die Texte aller anderen mit sich herum. Mit Phase 5 riss das Budget aus docs/09-performance-und-ux.md: 121 kB gegen 120 kB erlaubte.

Die Texte stehen jetzt in sechs Dateien nach Bereichen (shell, common, cases, people, documents, admin), und de.ts setzt sie nur noch zusammen. Serverkomponenten nehmen weiter de — was dorthin gezogen wird, verlässt den Server nicht. Client-Komponenten importieren den Bereich, den sie brauchen.

Das brachte die grösste Route von 121 auf 118 kB und wächst nicht mehr mit jedem neuen Bereich mit. Der Zuschnitt der sechs Gruppen folgt der gemeinsamen Verwendung, nicht der Grösse: was zusammen gebraucht wird, liegt zusammen.

Chat und Echtzeit

Ein Strom, Themen darin

docs/06 nennt GET /api/stream?topics=case:<id>,notifications. Gebaut wie beschrieben, mit den Themen case:<id>, channel:<id> und user:<id>. Der Ereignisstrom aus Phase 5 (/api/events, nur Dateiprüfungen) ist darin aufgegangen.

Zwei Prüfungen, beide nötig. Beim Verbinden wird gefiltert, welche der angefragten Themen der Nutzer überhaupt abonnieren darf — wer ein fremdes nennt, bekommt es nicht und erfährt auch nicht, ob es existiert. Und je Ereignis wird der Inhalt mit den Rechten des Empfängers geladen. Das zweite ist nicht nur Vorsicht: eine Umklassifizierung mitten in einer offenen Verbindung wirkt dadurch sofort, ohne dass jemand neu laden muss.

In der pg_notify-Nutzlast steht deshalb nie ein Inhalt, nur Thema, Art und Kennung. Ein Text darin wäre an jedem Filter vorbei.

Die Vorschaukarte ist der heikelste Ort im Chat

Ein Aktenzeichen im Fliesstext wird zur Karte. Sie wird serverseitig aufgelöst, und eine Akte, die der Leser nicht sehen darf, sieht darin genauso aus wie eine, die es gar nicht gibt. Nie den Titel mitschicken und im Client ausblenden — das ist der Fehler, den ein Aktensystem genau einmal macht.

Dasselbe gilt für nachkommende Nachrichten: der Strom-Endpunkt löst die Karten je Empfänger auf, nicht der Absender.

Nachrichten werden zurückgezogen, nicht gelöscht

Der Chat ist ausdrücklich kein Aktenbestandteil, deshalb darf hier bearbeitet werden, ohne dass eine Version entsteht. Eine Nachricht, auf die jemand geantwortet hat, darf trotzdem nicht spurlos verschwinden: withdrawnAt setzt einen Platzhalter, und eine Regel in der Datenbank verbietet DELETE. Autor, Zeitpunkt und Ort sind per Trigger unveränderlich.

Eine zurückgezogene Nachricht kommt ohne Text in der Oberfläche an. Nicht ausgeblendet — gar nicht erst geschickt.

Die Web-App entscheidet, der Worker liefert

Bei den Discord-Benachrichtigungen war die naheliegende Bauart, dem Worker die Kennungen zu geben und ihn nachschlagen zu lassen. Das hiesse, die Sichtbarkeitsprüfung ein zweites Mal zu bauen — und sie zweimal richtig zu haben, dauerhaft.

Stattdessen entscheidet die Web-App beim Einstellen in den Postausgang, wer die Meldung bekommt (eine Zeile je Empfänger) und was darin stehen darf. In meta liegt ein Titel nur, wenn er auch nach Discord darf; ab Stufe 3 liegt dort keiner. Der Worker setzt aus festen Bruchstücken einen Satz zusammen und hat keine Entscheidung mehr zu treffen.

Der Postausgang ist eine Tabelle, kein Aufruf: der Worker ist ein eigener Prozess und darf gerade neu starten. Eine Zeile, die nicht rausging, bleibt liegen. Angestossen wird er über notification_queued, der 30-Sekunden-Takt ist das Sicherheitsnetz — kein Einmal-Container, siehe harte Regel 8.

Die Empfängerprüfung ruft visibleCaseWhere je Kandidat auf, also eine Abfrage pro Person. Bei zehn bis fünfzig Leuten ist das billiger als der Fehler, die Regel ein zweites Mal zu schreiben und eine der beiden Fassungen später zu vergessen.

Eigene UUIDv7 statt Paket

Das Paket uuidv7 wog 2,6 kB im Bündel der Fallakte. Ersetzt durch web/lib/uuidv7.ts, rund sechzig Zeilen, mit dem Zähler in rand_a für die Reihenfolge innerhalb einer Millisekunde.

Der Grund ist nicht nur die Grösse: auf der lexikografischen Sortierbarkeit dieser Kennungen beruhen zwei Dinge — die Nachlieferung verpasster Nachrichten (id > seit) und die Reihenfolge im Chat ohne Sortierspalte. Diese Zusicherung steht jetzt ausdrücklich im eigenen Code und hat Tests, statt in einer Abhängigkeit vorausgesetzt zu werden.

Das Bündelbudget der Fallakte

Die Fallakte ist die dichteste Seite der Anwendung: Abschnitte, Verlauf, Beteiligte, Verknüpfungen, Dokumente, Anhänge, Chat. Mit Phase 6 riss sie das Budget aus docs/09 (122 kB gegen 120 kB erlaubte) und wurde in drei Schritten darunter gebracht:

Schritt Ersparnis
Chattexte aus lib/i18n/cases.ts in eine eigene Datei ~0,5 kB
Gemeinsames AddPanel statt viermal derselbe Aufklapp-Code ~1 kB
Eigene UUIDv7 statt Paket ~2,6 kB

Sie liegt jetzt bei 119,6 kB. Das ist knapp, und die Richtung stimmt nicht: jede weitere Insel auf dieser Seite kostet wieder. Der nächste Schritt, wenn es eng wird, ist kein weiteres Kürzen, sondern die Seite zu teilen — die Beteiligten, Verknüpfungen und Anhänge gehören fachlich zusammen und könnten hinter einen Reiter.

Der Chat wird bereits erst geladen, wenn er in Sichtweite kommt. Das zählt Next zwar weiter zur ersten Ladung, spart aber wirklich etwas: wer nur die Akte liest, baut keine offene Verbindung zum Ereignisstrom auf.

Beim Erweitern des Schemas zuerst nachsehen

In dieser Phase wurden NotifyMode und User.notifyMode ein zweites Mal angelegt — es gab beides schon seit dem Grundgerüst. Die Migration scheiterte, und die Datenbank stand halb geändert da; das Aufräumen war von Hand nötig.

Die Lehre steht hier, weil sie sich wiederholen wird: vor jedem neuen Modell, Enum oder Feld im Schema nachsehen, ob es das schon gibt. Das Schema ist über achthundert Zeilen lang, und der Teil, den man gerade nicht bearbeitet, ist der, den man vergisst.

Suche

Ein Vektor je Freigabestufe

Der Suchvektor aus Phase 2 enthielt nur, was jeder sehen darf (minClearance = 0). Das war leck-sicher und unbrauchbar zugleich: ab Stufe 1 fand die Suche nichts von dem, was einen Fall ausmacht. Der Kommentar in der Migration sagte schon, dass Phase 7 getrennte Vektoren je Stufe ergänzt — hier sind sie.

SearchIndex hält je Ziel sechs Zeilen, eine für jede Stufe. Die Zeile zur Stufe N enthält alles, was ein Leser dieser Stufe sehen darf: Kopfdaten, Abschnitte und Einträge bis einschliesslich N. Eine Suche nimmt genau eine Zeile — die zur eigenen Stufe — und kann deshalb nichts finden, was sie nicht lesen darf.

Sechs Zeilen je Fall statt einer ist bei ein paar hundert Akten kein Thema, und die Abfrage wird dadurch einfacher, nicht schwerer: kein DISTINCT, keine Aggregation über mehrere Zeilen, ein einzelner Gleichheitsvergleich auf minClearance.

Schwärzungen werden stufenweise aufgelöst

strip_redactions warf jede markierte Stelle weg. Für einen gestuften Index ist das falsch in beide Richtungen: ein Leser der Stufe 4 fände einen Namen nicht, den er auf der Seite im Klartext sieht, und ohne die Funktion fände ein Leser der Stufe 1 einen, den er dort nur als Balken sieht.

strip_redactions_above(text, stufe) entfernt deshalb nur, was über der Stufe liegt, und löst die übrigen Marker auf. Ein Durchgang je Stufe darüber, weil die Stufe als Ziffer im Marker steht und regexp_replace nicht auf einen Fund hin verzweigen kann.

Die Suchbedingung ist nicht die Zugriffsprüfung

Die Suche läuft in zwei Schritten, und der Unterschied ist der Punkt:

  1. Was passt — eine Abfrage auf SearchIndex, die höchstens 500 Kennungen nach Rang liefert.
  2. Was der Suchende sehen darf und wie viele es sind — eine Abfrage mit visibleCaseWhere und den Filtern. Treffer, Trefferzahl und Facettenzähler entstehen dort gemeinsam.

Der erste Schritt ist eine Suchbedingung, kein Filter im Sinne von docs/08 — die Zugriffsprüfung steht ausschliesslich im zweiten und stammt aus derselben Funktion wie in der Fallliste. Es gibt keine zweite Fassung davon, und es sollte auch nie eine geben: eine in SQL nachgebaute Sichtbarkeitsregel wäre genau die Sorte Duplikat, die irgendwann auseinanderläuft.

Die Begrenzung auf 500 kann bei sehr allgemeinen Begriffen etwas abschneiden. Die Oberfläche sagt das dann ausdrücklich, statt eine vollständige Liste vorzutäuschen.

Filter stehen in der Adresse, Ansichten sind Lesezeichen

Jeder Zustand der Suchseite ist eine Adresse. Damit ist er teilbar, der Zurück-Knopf tut das Richtige, und eine gespeicherte Ansicht ist nichts weiter als eine gemerkte Adresse.

Die mitgelieferten Ansichten stehen im Programm und nicht in der Datenbank, weil sie von der Gegenwart abhängen: „Wiedervorlage diese Woche" und „unbearbeitet seit 14 Tagen" wären als gespeicherte Zeitpunkte schon am nächsten Tag falsch.

„Zuletzt geöffnet" ist eine Sprungliste, kein Verlauf

Die Ansicht aus docs/08 blieb zunächst offen, weil dafür jeder Aufruf jeder Akte mitgeschrieben werden muss, während das Zugriffsprotokoll erst ab Stufe 3 mitschreibt. Eine zweite Spur daneben ist die Sorte Funktion, die harmlos aussieht und es nicht ist: sie heisst „Zuletzt geöffnet" und beantwortet, wenn man sie falsch baut, die Frage wer hat wann worin gelesen — nur eben ohne die Regeln, Gründe und Protokollierung, unter denen diese Frage sonst steht.

Sie ist jetzt gebaut, und vier Festlegungen halten sie auf der Seite der Bequemlichkeit:

Kein Verlauf. Der Primärschlüssel von RecentView ist (userId, targetType, targetId). Je Nutzer und Datensatz gibt es genau eine Zeile, die beim nächsten Öffnen überschrieben wird. Die Tabelle hat damit keine Form, in der sie sagen könnte, wie oft, wie lange oder in welcher Reihenfolge jemand gearbeitet hat. Zwanzig Einträge, älteres fällt hinten heraus.

Nur die eigene. Es gibt in der Anwendung keinen Weg, die Liste eines anderen zu lesen: keine Ansicht, keinen Adminbereich, keine Funktion, die eine fremde Kennung entgegennimmt. Jede Abfrage in lib/recent/ nimmt p.userId.

Jederzeit löschbar, ohne Rückfrage und ohne Grabstein. Regel 2 aus CLAUDE.md gilt für den Verlauf einer Akte, nicht für eine Merkliste — was hier verschwindet, ist keine Aussage über die Akte, sondern eine Bequemlichkeit für einen Menschen.

Vorabrufe zählen nicht. Next holt die Seiten hinter sichtbaren Links im Voraus. Ohne die Prüfung auf next-router-prefetch stünde in der Liste, worüber jemand mit der Maus gefahren ist — die Übersicht allein würde sie mit zehn Akten füllen, die niemand geöffnet hat. Das wäre die unangenehmere Sorte falsch: eine Aufzeichnung von Aufmerksamkeit statt von Handlung.

Gelesen wird die Liste über dieselben Sichtbarkeitsfragmente wie alles andere. Eine vorgemerkte Kennung ist keine Berechtigung: steigt eine Akte nachträglich über die Freigabestufe, verschwindet sie aus der Liste, ohne Lücke und ohne Platzhalter. Für Personalakten reicht die Stufe nicht, es braucht zusätzlich personnel.read — wie überall sonst.

Das Zugriffsprotokoll bleibt unberührt und bleibt die einzige Auskunft darüber, wer wann was gelesen hat.

Sie steht auf der Übersicht und im leeren Feld der Kommandopalette. Der zweite Ort ist der eigentliche: wer Strg+K drückt, will meistens dorthin zurück, wo er eben war.

Die Kommandopalette wiegt nichts

Strg+K steckt in jeder Seite, und die Fallakte hat kein Bündel übrig. Deshalb ist der Teil im Grundgerüst nur ein Tastaturereignis; die Palette selbst wird geholt, wenn jemand sie öffnet. Das kostet die anderen Seiten rund hundert Byte.

Sie sucht bewusst schmal — Aktenzeichen, Titel, Namen, kein Volltext über Inhalte. Wer sie öffnet, will springen, nicht recherchieren.

Export

Ein Zeichen statt eines Sitzungscookies

Der PDF-Dienst ruft die Druckansicht selbst auf und braucht dafür die Rechte des Abrufers. Der naheliegende Weg wäre, ihm das Sitzungscookie mitzugeben — der Kommentar im Gerüst aus Phase 0 schlug das sogar vor. Er ist trotzdem falsch: ein Cookie ist der volle Zugang zum Konto und läge dann im Speicher eines zweiten Prozesses und in dessen Protokollen.

Stattdessen ein signiertes, kurzlebiges Zeichen (lib/export/token.ts), das genau eines erlaubt: diese eine Ansicht, für diesen einen Auftrag, für zehn Minuten. Drei Einschränkungen machen es stumpf:

  • Es gilt nur, solange der Auftrag auf RUNNING steht. Davor und danach ist es wertlos.
  • Die Stufe wird beim Einlösen frisch aufgelöst und auf die im Zeichen vermerkte gedeckelt. Ein Zeichen kann also nie mehr zeigen, als der Abrufer beim Anfragen durfte — und auch nicht mehr, als er jetzt noch darf, falls ihm inzwischen Rechte entzogen wurden.
  • Es ist an den anfragenden Nutzer gebunden.

Dieselbe Bauart wie die signierte Datei-URL aus docs/07.

Die Druckansicht wirft kein notFound()

Sie rendert stattdessen selbst eine Nicht-verfügbar-Seite. Der Grund ist handfest: die Wurzel hat ein loading.tsx, und dessen Skelett blieb in der (print)-Gruppe stehen, statt durch die Nicht-gefunden-Seite ersetzt zu werden — im Browser kam nur ein leeres Gerüst an, obwohl die Antwort den richtigen Inhalt enthielt. Ein eigenes not-found.tsx in der Gruppe half nicht.

Für ein Blatt, das gedruckt wird, ist ein leeres Blatt der schlechteste Ausgang: es sieht aus wie ein Fehler im Papier, nicht wie eine Auskunft. Und der PDF-Dienst wartet auf networkidle und druckt, was dann dasteht — er hätte leere Seiten erzeugt.

Gefunden wurde das nur, weil der e2e-Test auf den Inhalt prüft und nicht auf den Statuscode. Der wäre hier ohnehin nutzlos gewesen: Next streamt, der Kopf ist längst raus, wenn notFound() greift, und die Antwort trägt 200 — auch bei den regulären Seiten.

Die Druckansichten liegen ausserhalb des angemeldeten Bereichs

app/(app)/layout.tsx ruft requireUser(), und der leitet ohne Sitzung zur Anmeldung um — womit der PDF-Dienst nie bei der Seite ankäme. Die Druckansichten liegen deshalb in einer eigenen Gruppe (print) mit einem nackten Rahmen: keine Navigation, keine Kopfzeile, keine Kommandopalette. Was auf Papier landet, soll das Dokument sein und nicht die Anwendung darum herum.

Jede dieser Seiten prüft dafür selbst, über permissionsForPrint. Ohne gültiges Zeichen und ohne Sitzung zeigen sie „Nicht gefunden" — nicht 403, denn die Existenz ist selbst eine Information.

Die Stufe steckt im Zwischenspeicherschlüssel

sha256(Ziel + Stand + Stufe + Vorlagenversion). Ohne die Stufe darin bekäme ein Leser der Stufe 1 das PDF, das für Stufe 4 gerendert wurde — der teuerste denkbare Zwischenspeicherfehler, und einer, der lange unbemerkt bliebe. Der Stand sorgt dafür, dass eine geänderte Akte neu gerendert wird.

Vier Augen heisst vier Augen

Ab Stufe 3 wartet ein Auftrag auf PENDING_APPROVAL. Freigeben darf, wer export.approve hat — ausser dem Anfragenden selbst, auch dann nicht, wenn er das Recht besitzt. Ohne diese eine Zeile wäre das ganze Verfahren Zierde.

Die offenen Freigaben stehen auf der Übersicht, weil die Leitung diese Seite ohnehin öffnet. Eine Anfrage, die niemand sieht, ist keine Freigabe.

Der Export darf nie sauberer aussehen als die Akte

Gesperrte Felder erscheinen als schwarzer Balken mit dem Vermerk „Geschwärzt, Stufe 4" statt zu fehlen; sonst merkt der Empfänger nicht, dass er eine gekürzte Fassung in der Hand hält. Nachträglich geänderte Einträge tragen bearbeitet, v3, 31.08.2026, zurückgezogene stehen als Grabstein mit Begründung. Der Stand:-Zeitstempel auf dem Deckblatt macht erkennbar, dass ein älterer Ausdruck überholt sein kann.

Ein eigener ZIP-Schreiber

Achtzig Zeilen statt einer Abhängigkeit. Der Inhalt eines Sammelexports sind PDFs und bereits neu kodierte Bilder — beides komprimiert; ein Deflate darüber bringt nichts und kostet nur CPU auf einem Heimserver. Was bleibt, ist das Behälterformat, und das ist überschaubar.

Der JSON-Abzug ist die eine Ausnahme

Er läuft ohne Stufenfilterung, nur mit Recht admin, immer protokolliert. Weil er die einzige Stelle im System ist, an der die Klassifizierung nicht greift, liegt er absichtlich unbequem: hinter einem eigenen Endpunkt im Adminbereich, nicht hinter einem Knopf an einer Akte. Sitzungen bleiben draussen — sie verschaffen Zugang, statt ihn zu dokumentieren.

Das Bündelbudget ist aufgebraucht

Die Fallakte liegt bei 119,8 kB gegen 120 erlaubte. In dieser Phase kostete allein das Verschieben der Exporttexte in eine eigene i18n-Datei den Unterschied zwischen 120,3 und 119,8 — der Export selbst hat eine eigene Seite und trägt nichts zur Fallakte bei.

Damit war Schluss mit dem Kürzen. Die Seite ist deshalb in Reiter geteilt worden, und zwar in eigene Seiten, nicht in umgeschaltete Blöcke: nur so wird der Code getrennt geladen. Ein Reiter, der im Client nur ein- und ausgeblendet wird, kostet dasselbe wie vorher.

Reiter Inhalt Bündel
Akte Abschnitte, Verlauf, Aktenchat 116,2 kB
Beteiligte Beteiligte, Verknüpfungen 110,7 kB
Dokumente Dokumente, Anhänge 110,9 kB

Kopfdaten, Stempel und die Knöpfe für Status und Umklassifizierung stehen im gemeinsamen Layout und damit auf jedem Reiter.

Der Schnitt folgt der Fachlichkeit, nicht dem Bündel: die drei Ebenen aus docs/05-akten-und-vorlagen.md — Kopfdaten, Abschnitte, Verlauf — gehören zusammen und bleiben zusammen. Das Umfeld der Akte braucht man selten gleichzeitig mit dem Sachstand.

Ein Verweis in der Datenbank ist kein Beweis für eine Datei

Die Entdopplung der Anhänge verwies auf eine vorhandene Datei, sobald eine Zeile mit gleichem sha256 und Zustand CLEAN existierte — ohne nachzusehen, ob die Datei noch daliegt.

Aufgefallen ist das, als /tmp beim Neustart der Maschine geleert wurde: die Testdatenbank kannte die alten Zeilen noch, die Ablage nicht mehr, und jeder weitere Upload desselben Inhalts zeigte stumm ins Leere. Auf dem Heimserver wäre dasselbe passiert, wenn ein Volume einmal nicht eingehängt gewesen wäre.

freigeben prüft jetzt, ob die Datei wirklich existiert, bevor es auf sie verweist. Sonst legt es sie neu ab. Die Regel dahinter gilt allgemein: ein Verweis in der Datenbank ist eine Behauptung über die Ablage, kein Beweis.

Schliff

Das helle Thema tauscht nur die semantische Ebene

--ink-* und --paper* bleiben, wie sie sind; getauscht werden --color-surface, --panel, --raised, --line, --text, --text-dim und --accent. Wer eine Rohfarbe direkt benutzt, merkt vom Thema nichts — genau deshalb tut es niemand.

Die Klassifizierungsfarben sind für hellen Grund eine Spur dunkler, im Farbton aber dieselben. Eine Stufe muss in beiden Themen wiedererkennbar bleiben, sonst lernt man sie zweimal.

Drei Zustände statt zwei: „Wie das System" ist der Auslieferungszustand und folgt prefers-color-scheme; wer ausdrücklich wählt, bekommt seine Wahl auf diesem Gerät gemerkt. Nicht am Konto — das Thema ist eine Eigenschaft des Bildschirms, an dem jemand sitzt, nicht der Person.

Gesetzt wird es von einem winzigen Skript im Kopf, bevor das erste Mal gemalt wird. Sonst blitzt beim Laden das falsche Thema auf. Es ist die einzige Stelle mit dangerouslySetInnerHTML in der Anwendung, und der Inhalt ist eine Konstante aus dem Modul daneben — das Verbot aus docs/07 gilt Nutzerinhalten.

Die Messung misst nicht den Nutzer

web-vitals schickt Zahlen an /api/vitals. Was nicht mitgeht: die Kennung des Nutzers und der konkrete Pfad. Übertragen wird das Routenmuster — aus /faelle/AfiS-2026-0042 wird /faelle/[caseNumber].

Der Grund ist nicht Sparsamkeit, sondern Zuständigkeit: wer wann welche Akte geöffnet hat, steht im Zugriffsprotokoll, dort ist es geregelt und dort ist es nachvollziehbar. Eine zweite Spur daneben wäre eine Überwachungsfunktion ohne Auftrag — und eine, die niemand als solche erkennt, weil sie „Ladewerte" heisst.

Der Endpunkt prüft, was ankommt: nur bekannte Metriken, nur plausible Zahlen, Route auf 120 Zeichen beschnitten. Ein Endpunkt, der ungeprüft Zeichenketten aus dem Browser speichert, ist ein Ablagefach für Fremde.

Ausgewertet wird Median und 75. Perzentil, nicht der Mittelwert: ein einzelner Ausreisser auf einem alten Laptop soll die Zahl nicht bestimmen.

Beobachten verschafft keinen Zugriff

Watch ist eine Merkliste, kein Recht. Beobachten kann man nur, was man ohnehin sehen darf, und die Liste läuft durch visibleCaseWhere — steigt eine Akte später über die eigene Stufe, verschwindet sie aus der Liste, ohne dass jemand aufräumen müsste. Der Eintrag bleibt stehen und greift wieder, wenn die Stufe wieder passt.

Die Wiederherstellung ist geprüft, nicht behauptet

scripts/restore-test.sh zieht einen Dump, spielt ihn in eine Wegwerf-Datenbank, vergleicht die Zeilenzahlen und räumt auf. Geprüft wird dabei ausdrücklich auch, dass Regeln und Trigger mitgekommen sind: eine Wiederherstellung ohne entry_no_delete wäre eine Datenbank, in der Verlaufseinträge löschbar sind — dieselben Daten, aber ohne die Zusicherung, um derentwillen das System gebaut wird.

Einmal durchgespielt: 42 Akten, 51 Verlaufseinträge, 97 Protokollzeilen, 8 Regeln und 20 Trigger kamen an.

Oberfläche, zweite Runde

Flächen bleiben eckig, Bedienelemente werden rund

Der Auslieferungszustand hatte überall 2 px Radius, mit der Begründung „Akten sind nicht abgerundet". Das stimmt für Akten und stimmte nicht für Knöpfe: ein Bedienelement, das aussieht wie ein Ausschnitt der Fläche, auf der es liegt, wird nicht als Bedienelement gelesen.

Die Regel heisst jetzt: Flächen bleiben eckig, alles Bedienbare ist rund. Knöpfe, Suchbalken, Marken und Profilbilder voll (999 px), Eingabefelder weich (10 px), Panels, Tabellen und Listen unverändert bei 2 px. Damit ist die Rundung eine Auskunft und keine Verzierung: rund heisst, man kann es anfassen.

Jeder Knopf hat einen Rahmen und meldet den Druck zurück

Vorher waren rund vierzig der siebzig Knöpfe blosser Text in Akzentfarbe — nicht von einem Link zu unterscheiden und auf einem vollen Bildschirm schlicht nicht zu finden. Jetzt trägt jeder Knopf Rahmen und Grundfläche, über die Klasse .btn an einer Stelle definiert statt siebzigmal als Klassenkette.

Beim Drücken zieht der Knopf sich leicht zusammen und bekommt einen inneren Ring: er wirkt kurz dicker, ohne dass die Rahmenbreite sich ändert und das Umfeld springt. Die Rückmeldung setzt ohne Verzögerung ein und klingt über 240 ms ab — andersherum bliebe ein kurzer Klick unsichtbar, und genau darum geht es.

Ausdrücklich keine Rückfrage vor dem Ausführen. „Bestätigung" heisst hier: der Nutzer soll wissen, dass er getroffen hat, nicht ein zweites Mal gefragt werden. Wo eine Rückfrage schon stand — Umklassifizieren, Änderungsrichtlinie —, bleibt sie.

Die Verwaltung ist ein Eintrag, nicht drei

Rollenverwaltung, Vorlagen und Änderungsrichtlinie standen als drei eigene Einträge in der Kopfzeile, für etwas, das man selten und dann meist zusammenhängend braucht. Sie liegen jetzt unter /admin mit einer Reiterleiste, wie die Fallakte. Die Sichtbarkeit eines Reiters richtet sich nach dem Recht, aber jede Seite prüft weiterhin selbst — eine versteckte Kachel ist kein Schutz.

Freie Kanäle sind entfallen

Der Chat sollte von Anfang an dort stehen, wo der Vorgang steht. Ein eigener Reiter mit freien Räumen war der Rückweg nach Discord: dort redet man über eine Akte, statt in ihr. Die Fallakte hatte ihren Chat bereits, die Personalakte hat ihn jetzt auch.

Channel ist mitsamt Tabelle entfernt, ChatMessage.channelId heisst personnelFileId, und ein CHECK erzwingt, dass eine Nachricht zu genau einem Ort gehört — vorher hing das allein an der Anwendung. Zum Zeitpunkt der Migration stand in keinem Kanal eine Nachricht.

Zwei Dinge sind am Personalaktenchat anders als am Aktenchat, beide aus demselben Grund — dass es zu jemandem eine Personalakte gibt, ist selbst eine Auskunft:

  • Kein „als Vermerk übernehmen". Dafür braucht es einen Verlauf, und den führt nur die Fallakte.
  • Meldungen gehen nur an ausdrücklich Erwähnte, nie in den allgemeinen Discord-Kanal, und sie nennen weder den Namen der Person noch den Text.

Suchbalken statt Suchknopf, Profilbild statt Textzeile

Die Suche ist der häufigste Weg in eine Akte und war ein Link zwischen anderen Links. Sie ist jetzt ein Feld in der Kopfzeile — ein gewöhnliches Formular an /suche, ohne Skript und ohne ein Byte im Bündel. Strg+K bleibt daneben: der Balken ist zum Recherchieren da, die Palette zum Springen.

Thema, Benachrichtigungen, „Dein Zugang" und Abmelden standen zwischen den Aktenbereichen und nahmen dort den Platz weg, den die Arbeit braucht. Sie sitzen jetzt hinter dem Profilbild. Das Bild kommt von Discord — dort liegt es ohnehin, die Anmeldung läuft über dieselbe Stelle, und ein eigener Zwischenspeicher wäre ein Dienst mehr im Stack für ein Bild von 64 Pixeln. Dafür steht cdn.discordapp.com in der Inhaltsrichtlinie. Wer kein Bild hat, bekommt seine Initialen; ein Platzhaltergesicht lädt nichts nach und sagt nichts.

Das Protokoll ist eine Ansicht, keine neue Spur

„Wer was wo macht" stand vom ersten Tag an in AuditLog — harte Regel 3 aus CLAUDE.md sorgt dafür, dass jede schreibende Aktion und jeder Lesezugriff ab Stufe 3 eine Zeile erzeugt. Was fehlte, war die Ansicht darauf. /admin/protokoll ist genau das und nicht eine zweite Aufzeichnung.

Zwei Festlegungen:

Nur die Leitung. Eine Protokollzeile nennt Aktenzeichen und Namen, und schon die Existenz einer Zeile verrät, dass es einen Vorgang gibt. Ein „gefiltertes Protokoll für alle" wäre ein Weg, die Sichtbarkeitsregeln von hinten aufzurollen: aus den Lücken liesse sich lesen, was man nicht sehen darf. Deshalb requirePermission('admin') und sonst nichts.

Kennungen werden aufgelöst, ohne Sichtbarkeitsfilter. Aus Case:0192… wird AfiS-2026-0042. Ohne das wäre die Ansicht eine Spalte UUIDs, die niemand liest — und ein Protokoll, das niemand liest, ist keine Kontrolle. Das ist die Ausnahme, die es geben muss, damit die Leitung prüfen kann, ob die Regeln eingehalten wurden. Sie ist genau deshalb auf die Leitung beschränkt.

Zeilen, deren Ziel sich nicht auflösen lässt — ein Exportauftrag etwa —, bleiben mit ihrer rohen Kennung stehen. Ein Protokoll mit Lücken wäre keins.

Ein Knopf für das ganze Blatt

Jedes Feld trug seinen eigenen Speichern-Knopf. Einen Arbeitsvertrag mit zwölf Feldern auszufüllen hiess zwölfmal speichern, und wer eines vergass, merkte es erst beim Nachlesen. Der Knopf steht jetzt am Blatt.

Gespeichert wird trotzdem je Feld und nicht als Transaktion. Das ist kein halber Umbau, sondern folgt aus dem Datenmodell: Abschnitte sind einzeln versioniert, weil an einer Akte mehrere Leute gleichzeitig arbeiten. Wäre das Speichern eine Transaktion, blockierte ein einziges zwischenzeitlich geändertes Feld das ganze Blatt — elf gute Änderungen gingen verloren, weil jemand anderes das zwölfte angefasst hat. Stattdessen geht durch, was durchgeht, und die Rückmeldung sagt, was offen blieb und wo. Der Fassungsvergleich steht weiterhin am betroffenen Feld.

Lichtbilder bleiben ausgenommen: ein Bild wird hochgeladen und geprüft, das ist ein eigener Vorgang mit eigener Prüfkette und keine Feldänderung.

Von Hand angelegte Indizes gehören auch ins Schema

Der GIN-Index über den Suchvektor stand in der Migration, aber nicht in schema.prisma — Unsupported("tsvector") verleitet dazu, den Index für nicht darstellbar zu halten. Er ist darstellbar: @@index([vector], type: Gin).

Ohne die Zeile hielt Prisma den Index für einen Rückstand und fragte bei jedem migrate dev nach einem Namen für eine neue Migration. Deren Inhalt wäre gewesen, den Suchindex zu löschen. Wer die Frage in Eile beantwortet, verliert die gestufte Volltextsuche und merkt es erst, wenn die Suche leer bleibt — der Index ist keine Optimierung, ohne ihn scannt jede Anfrage sechs Zeilen je Akte.

packages/db/tests/schema-drift.test.ts prüft das jetzt: migrate diff gegen die Datenbank des Testlaufs muss leer sein. Die Fehlermeldung nennt das fehlende Stück, damit man nicht sucht.

Die Regel daraus: was eine Migration von Hand anlegt, muss im Schema stehen, oder Prisma räumt es beim nächsten migrate dev weg. Das gilt für Indizes; Regeln, Trigger und CHECKs kennt Prisma nicht und lässt sie in Ruhe.

Die Testdatenbank wird bei jedem Lauf neu aufgesetzt

Sie wurde bisher nur angelegt, wenn sie fehlte. Weil Akten, Verlaufseinträge und Protokollzeilen nach harter Regel 2 nicht löschbar sind, bleibt aber alles stehen, was ein Testlauf anlegt — nach einigen Dutzend Läufen standen 2487 Akten darin.

Das ging so lange gut, bis drei Zusicherungen brachen, die eine auf zehn Einträge begrenzte Liste prüfen: die eigene, gerade angelegte Akte war nicht mehr unter den zehn nächsten Fristen. Das ist die unangenehmste Sorte Fehlschlag, weil sie aussieht wie ein Fehler im Code und keiner ist.

test:prepare verwirft die Datenbank jetzt und legt sie neu an; das dauert dreieinhalb Sekunden. Der Aufwand, so einen Fehlschlag einmal falsch zu deuten, ist grösser. KEEP_TEST_DB=1 behält den Bestand, für das schnelle Nacheinander an einer einzelnen Testdatei. Eine Sicherung im Skript besteht darauf, dass der Name auf _test endet.

Das Nonce am Themenskript wird nicht hydriert

Das Skript, das das Thema vor dem ersten Malen setzt, braucht ein Nonce, sonst blockiert die Inhaltsrichtlinie es und beim Laden blitzt das falsche Thema auf. Genau dieses Nonce erzeugte aber bei jedem Seitenaufruf eine Hydrierungsmeldung in der Konsole.

Der Grund liegt nicht bei uns: der Browser leert das Nonce-Attribut, sobald er das Element geparst hat. Das schreibt die CSP-Spezifikation so vor, damit kein Skript den Wert über getAttribute auslesen und sich damit selbst nachladen kann. React vergleicht beim Hydrieren trotzdem das Attribut, liest clientseitig den leeren Wert und meldet einen Unterschied, den es nicht gibt — unser Markup ist auf beiden Seiten dasselbe.

Deshalb suppressHydrationWarning an diesem einen Element. Kein Verstecken eines Problems: es sagt React, dass hier nichts zu vergleichen ist, und das stimmt. Die Alternative wäre, das Skript statt über das Nonce über einen sha256-Hash in der Richtlinie zu erlauben. Das würde die Ursache beseitigen, verlangt aber einen Eingriff in die Inhaltsrichtlinie für etwas, das nur eine Meldung in der Entwicklungskonsole ist — ein schlechter Tausch.

Geprüft wird das auf der Ebene der Quelle, nicht im Browser. Ein e2e-Lauf kann es nicht auffangen: React meldet den Unterschied nur im Entwicklungsmodus, der Testlauf nutzt den Produktionsbau. Ein erster Versuch mit einer Konsolenprüfung im e2e war deshalb auch ohne den Fix grün und wurde wieder entfernt — eine Prüfung, die nicht anschlägt, ist schlimmer als keine. web/tests/thema-vorab.test.ts prüft stattdessen die drei Dinge am Skript, die zusammenpassen müssen: Nonce, Hydrierungsausnahme und derselbe Speicherschlüssel wie im Umschalter.

Beteiligte, Suche, Abteilungen

Nur Arbeitsrollen öffnen die Akte

Anlass war ein einfacher Wunsch: neben Zeuge und Betroffenem sollte man auch Täter und Mittäter eintragen können. Dabei fiel eine Lücke auf, die schon vorher bestand.

Ein Beteiligter durfte die Akte lesen, gleich in welcher Rolle — so stand es in docs/03 und so war es umgesetzt. Bei „Betroffener" fiel das nicht auf. Mit „Täter" wäre es offensichtlich falsch geworden: der Beschuldigte hätte die Ermittlung gegen sich selbst mitgelesen, sofern er ein Discord-Konto hat.

Die Beteiligtenliste beantwortet eben zwei Fragen — wer bearbeitet den Fall und um wen geht es darin —, und nur die erste ist eine Berechtigung. WORKING_ROLES in lib/cases/terms.ts nennt die drei, die die Akte öffnen: Ermittlungsführung, Ermittler, Beobachter. Zeuge, Betroffener, Täter und Mittäter öffnen nichts.

Das gilt an allen vier Stellen, die auf Beteiligung geprüft haben: Aktenzugriff, Dokumentzugriff über die besitzende Akte, „meine Fälle" auf der Übersicht und der Filter „nur meine" in der Suche. Ein Test geht alle vier Nicht-Arbeitsrollen einzeln durch.

Die Suche zeigt, wo sonst noch etwas liegt

Wer nach einem Namen suchte, landete auf den Fallakten und erfuhr nie, dass es zu demselben Namen auch eine Personalakte gibt — die Suche fand sie, aber hinter einem Reiter, auf den nichts hindeutete. Die Reiter tragen jetzt ihre Trefferzahl, und ein leeres Ergebnis nennt ausdrücklich, wo etwas liegt.

Dabei fiel auf, dass searchPersonnel das Recht personnel.read nicht prüfte. Die Kommandopalette tat es, die Aktenseite tat es, die Suche nicht: über /suche?kind=personnel kam jeder mit ausreichender Stufe an eine Liste aller Personalakten mit Namen, Dienstgrad und Etiketten. Die Prüfung steht jetzt in lib/search/service.ts und damit an der Stelle, die diese Akten herausgibt, nicht in der Seite.

Die Trefferzahl einer Art, die es für den Nutzer nicht gibt, ist null und nicht 0: eine Null wäre die Auskunft „hier ist nichts", und die steht ihm nicht zu. Der Reiter fehlt dann ganz.

Die Abteilung ist eine Ebene, kein weiteres Feld

Die Abteilung stand als Freitext in der Personalakte. Damit liess sich nichts fragen: jede Schreibweise war eine eigene Abteilung, und „SHD", „Shd" und „Sicherheitsdienst" waren drei. Sie ist jetzt ein eigenes Modell, im Adminbereich verwaltbar, und die Akte zeigt darauf.

Die wichtigste Festlegung dabei: eine Fallakte trägt keine Abteilung. Sie gehört zu einer, wenn eine beteiligte Person ihr angehört — abgeleitet über CaseParticipant und die Personalakte. Eine eingetragene Zuordnung wäre nach zwei Wochen falsch: Leute wechseln die Abteilung, Beteiligte kommen dazu, und niemand pflegt so etwas nach. Abgeleitet stimmt sie immer, und sie kostet eine Verknüpfung mehr in der Abfrage.

Dasselbe gilt für die Dokumente einer Abteilung. Ein Dokument unmittelbar an der Abteilung wäre eine halbe Sache — die Vorlagen kennen über TemplateUse nur Fall- und Personalakte, und ein Besitzer, für den es keine Vorlage gibt, ist ein Feld, das nichts füllen kann. Die Liste zeigt deshalb, was an den Personalakten der Mitglieder und an den Fällen der Abteilung hängt.

Die Zusammenfassung ist die gefährliche Stelle. Eine Abteilungsseite fasst zusammen, was einzeln längst geschützt ist, und eine Zusammenfassung, die nicht filtert, verrät mehr als jede Einzelansicht. Deshalb läuft hier jede Liste und jede Zahl durch dieselben Fragmente wie überall: die Mitgliederzahl durch visiblePersonnelWhere, die Fälle und die Werte im Diagramm durch visibleCaseWhere. Ein Test prüft ausdrücklich, dass die Summe der Diagrammanteile mit der Länge der Liste übereinstimmt.

Die Abteilung selbst steht auf Stufe 0: dass es den Sicherheitsdienst gibt, ist kein Geheimnis, und eine Abteilung, die niemand findet, kann auch niemand jemandem zuordnen. Die Mindeststufe gibt es trotzdem, und sie gilt dann für das, was an der Abteilung hängt — ihre Vermerke und ihren Chat.

Abteilungsvermerke sind Verlauf, nur schmaler

Für die Vermerke gilt harte Regel 2 unverändert: kein DELETE, jede Änderung erzeugt eine Version, jede Zurücknahme lässt einen Grabstein stehen. Die Regeln stehen als Regel und Trigger in der Datenbank, nicht nur in der Anwendung.

Was fehlt, sind die Teile, die nur eine Ermittlung braucht: Korrekturkette, Anhänge, Karenzzeit. Ein Abteilungsvermerk ist ein laufendes Protokoll — was Beweiswert hat, gehört in eine Fallakte, und die gibt es ja.

Das Kreisdiagramm ist SVG vom Server

Eine Diagrammbibliothek wäre das Teuerste auf der Seite gewesen. Die Rechnung ist ein Dutzend Zeilen Trigonometrie, und serverseitig gerendert kostet das Diagramm kein Byte JavaScript.

Die Segmente sind Abstufungen von --steel und nicht die Klassifizierungsfarben. Die sechs Stufenfarben sind das einzige Buntsystem der Anwendung und bedeuten dort etwas Bestimmtes; ein Tortenstück in Oxidrot würde als „streng geheim" gelesen. Daneben steht die Aufschlüsselung als Liste mit Zahlen und Prozenten: Farbe allein darf keine Angabe tragen, also ist das Diagramm der Schmuck und die Liste die Auskunft.