Commit Graph
36 Commits
Author SHA1 Message Date
Harlekin 4652dd08ec Virenpruefung an clamd anbinden, wo clamd tatsaechlich lauscht
Prüfung / Lint, Typen, Tests (push) Canceled after 0s
Prüfung / Images bauen (pdf) (push) Canceled after 0s
Prüfung / Images bauen (web) (push) Canceled after 0s
Prüfung / Images bauen (worker) (push) Canceled after 0s
Images / bauen (pdf, docker/pdf.Dockerfile) (push) Canceled after 0s
Images / bauen (web, docker/web.Dockerfile) (push) Canceled after 0s
Images / bauen (worker, docker/worker.Dockerfile) (push) Canceled after 0s
Die Adresse zeigte auf /var/run/clamav/clamd.sock in einem geteilten
Volume. Das offizielle clamav-Image legt seinen Unix-Socket aber unter
/tmp im eigenen Container an - nachgemessen, das Verzeichnis im Volume
bleibt leer. Jeder Verbindungsversuch lief damit in ENOENT, und die
Statusseite meldete die Virenpruefung dauerhaft als gestoert.

Folgenreich war das nicht nur fuer die Anzeige: ohne erreichbaren Scanner
bleiben Uploads auf "Pruefung unvollstaendig" liegen und brauchen die
Freigabe von Hand. Die Kette hat also gehalten, aber niemand kam durch.

Dasselbe Image oeffnet ab Werk TCPSocket 3310. Darueber laeuft die
Anbindung jetzt; der Port ist im Stack nicht veroeffentlicht und nur im
internen Netz erreichbar. CLAMAV_ADDRESS nimmt weiterhin einen Pfad an,
falls irgendwo doch ein Unix-Socket erreichbar ist.

Das Socket-Volume faellt weg, es enthielt nie etwas. Der Scanner sieht
ausserdem die Dateiablage nicht mehr: uebertragen wird per INSTREAM durch
die Verbindung, gelesen hat er dort nie etwas.

Geprueft gegen ein laufendes clamd, einschliesslich EICAR. Diese Pruefung
bleibt als abschaltbarer Teil der Suite stehen:

  CLAMAV_LIVE=clamav:3310 pnpm --filter @afis/web test pruefkette

Die uebrigen neuen Pruefungen decken das Zerlegen der Adresse ab. Sie
haetten den Fehler nicht gefunden - eine Adresse, die auf nichts zeigt,
ist syntaktisch tadellos. Nur eine echte Verbindung beantwortet das.
2026-09-04 15:04:51 +02:00
Harlekin abf42c5187 Ranggefuege: Rollen, die aufeinander aufbauen
Prüfung / Lint, Typen, Tests (push) Canceled after 0s
Prüfung / Images bauen (pdf) (push) Canceled after 0s
Prüfung / Images bauen (web) (push) Canceled after 0s
Prüfung / Images bauen (worker) (push) Canceled after 0s
Images / bauen (pdf, docker/pdf.Dockerfile) (push) Canceled after 0s
Images / bauen (web, docker/web.Dockerfile) (push) Canceled after 0s
Images / bauen (worker, docker/worker.Dockerfile) (push) Canceled after 0s
Bisher trug jede Rolle ihre Rechte vollstaendig selbst. Bei achtzehn Rollen
und zweiundzwanzig Rechten heisst das, dass eine hoehere Rangrolle die ganze
Liste der darunterliegenden noch einmal abhaken muss - beim Anlegen
fehleranfaellig und bei jeder spaeteren Aenderung doppelt zu pflegen.

Ein Gefuege ist eine benannte, geordnete Folge von Rollen. Jede Sprosse
erbt alles von jeder Sprosse unter ihr, verwaltet wird nur noch das, was
oben dazukommt. Geerbt wird nicht nur die Rechteliste, sondern auch
Freigabestufe und Anmeldung: sonst waere eine Befoerderung unter Umstaenden
eine Herabstufung, naemlich immer dann, wenn Discord beim Befoerdern die
alte Rangrolle abnimmt.

Die Ordnung ist bewusst eine Reihenfolge und kein Zeiger je Rolle - ueber
ganzen Zahlen gibt es keinen Zyklus, den man abfangen muesste. Aufloesen
eines Gefueges laesst die Rollen stehen und nimmt nur das Geerbte weg.

Die Rechnung steht als reine Funktion in lib/auth/ladders.ts und wird an
drei Stellen benutzt: bei der Rechteaufloesung, in der Verwaltung und in
der Vorschau des Editors, die im Browser mitrechnet. Eine Vorschau, die den
Stand vor dem Verschieben zeigt, waere schlimmer als keine.

Die Rollenseite zeigt Geerbtes kursiv und gesperrt an, damit dort niemand
blind Kaestchen setzt, die laengst gesetzt sind.

Nebenbei: Aenderungen an der Rollenzuordnung landen jetzt im Protokoll, mit
Vorher und Nachher. Wer Rollen aendert, aendert die Rechte anderer - genau
dafuer ist es da.
2026-09-04 14:53:29 +02:00
Harlekin cbbc1dceb5 Rollenwechsel in Discord auf beiden Seiten pruefen
Prüfung / Lint, Typen, Tests (push) Canceled after 0s
Prüfung / Images bauen (pdf) (push) Canceled after 0s
Prüfung / Images bauen (web) (push) Canceled after 0s
Prüfung / Images bauen (worker) (push) Canceled after 0s
Images / bauen (pdf, docker/pdf.Dockerfile) (push) Canceled after 0s
Images / bauen (web, docker/web.Dockerfile) (push) Canceled after 0s
Images / bauen (worker, docker/worker.Dockerfile) (push) Canceled after 0s
Der Worker ist die einzige Stelle, an der Discord-Rollen in die Datenbank
kommen. Damit haengt jede Befoerderung, Herabstufung und jeder Rauswurf an
syncMember und enforceMembership - geprueft war davon bisher nur der
vollstaendige Entzug.

Neu abgedeckt: Rollen werden getauscht statt angehaeuft, eine leere
Rollenliste raeumt wirklich alles ab, ein Rueckkehrer wird wieder aktiv,
und ein unveraendertes Mitglied loest keine Meldung aus - sonst wuerfe der
Fuenf-Minuten-Abgleich reihum jeden Zwischenspeicher weg.

Auf der Web-Seite dazu die Gegenrichtung: eine Befoerderung wirkt sofort,
die Stufe kommt aus der hoechsten Rolle und nicht aus der zuletzt
eingetragenen, und eine im Adminbereich geaenderte Rollenzuordnung wirkt
auch dann, wenn sich beim Nutzer selbst keine Zeile aendert.

