Files
AfiS-Aktensystem/docs/13-entscheidungen.md
T
HarlekinandClaude Opus 5 5f9f5d99fb Protokoll im Adminbereich, ein Knopf für das ganze Blatt
Die restlichen zwei Änderungswünsche.

**Protokoll.** „Wer was wo macht" stand seit dem ersten Tag in AuditLog —
harte Regel 3 sorgt dafür. Was fehlte, war die Ansicht darauf. /admin/protokoll
ist genau das und keine zweite Aufzeichnung: filterbar nach Person, Aktion,
Art und Zeitraum, rückwärts blätternd.

Nur für die Leitung. Eine Zeile 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 demselben Grund werden Kennungen ohne Sichtbarkeitsfilter zu
Aktenzeichen aufgelöst — ein Protokoll aus UUIDs liest niemand, und eines, das
niemand liest, ist keine Kontrolle.

**Speichern.** Jedes Feld trug seinen eigenen Knopf: ein Arbeitsvertrag mit
zwölf Feldern hiess zwölfmal speichern. Der Knopf steht jetzt am Blatt.

Geschrieben wird trotzdem je Feld und nicht als Transaktion, und das folgt aus
dem Datenmodell: Abschnitte sind einzeln versioniert, weil mehrere Leute
gleichzeitig an einer Akte arbeiten. Als Transaktion blockierte ein einziges
zwischenzeitlich geändertes Feld das ganze Blatt — elf gute Änderungen verloren,
weil jemand das zwölfte angefasst hat. So geht durch, was durchgeht, und die
Rückmeldung sagt, was offen blieb und wo. Lichtbilder bleiben ausgenommen: ein
Upload mit Prüfkette ist keine Feldänderung.

lib/audit.ts liegt jetzt als lib/audit/write.ts neben lib/audit/query.ts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 15:16:10 +02:00

1048 lines
58 KiB
Markdown

# Getroffene Entscheidungen
Stellen, an denen die Fachdokumente sich widersprechen, lückenhaft sind oder gegen eine
Eigenschaft von Postgres laufen. Jede Zeile nennt den Konflikt, die Entscheidung und den Grund.
Wo eine Entscheidung fachlich anders ausfallen kann, steht das ausdrücklich dabei.
## Struktur
| Konflikt | Entscheidung |
|---|---|
| `CLAUDE.md` und `README.md` verweisen auf `docs/`, die Dateien lagen im Wurzelverzeichnis | Nach `docs/` verschoben |
| `02-architektur.md` zeigt `app/` und `lib/` im Wurzelverzeichnis, `TASKS.md` Phase 0 verlangt einen pnpm-Workspace mit `web/` und `worker/` | Workspace mit `web/`, `worker/`, `pdf/` und `packages/db/`. Die Struktur aus `02-architektur.md` gilt innerhalb von `web/` |
| Der Worker braucht denselben Prisma-Client wie die Web-App | Eigenes Workspace-Paket `packages/db` (`@afis/db`), von `web` und `worker` benutzt. Migrationen und Seed liegen dort |
## Datenmodell
| Konflikt | Entscheidung |
|---|---|
| `CaseParticipant` hat den zusammengesetzten Primärschlüssel `[caseId, role, userId, personnelFileId]`, aber `userId` und `personnelFileId` sind beide optional. Postgres lässt NULL in einem Primärschlüssel nicht zu | Eigener Schlüssel `id`, Eindeutigkeit über zwei **partielle** Unique-Indizes plus eine CHECK-Bedingung, dass mindestens eines der beiden Felder gesetzt ist |
| `04-datenmodell.md` nennt zugleich `CREATE SEQUENCE case_number_2026` und die Tabelle `CaseCounter(year, lastNumber)` mit `SELECT … FOR UPDATE` | `CaseCounter`. Eine Sequenz pro Jahr müsste jedes Jahr von Hand angelegt werden, und der Abteilungsleiter ist ausdrücklich nicht technisch |
| Das rohe SQL benutzt `search_vector` und `updated_at` in Schlangenschrift, die Prisma-Modelle Höckerschrift | Prisma-Voreinstellung behalten: Tabellen in Höckerschrift-Pascal, Spalten in Höckerschrift. Das SQL quotet entsprechend (`"searchVector"`, `"updatedAt"`) |
| Kein Modell für Kanäle, obwohl `ChatMessage.channelId` existiert und `06-chat-und-realtime.md` Kanäle mit eigener Mindeststufe verlangt | Modell `Channel` ergänzt, dazu `ChatReaction` für den festen Reaktionssatz |
> 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.