Vor Phase 3 geprüft, ob neue Dokumentarten ohne Code auskommen. Ergebnis:
für Fallarten ja, für alles andere nein.
CaseSection hängt fest an caseId. Ein Bußgeld ist keine Ermittlung, ein
Sonderarbeitsvertrag erst recht nicht. PersonnelFile.fields ist ein nacktes
JSON-Feld ohne Vorlage: ein Vertrag dort hätte keine Versionierung, keinen
Diff, keine Begründungspflicht, kein minClearance je Feld und keinen Eintrag
im Zugriffsprotokoll — genau die Eigenschaften, wegen derer das System gebaut
wird. Und würde man solche Dokumente behelfsweise als Case führen, stünden sie
in Fallliste, Fallzählung und Suche nach Ermittlungen.
Dazu ein zweiter Befund aus den beiden Beispieldokumenten: sie sind
grundverschieden gebaut. Der Vorfallsbericht ist ein Formular und wird von den
vorhandenen Feldtypen vollständig abgedeckt. Der Sonderarbeitsvertrag ist
fester Vertragstext mit Platzhaltern, Paragraphen und Unterschriftenzeilen —
dafür reicht eine Feldliste nicht.
Empfehlung in docs/13: eine gemeinsame Dokumentebene, additiv, mit denselben
Zusicherungen wie CaseSection. Case, CaseSection und CaseEntry bleiben
unangetastet.
Nichts davon gebaut. Die Entscheidung steht als erster Punkt in Phase 3.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Aus den echten Dokumenten der Abteilung, die bisher als Google Docs geführt
wurden.
Die Dokumente tragen zwei Angaben nebeneinander, "Foundation-Freigabe" und
"Interne Freigabe". Den Zugriff steuert allein die interne Freigabe; die
Foundation-Freigabe wird ein gewöhnliches Vorlagenfeld. Mit zwei sperrenden
Achsen bekäme jede Sichtbarkeitsprüfung, jede Trefferzahl und jeder
Negativtest eine zweite Dimension — Sichtbarkeit wird an genau einer Stelle
entschieden, sonst ist sie nicht mehr nachweisbar.
Das Aktenzeichen der Abteilung lautet AKT-PA-2026-62843, das Glossar schrieb
AfiS-2026-0042 vor. Das Muster gehört zur Vorlage, nicht ins Programm:
Präfix und Stellenzahl je Fallart, Nummernkreis je Präfix und Jahr.
docs/08 kannte nur den Export einer ganzen Akte. Dokumente wie der
Sonderarbeitsvertrag hängen aber an einer Person, nicht an einer Ermittlung,
und brauchen einen eigenen Weg: Deckblatt, Dokument, Fußzeile, kein Verlauf.
Stufe des Abrufers, Wasserzeichen und Protokollierung gelten dort genauso.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Der Kern von harter Regel 2. Jede Änderung an einem Verlaufseintrag schreibt
den vorherigen Stand nach CaseEntryVersion und erhöht die Versionsnummer.
Autor und Entstehungszeit bleiben unangetastet, geprüft gegen die Datenbank.
Die Karenzzeit wird serverseitig aus AppSetting gelesen, nie aus dem Request.
Eine Änderung darin wird versioniert und protokolliert, aber nicht sichtbar
markiert — ein Tippfehler, zwei Minuten später korrigiert, soll die Akte nicht
mit "bearbeitet"-Marken zupflastern. Alles danach ist markiert und verlangt
eine Begründung, ebenso jede Änderung an einem fremden Eintrag.
Die Stelle, an der eine naive Umsetzung leckt, hat einen eigenen Test: wird
minClearance eines Eintrags angehoben, erben alle bestehenden Versionen den
neuen Wert. Sonst liest jemand mit niedrigerer Stufe den ursprünglichen Text
einfach in der Historie.
Zurückziehen setzt einen Grabstein mit Pflichtbegründung, der Text bleibt in
der Datenbank. Rückgängig machen geht und wird wiederum protokolliert.
Systemeinträge sind von jeder Änderung ausgenommen.
Dazu die Änderungsübersicht als Reiter an der Akte — sichtbar für alle, die
die Akte lesen dürfen, nicht nur für die Leitung — und die Adminseite für die
Änderungsrichtlinie.
Zwei Testprobleme dabei behoben: AppSetting ist eine einzige Zeile für die
ganze Anwendung, deshalb laufen die Web-Tests jetzt nacheinander, und die
Testdatenbank startet bei jedem Lauf im Auslieferungszustand. Vorher hing das
Ergebnis davon ab, was der vorige Lauf hinterlassen hatte.
63 Tests, zweimal hintereinander grün. Bündel bei 110,5 kB von 120.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
graphify ist eingerichtet (786 Knoten, 1179 Kanten) und in CLAUDE.md als
erster Griff bei Fragen zum Bestand festgehalten, statt Dateien durchzulesen.
Mit der Stolperfalle dokumentiert: das `graphify` im PATH läuft nicht, weil es
für Python 3.10 installiert ist und das System-python3 3.12 ist. Der Aufruf
muss über python3.10 mit gesetztem PYTHONPATH laufen.
graphify-out/ ist ein Bauartefakt und bleibt aus dem Repository.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fachlogik in lib/cases/service.ts, Zugriffsprüfung in lib/access/. Jede
Funktion prüft selbst und schreibt ihren Protokolleintrag in derselben
Transaktion wie die Änderung — die Server Actions daneben sind dünn.
Schreibgrenzen nach docs/03: niemand legt eine Akte über der eigenen Stufe an,
niemand stuft über die eigene Stufe hoch, archiviert und versiegelt sind
schreibgeschützt auch für Admins. Eine Herabstufung verlangt eine Begründung
und landet als sichtbarer Systemeintrag im Verlauf, nicht nur im Protokoll.
Aktenzeichen über CaseCounter mit INSERT ... ON CONFLICT DO UPDATE, geprüft mit
acht gleichzeitigen Anlagen ohne Doppelung. Wer eine Akte anlegt, wird ihr
Ermittler, sonst verliert er sie aus dem Blick, sobald jemand hochstuft.
Fallliste cursorbasiert auf (updatedAt, id), Sichtbarkeit und Trefferzahl in
derselben Abfrage. Aktenansicht mit Kopfdaten sofort und Verlauf in eigener
Suspense-Grenze. Vermerke optimistisch mit clientseitiger UUIDv7, die zugleich
Idempotenzschlüssel ist — zweimal gesendet bleibt ein Eintrag.
Lesezugriffe ab Stufe 3 werden protokolliert, gepuffert und alle zwei Sekunden
gebündelt geschrieben, damit das Protokoll die Antwort nicht ausbremst.
Die vier Startvorlagen stehen jetzt im Seed statt in Phase 3: ohne Vorlage
lässt sich keine Akte anlegen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Zwei Festlegungen, beide in docs/13-entscheidungen.md begründet.
case.delete ist gestrichen. Der Lebenszyklus kennt ARCHIVED und SEALED, und
eine Akte mit Verlaufseinträgen liesse sich ohnehin nicht hart löschen, weil
CaseEntry nach Regel 2 unlöschbar ist und der Fremdschlüssel das Kaskadieren
blockiert. Das ist die konsequente Fortsetzung von Regel 2 eine Ebene höher.
Niemand klassifiziert eine Akte höher als die eigene Freigabestufe. Sonst
entstehen Akten, die ihr Urheber nicht mehr sehen kann, und die Frage "wer
konnte das lesen" wird im Nachhinein schwer beantwortbar.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Lesen war geregelt, Schreiben nicht: bisher stand nirgends, dass jemand mit
Stufe 2 keine Stufe-4-Akte anlegen darf. Ohne die Regel erzeugt man Akten, die
man selbst nicht mehr sehen kann, und umgeht die Sichtbarkeitsprüfung.
Festgehalten in docs/03-auth-und-rechte.md, als Aufgaben in Phase 2:
Klassifizierung nie über der eigenen Stufe, Ändern und Löschen nur mit
Lesezugriff, Umklassifizierung mit SYSTEM-Eintrag und AuditLog. Eine
Herabstufung ist der heikelste Vorgang im System — sie macht Inhalte für Leute
sichtbar, die sie vorher nicht sehen durften.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Die Testläufe hatten dort 70 erfundene Rollen, 112 Nutzer und 40 Akten
hinterlassen. Die Rollenverwaltung zeigte deshalb "Gast" siebzehnmal, echte
Rollen doppelt und in willkürlicher Reihenfolge.
Aufräumen allein reicht nicht: CaseEntry, CaseEntryVersion und AuditLog lassen
sich nach harter Regel 2 nicht löschen. Was ein Test dort anlegt, bleibt für
immer stehen — in Phase 2 hätte das die Fallliste unbrauchbar gemacht.
Tests laufen deshalb gegen <datenbank>_test auf demselben Server. pnpm test legt
sie an, migriert und seedet sie vorher. Über TEST_DATABASE_URL lässt sich ein
anderes Ziel setzen.
Dazu die Abschnitte der Rollenverwaltung in der Reihenfolge, in der die
Abteilung ihre Rollen aufzählt: Ränge, Zugehörigkeit, Zugang. Innerhalb eines
Abschnitts entscheidet die Position auf dem Discord-Server, oben die höchste.
Damit steht die Hierarchie an einer Stelle, die ohnehin gepflegt wird.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Auth.js baut die redirect_uri nicht aus dem Host-Header, sondern aus AUTH_URL.
Ohne die Variable bleibt sie fest auf http://localhost:3000 — hinter dem
Cloudflare Tunnel wäre die Anmeldung damit kaputt gewesen, und zwar mit einer
Fehlermeldung von Discord, die nicht auf die Ursache zeigt. APP_URL ist ohnehin
die kanonische öffentliche Adresse und wird jetzt durchgereicht.
Nachweis mit dem gebauten Standalone-Server: mit gesetztem APP_URL kommt
https://afis.example.de/api/auth/callback/discord heraus, ohne fällt sie auf
localhost zurück. Ein E2E-Test hält Scope identify, state, PKCE mit S256 und
den Pfad der Callback-URL fest.
Nebenbefund, eigenständig behoben: loadEnvFromRepoRoot hat gesetzte
Umgebungsvariablen mit den Werten der Entwicklungs-.env überschrieben.
process.loadEnvFile tut das von sich aus. Eine .env ist ein Rückfall für die
lokale Entwicklung, kein Vorrang vor dem, was der Aufrufer ausdrücklich
mitgibt — sonst überschreibt sie etwa die APP_URL eines Testlaufs und die
Fehlersuche führt weit weg von der Ursache.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Der Voll-Abgleich hat den Administrator alle fünf Minuten hinausgeworfen:
enforceMembership kannte BOOTSTRAP_ADMIN_IDS nicht und sah nur, dass keine
seiner Rollen zur Anmeldung berechtigt. Solange das Rollen-Mapping noch leer
ist, trifft das zwangsläufig zu — der Notfallzugang war damit genau in der
Lage wirkungslos, für die es ihn gibt.
Gefunden beim ersten Lauf gegen den echten Discord-Server, nicht im Test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Auth.js übernimmt nur den OAuth-Ablauf mit state und PKCE, Scope identify. Das
Zugangstoken des Nutzers wird danach verworfen: die Rollen holt der Bot mit
seinem eigenen Token. Die Sitzung steht in der Datenbank, im Cookie liegt nur
userId und sessionId. Ohne die Zeile in Session ist das Cookie wertlos, und nur
so lässt sich eine Sitzung zurückziehen.
Wer sich anmelden darf, steckt in lib/auth/signInPolicy.ts statt im Callback:
das ist die Regel, an der alles hängt, und sie gehört einzeln prüfbar. Kein
Guild-Mitglied, kein Zugang. Keine Rolle mit grantsMembership, kein Zugang.
Notfall-Admins umgehen die Rolle, aber nicht die Guild-Mitgliedschaft.
RoleMapping trägt jetzt grantsMembership, category und isPresent. Der Worker
spiegelt jede Rolle der Guild hierher, damit im Adminbereich nichts von Hand
eingetragen werden muss. Gelöschte Rollen werden nicht entfernt, sondern auf
isPresent = false gesetzt: beim Umzug auf den echten Server bleiben sie als
Vorlage stehen.
Der Worker gleicht über Gateway-Ereignisse und zusätzlich alle fünf Minuten
voll ab. Verliert jemand seine AfiS-Rolle, beendet er dessen Sitzungen sofort
und setzt isActive auf false — der Datensatz bleibt, damit Autorenangaben in
alten Akten gültig bleiben. Das passiert im Worker und nicht in der Web-App,
weil es dort erst beim nächsten Request des Betroffenen griffe.
Zwei Widersprüche der Dokumente aufgelöst, festgehalten in
docs/13-entscheidungen.md: der Ausschluss schlägt auch den Admin (das
Codebeispiel in 03-auth-und-rechte.md übergeht ihn, die Reihenfolge eine Zeile
darüber nicht), und versiegelte Akten fallen im Where-Fragment heraus statt in
einer Prüfung danach.
45 Tests, davon die Negativfälle aus docs/03 gegen eine echte Datenbank: Stufe 2
sieht die Stufe-4-Akte nicht und findet sie auch nicht in der Trefferzahl, ein
Ausgeschlossener mit Stufe 5 kommt nicht heran, ein Rollenentzug beendet die
Sitzungen, und ein Ausfall der Discord-API sieht nicht aus wie eine verweigerte
Mitgliedschaft.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Der Discord-Server hat drei Sorten Rollen, die Dokumente kannten nur eine:
Zugehörigkeit (AfiS, Personalabteilung) als Türsteher, Rangrollen für die
Rechte, Zugangsrollen für die Freigabestufe. Das bestehende RoleMapping trägt
das fast, es fehlt nur der Türsteher. Die beiden Felder dafür kommen mit
Phase 1, die Struktur ist in docs/13-entscheidungen.md festgehalten.
Die Freigabestufen heißen jetzt wie die Zugangsrollen: Unclassified,
Restricted Access, Confidential Access, Sensitive Access, Critical Operations,
Executive Oversight. Fachbegriffe der Domäne bleiben englisch, auch in einer
deutschen Oberfläche.
Die Bezeichnungen sind damit Daten in ClearanceLevelConfig, nicht mehr
Oberflächentext in lib/i18n/de.ts: sie sind im Adminbereich änderbar, und was
der Leiter ändern kann, gehört nicht in eine Übersetzungsdatei. StampMark
bekommt den Namen hereingereicht.
Vier neue Tests prüfen den Zustand, den der Seed herstellt, gegen die
Datenbank statt gegen eine Konstante im Code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
compose.yaml aus docs/11-betrieb.md, ergänzt um Healthchecks für db-backup und
offsite-backup. Das Dokument verlangt für jeden Dienst einen, die abgedruckte
Fassung hatte für die beiden keinen, und ohne ihn zeigt Dockge nur "läuft",
auch wenn seit Wochen nichts mehr gesichert wird.
Die Migration läuft im Startkommando von web, nicht in einem Init-Container.
Die Prisma-CLI samt Schema-Engine liegt dafür unter /app/migrator im Image:
ein Container, der nur startet, wenn gerade eine Registry erreichbar ist, wäre
auf einem Homeserver die falsche Bauart.
Alle drei Images gebaut und laufen gegen eine echte Postgres-Instanz geprüft:
web wendet Migrationen an und liefert /api/health aus, worker schreibt sein
Lebenszeichen, pdf rendert ein echtes PDF.
Die CI prüft Lint, Typen, Tests und E2E gegen den Standalone-Server, also so,
wie der Container startet, und hält das Bündel-Budget aus
docs/09-performance-und-ux.md ein.
Phase 0 aus TASKS.md ist damit abgehakt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Der Worker hängt am Discord-Gateway und schreibt alle 30 Sekunden sein
Lebenszeichen in die Datenbank, damit /api/health im Web-Container den
Gateway-Zustand kennt, ohne selbst mit Discord zu sprechen. healthcheck.js
prüft genau dieses Lebenszeichen. Der eigentliche Rollen-Abgleich kommt in
Phase 1.
Der PDF-Dienst rendert die Druckansicht über Playwright. Kein PDF-Layout von
Hand, die Gestaltung kommt aus @media print der Seite selbst. Er kennt keine
Rechte: wer darf, entscheidet die Web-App, bevor sie hier anfragt, und schickt
das Sitzungs-Cookie des Abrufers mit, damit die gerenderte Seite dessen
Freigabestufe sieht.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Design-Tokens aus docs/10-design-system.md als CSS-Variablen: Grundpalette,
die sechs Klassifizierungsfarben, Eckenradius durchgehend 2 px, keine Schatten
ausser dem Fokusring. Der Schwärzungsbalken mit dem einmaligen Durchwischen
respektiert prefers-reduced-motion. Vier Tests halten die Tokens gegen das
Dokument.
IBM Plex selbst gehostet als woff2, latin und latin-ext getrennt über
unicode-range, font-display: swap, immutable-Cache.
Alle Oberflächentexte in lib/i18n/de.ts. Ein Test durchsucht app/ und
components/ nach Umlauten im JSX und schlägt fehl, wenn dort deutscher Text
steht. Er hat beim ersten Lauf gleich einen Verstoss gefunden.
/api/health prüft Datenbank, Discord-Gateway und ClamAV. Die Gesamtbewertung
fällt nur auf "down", wenn die Datenbank weg ist: die anderen Dienste haben
eigene Healthchecks im Stack, und ein ausgefallener Virenscanner soll nicht
den Web-Container als tot markieren.
Strikte CSP mit Nonce, kein unsafe-inline für Skripte. Die erste Route liegt
bei 100 kB JavaScript nach Kompression, Budget sind 120 kB.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pnpm-Workspace mit web/, worker/, pdf/ und packages/db. Das Datenbankpaket ist
eigenständig, weil Web-App und Worker denselben Prisma-Client brauchen.
Schema nach docs/04-datenmodell.md, mit drei Abweichungen, die dort begründet
sind: CaseParticipant bekommt einen eigenen Schlüssel statt eines
zusammengesetzten mit NULL-Spalten, die Aktenzeichen laufen über CaseCounter
statt über eine Sequenz pro Jahr, und ServiceHeartbeat kommt dazu, damit
/api/health den Gateway-Zustand kennt, ohne Discord zu fragen.
Die zweite Migration enthält, was Prisma nicht ausdrücken kann:
- kein DELETE auf CaseEntry, CaseEntryVersion und AuditLog
- authorId, createdAt, caseId und type eines Eintrags sind unveränderlich
- an einer Eintragsversion darf nur minClearance angehoben werden
- Volltextsuche mit deutscher Konfiguration; geschwärzte Bereiche werden vor
dem Indizieren entfernt, sonst verrät ein Treffer den Inhalt
- pg_notify bei Rollen- und Mapping-Änderungen
Acht Tests prüfen diese Regeln gegen eine echte Postgres-Instanz, nicht gegen
die Anwendungslogik.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CLAUDE.md und README.md verweisen auf docs/, die Dateien lagen aber im
Wurzelverzeichnis. Verschoben.
docs/13-entscheidungen.md hält fest, wo die Dokumente sich widersprechen und
wie es aufgelöst wurde. Der wichtigste Punkt: 04-datenmodell.md verlangt eine
Regel, die jedes UPDATE auf CaseEntryVersion verwirft, 05-akten-und-vorlagen.md
verlangt, dass eine Umklassifizierung rückwirkend auf alle Versionen wirkt.
Beides zusammen geht nicht. Aufgelöst über einen Trigger, der die Tabelle
anhängend hält, aber das Anheben von minClearance zulässt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>