Der Notify-Test stellt seine Voraussetzung jetzt selbst her. Er erbte die
zu loeschende Zeile von einem frueheren Test und waere still umgefallen,
sobald der etwas anderes tut: ohne Loeschung kein Ausloeser, ohne
Ausloeser keine Meldung.
2026-09-04 14:06:42 +02:00
Harlekin e0b03ea336 Gedankenstriche durch Bindestriche ersetzt
Prüfung / Lint, Typen, Tests (push) Canceled after 0s
Prüfung / Images bauen (pdf) (push) Canceled after 0s
Prüfung / Images bauen (web) (push) Canceled after 0s
Prüfung / Images bauen (worker) (push) Canceled after 0s
Images / bauen (pdf, docker/pdf.Dockerfile) (push) Canceled after 0s
Images / bauen (web, docker/web.Dockerfile) (push) Canceled after 0s
Images / bauen (worker, docker/worker.Dockerfile) (push) Canceled after 0s
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die
README; an keiner Stelle Bedeutung, nur Zeichensetzung.
2026-09-04 08:54:52 +02:00
Harlekin 38be773636 README erklärt das Projekt, Zusicherungen unter Nummern
Die README beschrieb das Vorgehen statt der Anwendung. Sie erklärt jetzt, was
das System kann, wie es aufgebaut ist und wie man es lokal und im Betrieb
fährt.

Dazu tragen die acht Zusicherungen dort ihre Nummern - Berechtigungsprüfung in
der Abfrage, kein DELETE im Verlauf, Protokollpflicht ab Stufe 3, Magic Bytes
statt Dateiendungen, Skelette statt Spinner, Existenz als Information, keine
Geheimnisse im Repository, keine Einmal-Container. Achtzehn Dateien verwiesen
für ebendiese Regeln auf eine Datei, die nicht mehr mitverfolgt wird; sie
zeigen jetzt auf die README.

Arbeitsunterlagen bleiben auf der Platte, werden aber nicht mehr verfolgt. Die
Ausschlussregel steht in .git/info/exclude statt in .gitignore, damit sie nicht
selbst im Repository steht.
2026-09-04 08:42:07 +02:00
Harlekin 18c9a71fda Export ohne Vier-Augen, Wiedervorlage setzbar, Druckansicht mit Weg zurück
**Export.** Das Vier-Augen-Verfahren ab Stufe 3 ist weg. Wer exportiert,
entscheidet jetzt allein die Freigabestufe: mindestens die des Ziels. Das ist
strenger als das Lesen - wer als Beteiligter ausnahmsweise in eine höher
eingestufte Akte darf, darf sie deshalb noch nicht ausser Haus geben.

Dabei fiel eine Lücke auf: hängte man sich an einen laufenden Auftrag,
entstand kein Protokolleintrag. „Jeder Export wird protokolliert" heisst jeder
Abruf, nicht jeder Rendervorgang - wer dieselbe Datei bekommt, muss genauso
darin stehen.

**Fristen.** dueAt wurde überall gelesen - Übersicht, Suchfilter, Ausdruck -
und nirgends gesetzt. Es gab keine Stelle, an der man ein Datum einträgt,
deshalb stand unter „Fristen" immer „Nichts steht an". Jetzt im Kopf der Akte,
mit Protokolleintrag und ohne Verlaufseintrag: eine Wiedervorlage ist eine
Merkhilfe, keine Aussage über den Fall.

**Statuswechsel.** Aus einem Entwurf führt laut Lebenszyklus nur „Offen"
heraus, deshalb standen dort nie mehr als zwei Ziele. Das sah aus wie ein
Fehler und war keiner - jetzt steht die Erklärung daneben und an jeder Option.

**Druckansicht.** Sie hatte kein eigenes Layout: das weisse Blatt lag auf dem
dunklen Thema und es gab keinen Weg zurück. Jetzt heller Grund, eine Leiste
mit Zurück und Drucken, beides beim Drucken ausgeblendet. Das Ziel wird aus
dem Pfad abgeleitet, damit es auch beim direkten Aufruf stimmt.

**Abteilungsstatistik.** Eine Abteilung ohne Freigabe wird als „Ohne Freigabe"
geführt statt weggelassen - sonst summierten sich die Anteile nicht auf die
Zahl daneben. Mehrere ergeben ein Stück, sonst stünde dort, wie viele es sind.

Dazu: Dienstgrad in eckigen Klammern vor dem Namen und in dessen Grösse, in
einer eigenen Spalte; Knopfform für „Personalakte anlegen"; die abgeschnittene
Anzahl im Kopf der Personalaktenliste; „Zuletzt geöffnet" ohne die zwei
Hinweistexte, leer als Zeichen statt als Satz.
2026-09-04 00:14:07 +02:00
Harlekin e21e478fd1 Vorlagen-JSON abweisen statt still verwerfen, Platzhalter ohne Prototypkette
Durchgesehen, was mit Text passiert, der nicht als Text ankommt. Die Grundlage
war schon in Ordnung: genau ein dangerouslySetInnerHTML mit einer Konstanten,
kein eval, kein $queryRawUnsafe, keine Weiterleitung aus einem Adressparameter,
Dateien mit nosniff und sandbox-CSP als Anhang statt inline.

Drei Ränder waren offen.

**Vorlagen.** Das zod-Schema hatte einen festen Satz Feldarten und speicherte
das Geprüfte, verwarf unbekannte Schlüssel aber stillschweigend. Mit
strictObject fällt ein `typ` statt `type` auf, statt eine Vorlage zu
hinterlassen, die nicht tut, was dasteht.

**Platzhalter lasen über die Prototypkette.** Beim Durchprobieren gefunden:
`constructor` ist ein gültiger Feldschlüssel, die Schlüsselregel erlaubt
Kleinbuchstaben. Ein Baustein mit {{constructor}} bekam den Quelltext der
geerbten Funktion in den Text geschrieben - kein ausgeführter Code, aber
Innenleben in einem Dokument, das weitergereicht wird. Jetzt Object.hasOwn.

**Farbmarken aus der Datenbank** landen als Name einer CSS-Variablen in einem
style-Attribut. Heute setzt das nur der Seed, und eine zweite Deklaration liesse
sich darüber ohnehin nicht einschleusen - aber der Kommentar sagt „im
Adminbereich pflegbar", und was aus der Datenbank in ein Stilattribut geht,
wird geprüft.

Dreizehn Prüfungen, die es mit echten Versuchen probieren. Bösartiger Text
bleibt dabei ausdrücklich erlaubt: eine Feldbeschriftung darf
<script>alert(1)</script> heissen und sieht dann genau so aus. Verboten ist,
dass daraus ein Element wird.

Nebenbei: eine Vorlagenänderung stand im Zugriffsprotokoll als roles.update.
2026-09-03 23:52:53 +02:00
Harlekin 738330cee5 Vollständiger Statusfilter, Beteiligte beim Anlegen, Knöpfe in den Listen
Vier Sachen aus der Durchsicht.

**Der Statusfilter zeigte fünf von sieben Zuständen.** DRAFT und SEALED
fehlten - ausgerechnet die beiden, die in der Liste am häufigsten standen; man
konnte nach dem, was man sah, gerade nicht filtern. Das erklärt auch die Frage,
was „In Prüfung" und „Ruhend" bedeuten: die Zustände standen nirgends in der
Oberfläche. Jetzt alle sieben in der Reihenfolge des Lebenszyklus, mit einer
Erklärung als title, und Versiegelt nur für den, der Versiegeltes lesen darf.
Ein Test verlangt zu jedem Zustand einen Namen und eine Erklärung.

**Beliebig viele Beteiligte beim Anlegen.** Das Formular kannte genau eine
„betroffene Person", und die Rolle war auf SUBJECT festgelegt. Jetzt beliebig
viele Zeilen mit je einer Rolle, Konten und Personalakten in einer Auswahl, wie
auf der Aktenseite. Die Zerlegung steht als parseParticipantDrafts in
lib/cases/terms.ts und nicht in der Server Action: seit die Rolle über den
Zugriff entscheidet, darf eine erfundene Rolle nicht durchrutschen.

**Die Filterleisten sind Knöpfe** wie überall sonst, mit gefülltem aktivem
Filter.

**Keine leere Kennungsspalte mehr.** In „Zuletzt geöffnet" stand bei einer
Person nichts, wo sonst das Aktenzeichen steht - jetzt steht dort
„Personalakte". Nebenbei zwei Stellen zurückgenommen, an denen die
Spaltenklasse im Seitenkopf gelandet war statt in einer Zeile.
2026-09-03 23:38:56 +02:00
Harlekin 9c24858d75 Feste Spaltenbreiten für jede Listenzeile
„Keine Textzentrierung" - die Beobachtung stimmte an einundzwanzig Stellen, und
die Ursache war überall dieselbe: die Zeilen waren Flex-Behälter, in denen
jedes Element so breit war wie sein Inhalt. Damit fing der Titel in jeder Zeile
woanders an, und die Stempelmarken hatten nur rechts eine gemeinsame Kante. Bei
„ETHIK" und „WS" untereinander sieht man es sofort.

Die Breiten stehen jetzt einmal in globals.css - record-key, record-label,
record-title, record-meta, stamp-mark - und sind über Variablen je Liste
anpassbar: eine Liste von Abteilungskürzeln braucht keine vierzehn Zeichen,
eine Liste von Aktenzeichen schon.

Zwei Dinge, die dabei mehr sind als Kosmetik:

Eine Spalte wird auch reserviert, wenn sie leer ist. Vorher hing der Dienstgrad
an einer Bedingung, und bei einer Person ohne Rang rutschte alles Folgende nach
links.

Was keine Kennung ist, gehört nicht in die Kennungsspalte. In „Zuletzt
geöffnet" stand der Name einer Person dort, wo sonst das Aktenzeichen steht: in
Mono, abgeschnitten, und der Dienstgrad wanderte an die Stelle des Titels. Für
eine Person ist der Name der Titel.

Die Stempelmarke ist überall gleich breit; ein zu langer Name wird gekürzt, die
Stufenziffer bleibt stehen - sie ist die eigentliche Auskunft. Unter 40 rem
fallen die festen Breiten weg, sonst liefen sie über.

Nachgesehen im Browser mit dem gebauten CSS, Fallakten, Abteilungen, Zuletzt
geöffnet und Beteiligte.
2026-09-03 22:53:07 +02:00
Harlekin 79d2cc4753 Entzug greift auch im offenen Ereignisstrom
Nachgesehen, was bei Herabstufung, Rollenentzug, Rauswurf und Austritt
passiert. Für Requests war alles abgedeckt: der Worker löscht die Sitzungen,
ein Trigger verwirft die zwischengespeicherten Rechte, getCurrentUser prüft bei
jedem Request Sitzungszeile, isActive und Mitgliedschaft nach. Die Zeile in der
Datenbank ist der Nachweis, nicht das Cookie - und eine Zeile lässt sich
zurückziehen.

Eine Stelle fiel dabei durch: der Ereignisstrom stellt keine neuen Requests. Er
prüfte einmal beim Verbinden und lief danach mit einer eingefrorenen
Rechtekopie weiter. Wer mit offenem Tab hinausgeworfen wurde, bekam weiter
Chatnachrichten zugestellt, mit vollem Text - jeder Seitenaufruf wäre
abgemeldet gewesen, aber es machte ja keiner mehr einen.

/api/stream löst Sitzung und Rechte jetzt bei jedem Ereignis und bei jedem
Herzschlag neu auf. Ändern sich die Rechte, werden auch die erlaubten Themen
neu bestimmt: ein Ereignis zu einem Verlaufseintrag trägt nur eine Kennung, das
Thema ist dort die einzige Schranke. Ist die Sitzung weg, geht ein revoked
hinaus und die Verbindung wird geschlossen - Schliessen allein genügt nicht,
EventSource baut sofort wieder auf.

Fünf Tests für die Web-Hälfte der Kette; die Discord-Hälfte deckte
worker/tests/mitgliedschaft.test.ts schon ab.
2026-09-03 22:41:52 +02:00
Harlekin e2feaccf68 Beteiligte in den Suchindex der Fallakte
Wer „Jonathan Fletcher" eingibt, will die Akten sehen, in denen er vorkommt -
auch die, in denen er weder im Titel steht noch im Text erwähnt wird, sondern
nur als Betroffener eingetragen ist. Genau das wurde nicht gefunden.

Die Namen der Beteiligten stehen jetzt im Vektor der Fallakte, Gewicht B: ein
Name sagt etwas über die Akte aus, aber weniger als ihr Titel. In allen sechs
Vektoren und nicht erst ab einer Stufe - das ist die Stelle, an der man zweimal
hinsehen muss, und sie war bereits entschieden: der Name eines Beteiligten
steht in der Beteiligtenliste jeder Akte, die man lesen darf, zurückgehalten
wird allein der Verweis auf seine Personalakte. Der Index gibt also nichts
heraus, was die Akte nicht ohnehin zeigt.

Die Akte selbst muss man weiterhin lesen dürfen. Ein Test prüft genau das: eine
Stufe-4-Akte wird über den Namen ihres Zeugen für Stufe 1 nicht auffindbar,
auch nicht als Trefferzahl.

Drei Auslöser halten den Index aktuell: geänderte Beteiligung, geänderter Name
in einer Personalakte, geänderter Anzeigename eines Kontos. Den letzten gleicht
der Worker mit Discord ab; ohne ihn fände die Suche nach einer Umbenennung
weiter den alten Namen.

Die Trefferzeile sagt dazu, warum sie passt: „Jonathan Fletcher als
Betroffener". Ohne den Hinweis sieht ein Treffer, dessen Begriff nirgends im
sichtbaren Text steht, wie ein Fehlgriff aus.
2026-09-03 22:29:52 +02:00
Harlekin 75c22ff4f8 Ladegrenzen für jede Route, Turbopack im Entwicklungsserver
Gemessen statt vermutet: die Datenschicht war nie das Problem. resolvePermissions
unter 1 ms, loadDashboard 11 ms, searchCases 5 ms, die Abteilungsabfragen 3–4 ms.

Die Sekunden kamen aus zwei Ecken.

**Fehlende Ladegrenzen.** Den meisten Routen fehlte loading.tsx - unter anderem
der Übersicht, der Suche und dem gesamten Adminbereich. Ohne diese Datei hat
der Router keine Grenze, wartet stumm auf die fertige Serverantwort, und bis
dahin steht die alte Seite: man klickt, und scheinbar passiert nichts. Neunzehn
Skelette nachgetragen, jede Route hat jetzt eine.

Dazu quittiert der geklickte Link den Klick selbst, mit einem ruhig
pulsierenden Punkt statt eines Kreisels. Regel 5 verbietet den
Blockade-Spinner, der Inhalt ersetzt; eine Quittung im Link ersetzt nichts, und
die eigentliche Antwort auf das Warten bleibt das Skelett.

**Webpack.** Der Entwicklungsserver übersetzte jede Route beim ersten Aufruf
neu: 8 s für die Übersicht, 2,8 s für die Fallliste. Mit Turbopack sind es
0,43 s beim ersten Aufruf und 0,1 s danach. Nur pnpm dev, der Produktionsbau
bleibt unverändert. Gegengeprüft, dass die Anmeldeseite mit CSS und ohne
Fehler im Log rendert.

Nebenbei die eine Abfrage, die schlecht skalierte: die Übersicht zählte
ungelesene Nachrichten mit einer Abfrage je Ort - bei hundert Akten hundert
Hin- und Rückwege. Jetzt ein einziges groupBy mit der Lesemarke im ODER-Zweig
des jeweiligen Ortes. Ein Test prüft, was dabei leicht verrutscht: die Marke
gilt je Ort, und eigene Nachrichten zählen nie mit.
2026-09-03 22:21:43 +02:00
Harlekin 4b794ec9a8 Suche fängt bei „Alle" an, Bereiche in der Kopfzeile sind Knöpfe
**Suche.** Sie startete auf den Fallakten, und man musste die Art vorher
wählen - die falsche Reihenfolge: wer einen Namen eintippt, weiss noch nicht,
ob er eine Fallakte, ein Dokument oder eine Personalakte sucht. Er will wissen,
wo der Name vorkommt.

„Alle" ist jetzt der Auslieferungszustand und zeigt je Art fünf Treffer als
eigenen Block, mit dem Weg in die volle Liste daneben. Getrennte Blöcke statt
einer gemischten Liste nach Rang: ein Aktenzeichen neben einem Namen neben
einer Dokumentnummer hat keine erkennbare Ordnung. Die einzelnen Arten bleiben
als Filter, und nur dort gibt es Facetten und Blättern - beides bezieht sich
auf eine Grundmenge, ein Statusfilter über Fall- und Personalakten zugleich
wäre bei der einen ein Filter und bei der anderen ein Ausschluss.

**Kopfzeile.** Die Bereiche waren Textlinks: von einem Fliesstextlink nicht zu
unterscheiden und ohne Hinweis darauf, wo man gerade ist. Jetzt dieselbe
Knopfform wie alles Bedienbare, der aktive Bereich gefüllt.

Der gefüllte Zustand trägt hellen Text statt der Akzentfarbe: auf der getönten
Fläche wäre Akzent auf Akzent und der Kontrast fiele unter die Untergrenze.
Dazu etwas grosszügigere Polsterung an beiden Knopfgrössen, .btn-count für
Zahlen in Knöpfen, und kein Doppelring mehr am Profilbild.

Nachgesehen mit dem gebauten CSS im Browser, hell und dunkel.
2026-09-03 21:59:06 +02:00
Harlekin d49edeed18 Abstand zur Beschriftung folgt aus der Polsterung des Feldes
„Zwischen der Auswahl und dem Text ist kaum Platz" - und das stimmte überall,
nicht nur an der einen Stelle. Der Grund war messbar: der Abstand zur
Beschriftung betrug 8 px, die Polsterung innerhalb des Feldes 11,2 px. Das Wort
stand also näher am Rahmen als der Inhalt des Rahmens an demselben Rahmen, und
genau daran erkennt das Auge einen zu engen Satz.

Die Regel heisst jetzt: der Abstand nach aussen ist grösser als die Polsterung
nach innen. Sie steht einmal in globals.css als .field-label, .field-row und
.field-hint - vorher war sie ein Dutzend Mal als mt-1, mt-1.5 oder gap-2 an den
Aufrufstellen verteilt, mit drei verschiedenen Werten, von denen keiner
reichte. 33 Stellen umgestellt.

Der Test prüft die Regel und nicht die Zahl: er liest die Polsterung von .field
aus dem CSS und verlangt, dass beide Abstände darüber liegen. Gegengeprobt mit
einem zu kleinen Wert, dann schlägt er an.

Nachgemessen im Browser mit dem gebauten CSS: 8 px senkrecht gegen 5,6 px
Innenpolsterung, 13,6 px waagerecht gegen 11,2 px.
2026-09-03 20:28:42 +02:00
Harlekin aef3df0d44 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.
2026-09-03 19:57:28 +02:00
Harlekin 89bbed8679 Suche zeigt alle Arten, nur Arbeitsrollen öffnen die Akte
Punkt 1 und 2 der Wünsche, plus zwei Lücken, die dabei sichtbar wurden.

**Suche.** Wer nach Alvarez sucht, landet auf den Fallakten und erfährt nie,
dass es dazu 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
dort, wo diese Akten herausgegeben werden. 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.

**Täter und Mittäter** als Beteiligtenrollen - und damit wurde eine ältere
Lücke offensichtlich: ein Beteiligter durfte die Akte lesen, gleich in welcher
Rolle. Bei „Betroffener" fiel das nicht auf, bei „Täter" wäre es offensichtlich
falsch. Der Beschuldigte hätte die Ermittlung gegen sich selbst mitgelesen.

Die Beteiligtenliste beantwortet zwei Fragen: wer bearbeitet den Fall, und um
wen geht es darin. Nur die erste ist eine Berechtigung. WORKING_ROLES nennt die
drei, die die Akte öffnen; Zeuge, Betroffener, Täter und Mittäter öffnen
nichts. Das gilt an allen vier Stellen, die auf Beteiligung prüften:
Aktenzugriff, Dokumentzugriff über die besitzende Akte, „meine Fälle" und der
Filter „nur meine".

Zwei bestehende Tests hatten die alte Annahme festgeschrieben und benutzten
WITNESS, um Zugriff zu belegen; sie prüfen jetzt mit INVESTIGATOR.
2026-09-03 19:16:17 +02:00
Harlekin 7f43bfc2a3 Themenskript aus dem Hydrierungsvergleich nehmen
Der Browser leert das Nonce-Attribut, sobald er das Element geparst hat - so
schreibt es die CSP-Spezifikation vor, damit kein Skript den Wert 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.

suppressHydrationWarning an diesem einen Element sagt React, dass hier nichts
zu vergleichen ist. Das Nonce selbst bleibt: ohne es blockiert die Richtlinie
das Skript, und ohne das Skript blitzt beim Laden das falsche Thema auf.

Nachgewiesen gegen einen Entwicklungsserver: ohne das Attribut erscheint genau
die gemeldete Konsolenmeldung, mit ihm nicht.

Ein erster Versuch, das per Konsolenprüfung im e2e abzusichern, war auch ohne
den Fix grün - React meldet den Unterschied nur im Entwicklungsmodus, der
Testlauf nutzt den Produktionsbau. Die Prüfung ist wieder entfernt; eine, die
nicht anschlägt, ist schlimmer als keine. Stattdessen prüft
tests/thema-vorab.test.ts die drei Dinge am Skript, die zusammenpassen müssen:
Nonce, Hydrierungsausnahme und derselbe Speicherschlüssel wie im Umschalter.
2026-09-03 17:26:40 +02:00
Harlekin 89edaa6ae1 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.
2026-09-03 15:16:10 +02:00
Harlekin 6af1e437e7 Runde Bedienelemente, Admin zusammengelegt, Kanäle entfallen
Vier der acht Änderungswünsche.

**Knöpfe.** Rund vierzig der siebzig Knöpfe waren blosser Text in Akzentfarbe,
von einem Link nicht zu unterscheiden. Jeder trägt jetzt Rahmen und
Grundfläche, über die Klasse .btn an einer Stelle definiert statt siebzigmal
als Klassenkette. Beim Drücken zieht der Knopf sich 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
langsam ab, sonst bliebe ein kurzer Klick unsichtbar.

Dazu die neue Regel: Flächen bleiben eckig, alles Bedienbare ist rund. Damit
ist die Rundung eine Auskunft und keine Verzierung.

**Verwaltung.** Rollen, Vorlagen und Änderungsrichtlinie waren drei Einträge in
der Kopfzeile für etwas, das man selten und dann zusammenhängend braucht. Jetzt
ein Eintrag /admin mit Reiterleiste, dazu eine gemeinsame Tabs-Komponente, die
auch der Fallakte zeigt, welcher Reiter offen ist.

**Kanäle.** Ersatzlos entfernt, mitsamt Tabelle. Der Chat gehört dorthin, wo
der Vorgang steht; ein eigener Reiter mit freien Räumen war der Rückweg nach
Discord. Die Personalakte hat jetzt denselben Chat wie die Fallakte - ohne
„als Vermerk übernehmen", denn dafür braucht es einen Verlauf, und Meldungen
gehen nur an Erwähnte, nie in den allgemeinen Kanal: dass es zu jemandem eine
Personalakte gibt, ist selbst eine Auskunft. Ein CHECK erzwingt jetzt, dass
eine Nachricht zu genau einem Ort gehört.

**Kopfzeile.** Suchbalken statt Suchlink, als gewöhnliches Formular ohne ein
Byte im Bündel. Thema, Benachrichtigungen und Abmelden sitzen hinter dem
Profilbild, das von Discord kommt; wer keins hat, bekommt seine Initialen.

Nebenbei: Aktenzeichen in Tests waren nur auf vier Stellen eindeutig und
kollidierten zwischen Läufen, weil Akten nicht löschbar sind.
2026-09-03 15:07:43 +02:00
Harlekin da4a067850 Zuletzt geöffnet als Sprungliste, ohne Verlauf
Die letzte offene Zeile aus TASKS.md. Sie blieb offen, weil eine Spur über
jeden Aktenaufruf schnell etwas anderes wird als eine Bequemlichkeit.

Vier Festlegungen halten sie auf der richtigen Seite:

  - Kein Verlauf. Der Primärschlüssel (userId, targetType, targetId) lässt je
    Nutzer und Datensatz genau eine Zeile zu, die überschrieben wird. Die
    Tabelle kann nicht sagen, wie oft oder in welcher Reihenfolge gearbeitet
    wurde. Zwanzig Einträge, älteres fällt hinten heraus.
  - Nur die eigene. Keine Ansicht, kein Adminbereich, keine Funktion mit
    fremder Kennung. Jede Abfrage nimmt p.userId.
  - Jederzeit löschbar, ohne Rückfrage und ohne Grabstein.
  - Vorabrufe zählen nicht. Ohne die Prüfung auf next-router-prefetch stünde
    dort, worüber jemand mit der Maus gefahren ist.

Gelesen wird über dieselben Sichtbarkeitsfragmente wie überall: eine
vorgemerkte Kennung ist keine Berechtigung. Steigt eine Akte über die
Freigabestufe, verschwindet sie ohne Lücke und ohne Platzhalter; Personalakten
brauchen zusätzlich personnel.read. 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.

Nebenbei: NEXT_DIST_DIR, damit ein Build neben einem laufenden
Entwicklungsserver nicht dasselbe .next-Verzeichnis abräumt.
2026-09-03 10:52:23 +02:00
Harlekin addef20e16 Phase 9 abgeschlossen: Übersicht, Beobachtungsliste, helles Thema, Messung
Die Übersicht beantwortet vier Fragen, die man morgens hat: woran arbeite ich,
was ist fällig, wo hat jemand geschrieben, was hat sich getan. Jede Liste
läuft durch visibleCaseWhere - das ist der Ort, an dem eine unachtsame
Abfrage am ehesten eine Akte verrät. Ein Test prüft jede einzeln gegen eine zu
hoch eingestufte Akte. Der bisherige Inhalt ("Dein Zugang") steht jetzt unter
/zugang.

Beobachten ist eine Merkliste, kein Recht: nur was sichtbar ist, lässt sich
merken, und steigt eine Akte später über die eigene Stufe, verschwindet sie
aus der Liste, ohne dass jemand aufräumt.

Das helle Thema tauscht nur die semantische Ebene; die Grundpalette bleibt.
Die Klassifizierungsfarben sind für hellen Grund eine Spur dunkler, im Farbton
aber dieselben - eine Stufe muss in beiden Themen wiedererkennbar sein. Drei
Zustände, "wie das System" ist der Auslieferungszustand, und ein Skript im
Kopf setzt die Wahl, bevor das erste Mal gemalt wird.

Die Messung misst nicht den Nutzer. web-vitals schickt Zahlen und das
Routenmuster, keine Kennung und keinen konkreten Pfad: wer wann welche Akte
geöffnet hat, steht im Zugriffsprotokoll, und eine zweite Spur daneben wäre
eine Überwachungsfunktion ohne Auftrag. Ausgewertet wird Median und 75.
Perzentil, nicht der Mittelwert.

Mobil: die breiten Tabellen scrollen in sich, statt die Seite breiter zu
machen. Ein e2e-Test hält fest, dass keine Seite waagerecht überläuft.

scripts/restore-test.sh spielt eine Sicherung in eine Wegwerf-Datenbank und
prüft ausdrücklich, dass Regeln und Trigger mitkommen: eine Wiederherstellung
ohne entry_no_delete wäre eine Datenbank ohne die Zusicherung, um derentwillen
das System gebaut wird. Einmal durchgespielt, alles angekommen.

186 Tests im Web-Paket, 17 in der Datenbank, 7 im Worker, 54 e2e.
Grösste Route 116,2 von 120 kB.
2026-09-03 10:28:50 +02:00
Harlekin f1106a57d6 Phase 8 abgeschlossen: Export als PDF, Wasserzeichen, Vier-Augen-Freigabe
Die Druckansicht ist die einzige Vorlage für den Export: kein zweites Layout
von Hand in einer PDF-Bibliothek. Der Playwright-Dienst ruft genau diese Seite
auf und bekommt dafür ein signiertes, kurzlebiges Zeichen statt eines
Sitzungscookies. Es gilt nur, solange der Auftrag läuft, ist an den Anfragenden
gebunden, und die Stufe wird beim Einlösen frisch aufgelöst und auf die im
Zeichen vermerkte gedeckelt.

Der Export darf nie sauberer aussehen als die Akte: gesperrte Felder erscheinen
als Balken mit Vermerk statt zu fehlen, geänderte Einträge tragen ihre Version,
zurückgezogene stehen als Grabstein. Wasserzeichen mit Name, Zeitstempel und
Stufe auf jeder Seite.

Die Stufe steckt im Zwischenspeicherschlüssel. Ohne sie bekäme ein Leser der
Stufe 1 das PDF, das für Stufe 4 gerendert wurde.

Ab Stufe 3 wartet der Auftrag auf Freigabe durch ein zweites Augenpaar - und
der Anfragende darf sich nicht selbst freigeben, auch nicht mit dem Recht
dazu. Offene Freigaben stehen auf der Übersicht.

Dazu Einzeldokumente als PDF (Aktenblatt bleibt Aktenblatt), ZIP-Sammelexport
mit eigenem achtzigzeiligem Schreiber statt einer Abhängigkeit, und der
JSON-Vollabzug ohne Stufenfilterung - die einzige Stelle, an der die
Klassifizierung nicht greift, deshalb hinter einem eigenen Admin-Endpunkt.

Zwei Funde unterwegs, beide in docs/13 festgehalten:

Die Druckansicht warf notFound(), und das Skelett aus dem Wurzel-loading.tsx
blieb im Browser stehen - der PDF-Dienst hätte leere Blätter gedruckt.
Gefunden nur, weil der e2e-Test auf den Inhalt prüft und nicht auf den
Statuscode. Sie rendert die Auskunft jetzt selbst.

Und die Entdopplung der Anhänge verwies auf Dateien, ohne nachzusehen, ob sie
noch daliegen. Nach einem geleerten /tmp zeigte jeder weitere Upload desselben
Inhalts stumm ins Leere.

178 Tests im Web-Paket, 17 in der Datenbank, 7 im Worker, 44 e2e.
Fallakte bei 119,8 von 120 kB - das Budget ist aufgebraucht.
2026-09-03 10:01:57 +02:00
Harlekin cb0eabb266 Phase 7 abgeschlossen: Suche mit gestuftem Index, Facetten, Palette
Der bisherige Suchvektor enthielt nur, was jeder sehen darf. Das war
leck-sicher und unbrauchbar zugleich: ab Stufe 1 fand die Suche nichts von
dem, was einen Fall ausmacht.

SearchIndex hält jetzt sechs Zeilen je Ziel, eine pro Freigabestufe. Die Zeile
zur Stufe N enthält alles, was ein Leser dieser Stufe sehen darf. Eine Suche
nimmt genau diese eine Zeile und kann deshalb nichts finden, was sie nicht
lesen darf - auch keinen höher eingestuften Eintrag in einer sichtbaren Akte.

Schwärzungen werden dabei stufenweise aufgelöst: strip_redactions_above
entfernt nur, was über der Stufe liegt. Sonst fände ein Leser der Stufe 4
einen Namen nicht, den er auf der Seite im Klartext sieht, und ein Leser der
Stufe 1 einen, den er dort nur als Balken sieht.

Die Suchbedingung ist nicht die Zugriffsprüfung: der Volltext liefert
Kandidaten, die Sichtbarkeit kommt aus visibleCaseWhere - derselben Funktion
wie in der Fallliste, und Treffer, Trefferzahl und Facettenzähler entstehen
gemeinsam in einer Abfrage. Eine in SQL nachgebaute Sichtbarkeitsregel wäre
die Sorte Duplikat, die auseinanderläuft.

Filter stehen in der Adresse. Damit ist jeder Zustand teilbar, der
Zurück-Knopf tut das Richtige, und eine gespeicherte Ansicht ist ein
Lesezeichen. Die mitgelieferten Ansichten stehen im Programm, weil sie von der
Gegenwart abhängen.

Strg+K öffnet die Kommandopalette. Im Grundgerüst steckt nur das
Tastaturereignis, die Palette selbst wird beim Öffnen geholt - das kostet die
anderen Seiten rund hundert Byte.

Offen geblieben: "Zuletzt geöffnet" als Ansicht. Dafür müsste jeder Aufruf
jeder Akte mitgeschrieben werden; das wäre eine Überwachungsfunktion, die
niemand bestellt hat. In TASKS vermerkt.

165 Tests im Web-Paket, 17 in der Datenbank, 7 im Worker, 34 e2e.
Grösste Route bei 119,7 kB gegen 120 erlaubte.
2026-09-02 21:23:01 +02:00
Harlekin 88c257e793 Phase 6 abgeschlossen: Aktenchat, Kanäle, Echtzeit, Benachrichtigungen
Ein Ereignisstrom mit Themen (/api/stream), in dem der Dateistrom aus Phase 5
aufgegangen ist. Zwei Prüfungen: beim Verbinden, welche Themen der Nutzer
abonnieren darf, und je Ereignis wird der Inhalt mit seinen Rechten geladen.
Das zweite ist nicht nur Vorsicht - eine Umklassifizierung wirkt dadurch in
einer offenen Verbindung sofort. In der pg_notify-Nutzlast steht nie ein
Inhalt, nur Thema, Art und Kennung.

Der Aktenchat sendet optimistisch mit clientseitiger UUIDv7, die zugleich
Idempotenzschlüssel ist. Antworten, vier feste Reaktionen, Bearbeiten mit
sichtbarem editedAt, Zurückziehen mit Platzhalter - Löschen gibt es nicht, und
eine Regel in der Datenbank hält das fest. Eine zurückgezogene Nachricht kommt
ohne Text an, nicht ausgeblendet.

Aktenzeichen im Text werden zu Vorschaukarten, serverseitig aufgelöst. Eine
Akte, die der Leser nicht sehen darf, sieht darin genauso aus wie eine, die es
gar nicht gibt.

"Als Vermerk übernehmen" steht sichtbar an jeder Nachricht. Der Vermerk gehört
dem Verfasser, nicht dem Übernehmenden; ein Systemeintrag nennt den Ursprung,
damit der Wortlaut später nicht für die Akte geschrieben wirkt.

Bei den Discord-Benachrichtigungen entscheidet die Web-App, wer was erfährt,
und der Worker liefert nur aus. Die Alternative hätte geheissen, die
Sichtbarkeitsprüfung im Worker ein zweites Mal zu bauen. In meta steht ein
Titel nur, wenn er hinaus darf - ab Stufe 3 keiner. Postausgang als Tabelle,
angestossen über LISTEN, mit Takt als Sicherheitsnetz.

Nebenbei riss das Budget der Fallakte erneut (122 kB). Drei Schritte zurück
auf 119,6: Chattexte in eine eigene i18n-Datei, ein gemeinsames AddPanel statt
viermal derselbe Aufklapp-Code, und eine eigene UUIDv7 statt des Pakets - die
Sortierbarkeit, auf der die Nachlieferung beruht, steht damit im eigenen Code
und hat Tests.

Unterwegs habe ich NotifyMode und User.notifyMode ein zweites Mal angelegt,
obwohl es beides seit dem Grundgerüst gibt. Die Migration schlug fehl und die
Datenbank musste von Hand aufgeräumt werden; die Lehre steht in docs/13.

155 Tests im Web-Paket, 15 in der Datenbank, 7 im Worker, 32 e2e.
2026-09-02 21:06:03 +02:00
Harlekin 55bcb0d95c Phase 5 abgeschlossen: Dateien mit vollständiger Prüfkette
Upload → quarantine/ → Typprüfung → ClamAV → Neukodierung → store/. Es gibt
keinen Pfad, auf dem eine Datei ungeprüft in store/ landet, und keinen
Schalter, der die Kette abschaltet.

Die Typprüfung liest Magic Bytes aus einer eigenen Signaturtabelle statt aus
file-type: bei einer Positivliste aus fünf Formaten hilft es nicht, hundert
erkennen zu können. 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. Wer trotzdem durchkommt, kommt als text/plain durch und wird unter der
Endung des erkannten Typs ausgeliefert, nicht unter der hochgeladenen.

Drei Ausgänge statt zwei: BLOCKED heisst "ist gefährlich", FAILED heisst
"wissen wir nicht". Nur FAILED lässt sich freigeben, und die Freigabe
wiederholt die Kette ohne den Scanner - Typprüfung und Neukodierung laufen
dabei mit. Ein fehlender clamd macht damit keine Datei sauber; ein Test hält
das fest.

Auslieferung nur über /api/files/[id], mit Sichtbarkeitsprüfung der
zugehörigen Akte, Protokoll ab Stufe 3 und den harten Kopfzeilen aus docs/07.
Vorschaubilder dürfen inline, weil sie serverseitig als WebP neu kodiert
wurden.

Das Lichtbild im Aktenblatt ist damit gefüllt: der Feldwert ist die Kennung
eines geprüften Anhangs, serverseitig zu einer Adresse aufgelöst.

/api/events liefert attachment.scanned über pg_notify - die Grundlage, auf der
Phase 6 den Aktenchat aufsetzt. Die Sichtbarkeit wird je Ereignis geprüft,
sonst verriete schon die Kennung die Existenz.

Nebenbei riss das Bündelbudget (121 kB gegen 120 erlaubte): lib/i18n/de.ts war
ein Objekt, und eine Client-Komponente zieht die ganze Datei mit. Die Texte
stehen jetzt in sechs Dateien nach Bereichen, de.ts setzt sie nur zusammen.
Grösste Route wieder bei 118 kB, und sie wächst nicht mehr mit jedem neuen
Bereich mit.

133 Tests im Web-Paket, 15 in der Datenbank, 28 e2e.
2026-09-02 20:27:22 +02:00
Harlekin d2c335edf4 Personalakte als Aktenblatt, Status als Etiketten, Verknüpfen von beiden Seiten
Das Formular der Personalabteilung ist übernommen, nicht durch eine
Bildschirmansicht ersetzt: schwarzer Rahmen, Kopf mit Kennung und
Klassifizierungsbalken, Raster aus fünf Spalten, Unterschriftenzeilen,
Fußzeile. Es steht bewusst außerhalb des Design-Systems - es wird gedruckt und
muss auf Papier aussehen wie das Blatt daneben.

Datengetrieben bleibt es trotzdem. Kein Feld steht im Programm: sheetTitle und
sheetSubtitle an der Vorlage, span für die Zellbreite, rows für das Lichtbild,
dateKey für das Datum unter einer Unterschrift, slot für den
Klassifizierungsbalken. Eine weitere Dokumentart mit layout = DOSSIER braucht
keine Zeile Code.

Der Balken zeigt die Foundation-Stufe aus der neuen Tabelle
FoundationClearance. Sie ist Inhalt, kein Zugriff - was jemand sehen darf,
entscheidet weiterhin allein die interne Stufe. Ohne Auswahl bleibt der Balken
neutral, statt eine Einstufung zu behaupten.

Die Dienstnummer ist entfallen, es gibt sie nicht. Der Status ist zu mehreren
Etiketten geworden: "Gekündigt" und "Verhaftet" schließen einander nicht aus.
Unbekannte Schlüssel werden verworfen und die Reihenfolge kommt aus der
Konfiguration, nicht aus der Eingabe.

PersonnelAssignment hängt AfiS-Mitglieder an eine Person - Aktenführung,
Ermittler, Ansprechpartner - und ist zugleich der ausnahmsweise Zugang, wie
CaseParticipant bei der Fallakte. Betroffen zu sein gibt weiterhin keinen
Zugang: das ist eine Eigenschaft der Person, die Zuordnung eine Entscheidung
der Abteilung.

Fall und Person lassen sich von beiden Seiten verknüpfen und schon beim
Anlegen einer Fallakte. Alle drei Wege laufen in addParticipant zusammen.

Noch offen: das Lichtbild. Feldtyp und Platz stehen, hochladen kommt mit der
Prüfkette in Phase 5 - ein Bild als Data-URL im Abschnittswert verstiesse
gegen harte Regel 4.

102 Tests im Web-Paket, davon sechs auf dem gerenderten Blatt selbst.
2026-09-02 19:56:28 +02:00
Harlekin 03cb5ec9a7 Phase 4 abgeschlossen: Personalakten, Dokumente, Beteiligte, Verknüpfungen
Die Dokumentebene aus docs/13 ist gebaut, additiv: Document, DocumentSection
und DocumentSectionVersion neben Case, CaseSection und CaseEntry. Ein Dokument
hängt an einer Fallakte, an einer Personalakte oder an nichts, höchstens an
einem - gesichert durch eine CHECK-Bedingung. Es gibt kein DELETE; eine
Zurücknahme lässt einen Grabstein stehen, der auch für den Admin zu ist.

Eine neue Dokumentart ist damit ein Datensatz, kein Deployment. Im Seed stehen
vier: vermerk, vorfallsbericht, sonderarbeitsvertrag (mehrseitiger Vertragstext
mit Platzhaltern und Unterschriftenzeilen) und bussgeld.

Zwei Umbenennungen, beide von Hand migriert, damit die vorhandenen Zeilen und
Zählerstände stehen bleiben:
- CaseTemplate -> DocumentTemplate, plus uses[] und layout
- CaseCounter -> NumberSeries, ein Nummernkreis je Präfix für Aktenzeichen
  und Dokumentnummern

Die Regeln für Abschnitte stehen jetzt einmal in lib/sections/render.ts und
gelten für Fallakten und Dokumente gleichermaßen. Die Frage aus docs/13, ob
CaseSection langfristig in die Dokumentebene wandert, ist damit verneint und
begründet.

Rückverweise auf der Personalakte laufen durch visibleCaseWhere, die Zahl
daneben entsteht aus dem gefilterten Ergebnis. Auch die Rollengruppe
verschwindet mit, sonst verriete "Betroffener" ohne Eintrag die Ermittlung.
Die eigene Personalakte ist keine Ausnahme von der Freigabestufe.

Vier neue Rechte: document.create, .edit, .finalize, .retract. Das letzte
steht bei den zurückgehaltenen, es greift in Geschriebenes ein.

91 Tests im Web-Paket, 15 in der Datenbank, 28 e2e. Größte Route 118 kB
gegen das Budget von 120 kB - die Luft wird knapp.
2026-09-02 14:15:16 +02:00
Harlekin d2174f472b Phase 3 abgeschlossen: Vorlagen, Abschnitte, Schwärzung, Diff
Eine Vorlage ist eine geordnete Liste von Bausteinen: Eingabefelder und feste
Textbausteine mit Platzhaltern. Damit trägt dieselbe Struktur den
Vorfallsbericht und den Sonderarbeitsvertrag. Die Startvorlagen bilden jetzt
den echten Bericht der Abteilung ab, samt Berichtsart, Gefahreneinschätzung
und Foundation-Freigabe - letztere als gewöhnliches Feld ohne Wirkung auf den
Zugriff.

Abschnitte werden gerendert und bearbeitet, Entwürfe alle zwei Sekunden lokal
gesichert. Gespeichert wird auf Knopfdruck: ein Abschnitt ist der Sachstand
der Akte, kein Chatbeitrag. Die geladene Version geht mit; weicht sie ab, gibt
es kein stilles Überschreiben, sondern beide Fassungen nebeneinander mit
"Meine übernehmen" und "Fremde übernehmen".

Felder über der Stufe des Lesers werden serverseitig durch einen Platzhalter
ersetzt, Schwärzungen im Fliesstext kommen als Segmente. Beides so, dass der
echte Wert den Server nicht verlässt - nicht als Ausblenden im Client.

Der Wortdiff ist selbst geschrieben statt eingekauft: ein Dutzend Zeilen gegen
30 kB im Bündel für etwas, das nur serverseitig läuft.

Beim Bauen des Vorlagen-Editors riss das Bündel-Budget auf 127,9 kB, weil zod
über das Vorlagenmodul in den Client geriet. Typen und Validierung sind jetzt
getrennt: fields.ts ohne zod für Client und Server, schema.ts nur
serverseitig. Wieder bei 111,9 kB von 120.

85 Tests, 22 E2E.
2026-09-02 12:52:53 +02:00
Harlekin 30c3d28726 Phase 3, erster Teil: Vorlagen-Bausteine und Schwärzung
Entschieden und festgehalten: jede Akte trägt Kurzvermerke und Dokumente, die
Fallakte zusätzlich ihre Abschnitte. Die Trennung aus docs/05 bleibt damit
bestehen.

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

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

Ein Test vergleicht den TypeScript-Parser gegen die SQL-Funktion
strip_redactions aus der Migration. Laufen die beiden auseinander, findet die
Suche einen Begriff, den die Ansicht verbirgt - und der Treffer allein verrät
ihn dann.
2026-09-02 12:39:35 +02:00
Harlekin 66ad19c456 Aktenzeichen-Muster je Vorlage, Startskript für die Entwicklung
Richtigstellung zuerst: der vorige Commit trägt die Nachricht "Nichts davon
gebaut", enthält aber bereits die Schema-Änderung an CaseTemplate und
CaseCounter. Ein abgebrochener Aufruf hatte die Datei schon geschrieben, und
das nachfolgende `git add -A` hat sie mitgenommen. Die Nachricht war damit
falsch. Hier ist die Änderung vollständig und geprüft.

Das Muster des Aktenzeichens gehört zur Vorlage: Präfix und Stellenzahl je
Fallart, ein Nummernkreis je Präfix und Jahr. Die Abteilung führt gewachsene
Präfixe wie AKT-PA-2026-62843 neben dem AfiS-2026-0042 aus dem Glossar.

Die Migration ist von Hand geschrieben, weil der Wechsel des Primärschlüssels
auf CaseCounter den bestehenden Zähler erhalten muss: eine zurückgesetzte
Nummer wäre ein doppeltes Aktenzeichen. Vorher und nachher geprüft.

parseCaseNumber zerlegt von hinten, damit Präfixe mit Bindestrich funktionieren.
Ein Test belegt, dass AKT-PA und AfiS getrennte Nummernkreise führen.

Dazu start.sh für die lokale Entwicklung, solange der Dockge-Stack nicht läuft:
Datenbank hochfahren, Migrationen, Grundzustand, Worker und Web. Mit
--ohne-worker für Arbeit ohne Discord und --nur-db für die Datenbank allein.
2026-09-02 12:10:34 +02:00
Harlekin cb2088c4f2 Phase 2 abgeschlossen: nachträgliche Änderungen, Grabstein, Richtlinie
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.
2026-09-01 23:11:42 +02:00
Harlekin 32577f7c19 Phase 2, erster Teil: Akten anlegen, lesen, Vermerke schreiben
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.
2026-09-01 22:57:38 +02:00
Harlekin 2505664822 Tests schreiben nicht mehr in die Entwicklungsdatenbank
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.
2026-09-01 21:11:40 +02:00
Harlekin c6aefb310c Phase 1: Anmeldung, Rechte und Rollenverwaltung
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.
2026-09-01 19:11:53 +02:00
Harlekin c6401aeb4d Rollenstruktur und englische Fachbegriffe festgelegt
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.
2026-09-01 18:50:07 +02:00
Harlekin ade977538d Web-Anwendung: Design-Tokens, Texte, Gesundheitsendpunkt
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.
2026-09-01 00:01:41 +02:00