Zweistufig wie der Werkzeugkatalog: Betreiber pflegt den plattformweiten Standard (/betreiber/email-vorlagen), jeder Mandant kann ihn für sich übersteuern (/verwaltung/email-vorlagen) - ResolveEmailVorlage nutzt die eigene Vorlage falls vorhanden, sonst fällt sie auf den Plattform- Standard zurück. Passwort-Zurücksetzen ist die einzige aktuell existierende E-Mail und nutzt jetzt diese Vorlage statt Hardcoding. Bug beim Live-Verifizieren gefunden: UNIQUE(account_id, typ) verhindert bei NULLABLE account_id keine Duplikate (NULL != NULL in SQL) - jedes Speichern des Plattform-Standards erzeugte eine neue Zeile statt sie zu aktualisieren. Fix: zwei partielle Unique-Indizes statt eines gemeinsamen Constraints, mit Regressionstest abgesichert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
80 KiB
Deklarix
KI-Antragsprüfung für Unternehmen: Mitarbeitende beschreiben ein KI-Vorhaben in eigenen Worten, das System leitet Datenklasse, KI-VO-Einstufung und Anforderungsprofil ab, gleicht sie gegen einen gepflegten Werkzeugkatalog ab und erzeugt einen begründeten Entscheidungsvorschlag. Ein Mensch entscheidet, das System bereitet nur vor.
Produktwechsel (2026-08-28)
Deklarix war ursprünglich eine Pre-Publish-Kennzeichnungsprüfung für
Werbe-Content (UWG/MStV) für Creator, Agenturen, Marken und Kanzleien.
Dieser Scope wurde komplett verworfen und durch das oben
beschriebene Produkt ersetzt — kein Feature-Zusatz, ein Produktwechsel.
Wiederverwendet wurden nur produktunabhängige Bausteine: Auth/Session/
Account-Mandantentrennung, das Prinzip "Regelwerk als versionierte
YAML-Dateien, kein ML für die Bewertung", die apt/systemd/Gitea-
Deployment-Pipeline und das Design-System. Alles Werberecht-Spezifische
(Kennzeichnungsregeln, Beweisarchiv für Social-Media-Posts, Instagram/
TikTok-OAuth, Insights-Erinnerung) wurde entfernt, nicht nur ersetzt —
siehe Migration 0008_pivot_ki_antragspruefung für den genauen Schnitt.
Grundregel
Das System entscheidet nicht. Es bereitet vor. Der/die Verantwortliche bestätigt, ändert oder überschreibt einen Bewertungsvorschlag — mit Begründungsfeld bei Abweichung. Jede Entscheidung wird als Registereintrag protokolliert. Die Bewertung wird nie ohne ihre vollständige Herleitung angezeigt (welche Antwort welche Anforderung ausgelöst hat) — ohne das ist ein Vorschlag im Audit wertlos.
Regelbasiert, nicht ML: die Ableitungsregeln (Datenklasse, KI-VO-
Einstufung, Anforderungsprofil) liegen als versionierte YAML-Dateien
vor, nicht im Code — ein fachlich Verantwortlicher soll sie anpassen
können, ohne Go-Code anzufassen. Ein LLM ist v1 höchstens optional zur
Zweckerkennung aus dem Freitext vorgesehen (die zwölf Zweck-Kategorien
erkennen, siehe gueltigeZwecke weiter unten), nie für die Einstufung
selbst — und auch dort nur als Vorschlag, den der Mensch bestätigt.
Fünf Ebenen, fünf Rollen
Die Oberfläche gliedert sich in fünf Ebenen mit steigender Sensibilität.
Vier Mandanten-Rollen sind eine Voreinstellung, kein fest verdrahtetes
Modell (spätere Ausbaustufe: konfigurierbare Rollen) — betreiber ist
technisch getrennt vom Mandantenbereich, keine Voreinstellung.
| Ebene | Zugang | Rolle(n) | Zweck |
|---|---|---|---|
| 1 — Öffentlich | ohne Konto | — | Anmeldung, Passwort zurücksetzen, Einladung annehmen, Antrag über Mandantenlink |
| 2 — Mitarbeiter | Konto nötig | mitarbeiter |
Antrag stellen, eigene Anträge verfolgen, Rückfragen beantworten, freigegebene Werkzeuge einsehen. Bewusst schlank — dauert der Antrag zu lange, wird er umgangen |
| 3 — Fachebene | Konto nötig | verantwortlicher (Entscheidungsrecht), pruefer (identische Sicht, kein Entscheidungsrecht) |
Posteingang offener Anträge, Bewertungsvorschlag mit Herleitung, entscheiden, Register, Berichte/Exporte, Audit-Log lesend |
| 4 — Mandanten-Verwaltung | Konto nötig | admin |
Nutzer/Rollen, Abteilungen, Anmeldeverfahren, eigene Werkzeug-Freigaben/-Sperrungen, Rechnungsdaten — bezogen auf GENAU EINEN Mandanten |
| 5 — Plattform | Konto nötig, technisch getrennt | betreiber |
Werkzeugkatalog, Regelwerk-Versionierung, Mandantenverwaltung/Freischaltung, Nutzungssicht — Netcell-IT-Personal, plattformweit über alle Mandanten |
Funktionstrennung bleibt bestehen, auch wenn in kleinen Firmen
dieselbe Person Admin und Verantwortlicher ist — das sind zwei Rollen
auf demselben Menschen, keine verschmolzene Rolle. admin (Ebene 4,
Mandanten-Verwaltung für den eigenen Account) und betreiber (Ebene 5,
Plattform-Betrieb für Netcell-IT über alle Mandanten) sind nicht
dasselbe, auch wenn beide "Admin"-artige Rechte haben — nur betreiber
ist der Nachfolger dessen, was früher (vor dem Produktwechsel)
admin hieß. Praktische Umsetzung dieser Doppelrolle: admin
bekommt zusätzlich dieselben Fachebene-Rechte wie verantwortlicher
(siehe requireFachebene/hatEntscheidungsrecht) — pragmatisch gelöst
ohne Datenmodell-Umbau, siehe Offene Punkte weiter unten.
Rechte werden als Prüfung an jeder Aktion durchgesetzt (Middleware je Handler), nicht als grob unterschiedene Seitenbereiche.
Ebene 4 ist inzwischen zu großen Teilen umgesetzt (Anspruch: alles, was im Datenmodell existiert, muss über das Frontend steuerbar sein, nicht nur einsehbar — Deklarix soll ein buchbarer Service werden, ein reiner Anzeige-Screen reicht nicht):
- Nutzerverwaltung (
internal/web/admin_handlers.go, MiddlewarerequireAdmin):GET /verwaltung/nutzerlistet alle Logins des eigenen Mandanten mit Status (aktiv/deaktiviert),GET/POST /verwaltung/nutzer/neulegt einen weiteren Login mit einer der vier Mandanten-Rollen an (mitarbeiter,verantwortlicher,pruefer,admin—betreiberkann kein Mandanten-Admin vergeben, das ist Ebene 5). Es gibt noch keine Einladungsmail — der Admin setzt das Initialpasswort direkt im Formular. Nutzer werden nicht gelöscht (app_userwird vonantrag/entscheidung/audit_logper Foreign Key referenziert — ein Hard-Delete würde die Historie zerstören), sondern überPOST /verwaltung/nutzer/{id}/deaktivierenbzw..../aktivieren(de-)aktiviert (Spalteapp_user.active, Migration 0012). Ein deaktivierter Login kann sich nicht mehr anmelden (handleLoginprüftActiveerst NACH der Passwortprüfung, um keine Kontoexistenz zu verraten) und verliert eine bereits laufende Sitzung sofort (authenticate-Middleware prüftActivebei jedem Request). Ein Admin kann sich nicht selbst deaktivieren (Aussperr-Schutz). - Abteilungen (
GET /verwaltung/abteilungen, anlegen + löschen): reine Stammdaten für das Fragebogen-Feld "Abteilung" — ohne diese Seite blieb die Abteilung-Auswahl im Fragebogen faktisch leer und unbenutzbar, das war kein Komfort-, sondern ein Funktionsdefizit. - Eigene Werkzeug-Sperrungen und -Einträge (
internal/web/mandant_werkzeug_handlers.go,GET /verwaltung/werkzeuge): ein Mandant kann einen zentralen Katalogeintrag für sich sperren/entsperren (werkzeug_sperre), ohne den zentralen Katalog selbst zu verändern — das bleibt Sache der Plattform (Ebene 5,betreiber_werkzeug_handlers.go). Zusätzlich kann ein Mandant eigene, nur für sich sichtbare Katalogeinträge anlegen/ bearbeiten/löschen (GET/POST /verwaltung/werkzeuge/neu,GET/POST /verwaltung/werkzeuge/{id},POST /verwaltung/werkzeuge/{id}/loeschen—werkzeug.account_id= der eigene Account). Nutzt dasselbe Formular wie der zentrale Katalog (werkzeugFormData/betreiber-werkzeug-form.html, einActionBase-Feld unterscheidet Betreiber- von Mandanten-Ziel-URL) — ein zentraler oder fremder Eintrag ist über diese Route nicht erreichbar (404).
Support-Login (Ebene 5, betreiber) ist umgesetzt — auf
GET /betreiber/accounts/{id} kann sich der Betreiber über
POST .../nutzer/{userID}/anmelden-als als ein aktiver Kunden-Nutzer
anmelden, ohne dessen Passwort zu kennen (handleBetreiberLoginAls in
betreiber_handlers.go, store.CreateImpersonatedSession, Migration
0014). Nicht für deaktivierte Nutzer möglich. Die neue Sitzung ersetzt
die eigene Betreiber-Sitzung (kein Sitzungs-Stack/"Zurück zum
Betreiber" — der Betreiber meldet sich danach mit den eigenen
Zugangsdaten neu an). Jede Nutzung erzeugt einen audit_log-Eintrag
(Action betreiber_login_als_nutzer); während der gesamten Sitzung
zeigt die Nav einen auffälligen Banner ("Support-Zugriff durch ...",
siehe currentImpersonator in middleware.go), damit nie unklar ist,
im Kontext eines fremden Kontos zu handeln.
Firmen-CRUD für den Betreiber ist umgesetzt: GET/POST /betreiber/accounts/neu legt eine Firma samt erstem admin-Login an
(dasselbe Ergebnis wie die öffentliche Registrierung, nur vom Betreiber
ausgelöst — z. B. für vertriebsunterstütztes Onboarding oder Testkonten),
POST /betreiber/accounts/{id}/umbenennen korrigiert den Firmennamen
(store.UpdateAccount). Jede Firmenanlage erzeugt einen
audit_log-Eintrag. Bewusst kein Löschen — ein Hard-Delete würde
gegen die Fremdschlüssel aus antrag/app_user/audit_log laufen und
Historie zerstören (dasselbe Muster wie bei Nutzern: deaktivieren statt
löschen, aber ein Sperren/Deaktivieren-Zustand für Accounts existiert
noch nicht, siehe unten).
Weiterhin nicht gebaut: Anmeldeverfahren-Konfiguration, das
Abo-System. Wichtige Korrektur (2026-08-31): die eigentliche
Zahlungsabwicklung/Rechnungsstellung läuft über ein anderes,
externes System — Deklarix baut dafür kein Stripe/Zahlungs-SDK.
Deklarix bildet nur den Subskriptionsstatus ab (Plan, gebuchte
Mitarbeiterzahl, Status wie test/aktiv/gekündigt, Ablaufdatum),
der von außen gesetzt wird — noch offen, wie genau ("von außen" heißt
aktuell: manuell durch den Betreiber, eine Schnittstelle zum externen
Abrechnungssystem ist nicht spezifiziert). Das Preismodell selbst
(3 €/Mitarbeiter/Monat, Mindestabnahme 10 Mitarbeiter, 14 Tage
Testphase) bleibt als Orientierung gültig, nur der Stripe-Teil der
Empfehlung vom 2026-08-29 entfällt. Sperren/Deaktivieren eines Accounts
durch den Betreiber hängt weiter an dieser (jetzt kleineren)
Subskriptions-Architektur (siehe Offene Punkte: account.verified
wurde beim Produktwechsel sogar entfernt).
Mandantenfähigkeit: jede Tabelle trägt account_id. Aktuell wird
Isolation in der Anwendungsschicht erzwungen (Handler vergleichen
AccountID gegen den angemeldeten Account, wie bisher) — die
Zielvorgabe "Isolation auf Datenbankebene erzwungen" (Postgres Row-
Level Security) ist eine bewusst noch nicht getroffene Entscheidung,
siehe Offene Punkte unten.
Fragebogen (Ebene 2, "Antrag stellen")
Adaptiv: Folgefragen erscheinen nur, wenn die Antwort sie auslöst. Alltagssprache, keine Fachbegriffe — die stehen nur in der Ableitung.
A — Vorhaben: titel, beschreibung (Freitext: was soll die KI
tun?), ergebnis (Freitext: was soll herauskommen?), haeufigkeit
(einmalig/gelegentlich/täglich/automatisiert), abteilung (aus
Stammdaten).
B — Daten (bestimmt die Datenklasse). Mehrfachauswahl Ja/Nein/ Unsicher, "Unsicher" zählt wie "Ja": Namen/E-Mail/Personenangaben (B1), Gesundheit/Religion/Herkunft/Gewerkschaft/Sexualleben (B2), Patienten-/ Mandanten-/Versichertendaten (B3), Bewerbungs-/Personal-/ Leistungsdaten Beschäftigter (B4), von Kunden anvertraute Daten (B5), Konstruktions-/Kalkulations-/Vertragsunterlagen (B6), nur allgemein zugängliche/erfundene Inhalte (B7).
C — Verwendung des Ergebnisses (bestimmt die KI-VO-Einstufung): geht das Ergebnis unverändert nach außen (C1)? beeinflusst es eine Entscheidung über einen Menschen (C2, Folgefrage: Einstellung/ Beförderung/Kündigung, Kreditwürdigkeit, Leistungsbewilligung, Zugang zu Bildung, medizinische Beurteilung, keins davon)? erkennt/bewertet es Emotionen, Verhalten oder biometrische Merkmale (C3)? läuft es ohne menschliche Prüfung (C4)? merkt der Empfänger, dass es von einer KI stammt (C5)?
D — Werkzeug: ggf. schon ein Werkzeug im Sinn (optional, Freitext), Firmen- oder privater Zugang, Firmen- oder Privatgerät.
Ableitungen (rules/*.yaml, internal/rules)
Datenklasse (rules/datenklasse.yaml) — höchste zutreffende Stufe
gewinnt: oeffentlich (B7) < intern (B6) < auftragsdaten (B5) <
personenbezogen (B1, B4) < berufsgeheimnis (B3, § 203 StGB) <
besondere_kategorie (B2, Art. 9 DSGVO). Die Rangfolge ist eine
Annahme (Spezifikation nennt keine expliziten Ränge) — siehe
rules/OPEN.md, vor Produktiveinsatz mit dem/der Datenschutz-
beauftragten bestätigen.
KI-VO-Einstufung (rules/kivo_einstufung.yaml) — Prüfreihenfolge,
erste zutreffende Stufe gewinnt: verboten (Art. 5 KI-VO: C3 mit
Emotionserkennung am Arbeitsplatz, Social Scoring oder biometrischer
Kategorisierung) → hochrisiko (C2 mit Beschäftigung/Kreditwürdigkeit/
Leistungsbewilligung/Bildung/Gesundheit) → transparenzpflicht (C1 ja
und C5 nein) → minimal (alles übrige). Die genauen Fragebogen-Felder
für die drei "verboten"-Varianten sind noch nicht abschließend
spezifiziert — siehe rules/OPEN.md.
Anforderungsprofil (rules/anforderungen.yaml) — aus Datenklasse
und/oder Einstufung abgeleitete Menge von Anforderungen an ein
Werkzeug: avv_erforderlich, eu_verarbeitung,
kein_training_auf_eingabe, loeschfrist_max_tage,
menschliche_aufsicht, kennzeichnungspflicht, dsfa_erforderlich.
internal/rules lädt und validiert diese drei Dateien (eindeutige IDs,
eindeutige Ränge, jede Anforderung braucht mindestens einen Auslöser)
und wertet sie seit Schritt 3 auch aus: EvaluateDatenklasse,
EvaluateEinstufung, IstVerboten, DeriveAnforderungen operieren auf
rules.Antworten (geparst aus antrag.antworten via ParseAntworten)
— reine, für sich getestete Funktionen ohne DB-/Web-Zugriff.
FilterWerkzeuge/ErfuelltAnforderung filtern einen Werkzeugkatalog
hart gegen die abgeleiteten Anforderungen (WerkzeugEigenschaften ist
ein schlanker, von store.Werkzeug unabhängiger Typ, damit
internal/rules weiterhin ohne internal/store auskommt). Seit
Schritt 4 wird das auch tatsächlich aufgerufen: POST /antraege
berechnet nach dem Einreichen sofort eine bewertung (append-only,
internal/web/antrag_handlers.go:bewerteAntrag) — bei verboten
(K.-o.-Prüfung) ohne Werkzeugsuche — und GET /antraege/{id} zeigt sie
inklusive Herleitung, RegelwerkVersion/KatalogVersion und dem
Pflicht-Hinweis "Das System entscheidet nicht. Es bereitet vor."
Werkzeugkatalog (werkzeug, werkzeug_sperre)
Der eigentliche Wert des Produkts. Zentral gepflegt (Ebene 5,
account_id IS NULL), für alle Mandanten identisch sichtbar; jeder
Mandant kann zusätzlich eigene Einträge (account_id gesetzt) und
Sperrungen zentraler Einträge (werkzeug_sperre) führen, ohne den
zentralen Katalog zu verändern.
Pflicht: letzte_pruefung und quelle sind NOT NULL — jede
Zusicherung im Katalog (AVV verfügbar, Verarbeitungsländer, Training-Opt-
out, Zertifizierungen) muss belegbar sein, sonst haftet Deklarix für
eine Aussage, die nicht nachweisbar ist. Beide Katalogansichten zeigen
eine dreistufige Ampel zum Prüfalter (pruefStatus() in
betreiber_werkzeug_handlers.go, CSS-Klassen status-niedrig/
-mittel/-hoch, dieselben Farbtoken wie die KI-VO-Risikoampel):
aktuell (grün, < 150 Tage), bald fällig (gelb, 150–180 Tage —
dasselbe 30-Tage-Vorwarnfenster wie bei der Wiedervorlage), abgelaufen
(rot, > 180 Tage).
aufbewahrung_tage ist NULLABLE (Migration 0015) — NULL bedeutet
"vom Anbieter nicht beziffert" und ist bewusst von 0 ("belegte
Zusicherung: sofortige Löschung", z. B. DeepL) unterschieden. Vorher
war die Spalte NOT NULL DEFAULT 0, wodurch beide Fälle identisch
aussahen — formatAufbewahrung() zeigt entsprechend "unbekannt" statt
"0 Tage".
geeignete_zwecke ist seit derselben Migration ein kontrolliertes
Vokabular statt Freitext: das Formular zeigt Checkboxen statt eines
Freitextfelds (gueltigeZwecke in betreiber_werkzeug_handlers.go,
serverseitig validiert via istGueltigerZweck). Grund: ein künftiger
automatischer Zweckabgleich (Bewertungslogik Schritt 3, weiterhin nicht
gebaut) würde an inkonsistentem Freitext ("Text" statt
"Textgenerierung") scheitern. Am 2026-08-31 von sechs auf zwölf
Kategorien erweitert, nachdem der Nutzer zurecht einwandte, dass sechs
Kategorien die tatsächliche Bandbreite an Einsatzgebieten nicht
abdecken: Textgenerierung, Übersetzung, Code, Bild, Transkription,
Recherche, Datenanalyse, Video, Audio/Sprache,
Kundenservice/Chatbot, Präsentation/Design, Automatisierung/Agenten.
Es gibt keinen einheitlichen Branchenstandard für KI-Funktions-
kategorien (regulatorische Taxonomien wie die KI-VO selbst oder OECD/
NIST klassifizieren nach Risiko, nicht nach Funktion; Marktplatz-
Kategorien wie bei G2 sind Marketing, kein Standard) — diese zwölf
sind Deklarix' eigene, bewusst begrenzte Liste für den künftigen
Zweckabgleich, keine allgemeine KI-Taxonomie.
Neues Feld subprozessoren (TEXT[], Migration 0015) macht
Unterauftragsverarbeiter (z. B. Anthropic bei Microsoft 365 Copilot,
AWS bei DeepL) als eigenes, durchsuchbares Feld sichtbar statt nur als
Freitext in einschraenkungen — das ist oft der Grund, warum ein
scheinbar EU-verarbeitetes Werkzeug die USA doch nicht ausschließt.
Sowohl der zentrale Katalog (GET /betreiber/werkzeuge) als auch die
mandantenseitige Sicht (GET /verwaltung/werkzeuge) zeigen den Katalog
als Tabelle (Name, Anbieter, Verarbeitungsländer, DPF, AVV, Training aus,
Aufbewahrung, Zertifizierungen, Geeignete Zwecke, Zuletzt geprüft) statt
als Liste — vorher waren nur Name/Anbieter/Ort auf einen Blick sichtbar,
der Rest war erst nach Klick auf den Detaileintrag zu sehen.
Filter & Paginierung (seit 2026-08-31, pagination.go +
werkzeugFilter in betreiber_werkzeug_handlers.go): beide
Katalog-Tabellen haben eine Filterleiste (Freitext über Name/Anbieter/
Verarbeitungsländer, Dropdown über die zwölf Zweck-Kategorien) und
serverseitige Paginierung (20 Einträge/Seite, page-Query-Parameter).
Alles über ein einziges GET-Formular, ohne JavaScript — Prev/Next-
Buttons setzen page per <button type="submit" name="page" value="...">, damit q/zweck beim Blättern erhalten bleiben. Auf der
Mandanten-Seite (die pro Zeile zusätzlich Sperren/Entsperren-Formulare
hat) sind Filter und Paginierungs-Buttons bewusst in getrennte
<form>-Elemente aufgeteilt statt in eines um die ganze Tabelle —
verschachtelte <form>-Elemente sind ungültiges HTML, die
Paginierungs-Buttons tragen q/zweck deshalb als Hidden-Felder
nach. pagination.go ist bewusst generisch (Go-Generics,
paginate[T any]) für Wiederverwendung in weiteren Listen (Register,
Fälle, ...) — bisher nur auf den Werkzeugkatalog angewendet, das war
die von den beiden am dringendsten wachsende Tabelle.
Der zentrale Katalog ist aktuell mit 20 recherchierten Einträgen
befüllt (u. a. ChatGPT Enterprise, Microsoft 365 Copilot, Claude for
Work, Claude Code, GitHub Copilot Business, DeepL Pro/API, Google
Gemini für Google Workspace, Le Chat Enterprise, Perplexity Enterprise
Pro, Adobe Firefly Enterprise, Otter.ai Business, Notion AI, Grammarly
Business, Midjourney, Julius AI, Synthesia, ElevenLabs, Zendesk AI,
Canva Magic Studio, Zapier) — deckt damit alle zwölf Zweck-Kategorien mit
mindestens einem Eintrag ab. Claude Code ist bewusst ein eigener
Eintrag neben Claude for Work, kein Duplikat: es läuft lokal auf
Entwicklerrechnern mit direktem Zugriff auf Dateien/Codebasen und
speichert Sitzungsprotokolle lokal im Klartext (bis zu 30 Tage,
konfigurierbar) — ein zusätzliches lokales Aufbewahrungsrisiko, das bei
einer reinen Web-Chat-Anwendung wie Claude for Work nicht existiert,
obwohl beide über dieselben Commercial Terms/DPA laufen. Jede
Zusicherung ist mit einer echten,
offiziellen Anbieter-Quelle belegt (Stand 2026-08-31), nicht geraten —
und zwar auch dann, wenn keine Quelle eine positive Zusicherung
hergibt: Synthesia und Zapier haben training_standard/
training_opt_out bewusst NICHT gesetzt, weil keine belegte Aussage
zum Trainingsausschluss gefunden wurde (nicht mit "trainiert
nachweislich" verwechseln — es ist ein "wissen wir nicht, deshalb
keine Haken"). Mehrere Einträge sind bewusst mit kritischen
Einschränkungen versehen statt schöngefärbt, das ist die vorgesehene
Funktion des Katalogs: Adobe Firefly (Trainingsausschluss ist nicht
Standard), Otter.ai (Training auf de-identifizierten Aufnahmen,
laufende Rechtsstreitigkeiten), Midjourney (kein AVV, keine
EU-Datenresidenz, unbegrenzte Speicherung inkl. Training — laut
CLAUDE.md-Leitplanken für personenbezogene/vertrauliche Daten nicht
geeignet, absichtlich als Negativbeispiel im Katalog, nicht
weggelassen).
Schema-Lücke behoben (2026-08-31, Migration 0016): der grobe
verarbeitungsort-Eimer (EU/USA/gemischt/on-prem) wurde durch
verarbeitungslaender TEXT[] ersetzt — echte Länder statt Buckets.
Grund: "USA" war schon immer ein Drittland im Sinne der DSGVO (Art. 44
ff.) wie jedes andere auch, das Wort "Drittland" für einen fünften
Bucket-Wert wäre also verwirrend gewesen, nicht erhellend. Das Formular
bietet dafür eine echte Weltländerliste (weltLaenderliste in
laenderliste.go, <select multiple>, Mehrfachauswahl per Strg/Cmd-
Klick — kein JavaScript nötig) plus zwei Pseudo-Einträge ("EU-Region
(kein einzelnes Land benannt)" für Anbieter, die nur eine Region statt
eines Staates zusichern, z. B. Microsofts EU Data Boundary; "On-Premise
(selbst gehostet)"). Der harte Filter eu_verarbeitung
(internal/rules/evaluate.go, alleLaenderInEUEWR) verlangt jetzt,
dass ALLE genannten Länder EU/EWR-Mitgliedstaaten sind — eine leere
Liste gilt als nicht erfüllt, nicht als Freifahrtschein. Zusätzlich neues
Feld dpf_zertifiziert (Checkbox "EU-US Data Privacy Framework
zertifiziert"): DPF ist eine schmalere, gerichtlich schon zweimal
gekippte Rechtsgrundlage (Safe Harbor, Privacy Shield) für USA-Transfers,
bisher nur als Fließtext in "Einschränkungen" erwähnt, jetzt strukturiert
und damit künftig filterbar. Alle 19 Katalogeinträge wurden mit den
tatsächlichen, bereits recherchierten Ländern nachgepflegt (z. B.
Mistral → Frankreich, DeepL → Deutschland, Synthesia → Irland, Claude
for Work → USA) statt der pauschalen alten Buckets.
Das öffnet jetzt auch DeepSeek (Verarbeitung in China) für einen künftigen Katalogeintrag — die Sperre lag ausschließlich am alten Wertebereich, nicht an einer fachlichen Entscheidung.
dpf_zertifiziert recherchiert (2026-09-01), Offener Punkt erledigt.
Für alle 20 Einträge wurde gezielt gegen das offizielle Register
(dataprivacyframework.gov, per Participant-ID einzeln nachvollziehbar)
geprüft — nicht gegen die JS-Suchseite selbst (nicht scrapebar), sondern
gegen das dahinterliegende, von ihr selbst genutzte API. Ergebnis: 12
zertifiziert (Microsoft 365 Copilot, GitHub Copilot Business, Google
Gemini, Perplexity Enterprise Pro, Adobe Firefly, Otter.ai Business,
Notion AI, Grammarly Business, ElevenLabs, Zendesk AI, Canva Magic
Studio, Zapier), 5 recherchiert aber nicht im Register gefunden
(ChatGPT Enterprise, Claude for Work, Claude Code, Midjourney, Julius
AI — false heißt hier jetzt "geprüft, kein Eintrag gefunden", nicht
mehr "nicht recherchiert"), 3 nicht anwendbar (DeepL, Le Chat/Mistral,
Synthesia — reine EU-Verarbeitung ohne US-Rechtsträger im Register, DPF
regelt EU→USA-Transfers). Besonderheiten als einschraenkungen-Eintrag
je Werkzeug festgehalten: Perplexity hat nur EU-US/UK-, keine
Swiss-US-Zertifizierung; Notion AIs Zertifizierung ist als "Active" mit
bereits abgelaufenem Datum in Re-Zertifizierung; Grammarly ist im
Register unter dem fusionierten Rechtsträger "Superhuman Platform Inc."
gelistet, nicht mehr unter "Grammarly, Inc.".
CurrentKatalogVersion liefert eine reproduzierbare Kennung des
aktuellen Katalogzustands (Anzahl Einträge + letzte Änderung) — wird in
jeder Bewertung/Entscheidung eingefroren, damit im Audit nachvollziehbar
bleibt, mit welchem Katalogstand ein Vorschlag erzeugt wurde.
Bewertungslogik (geplant, Schritt 3+)
- K.-o.-Prüfung. Einstufung
verboten→ sofort Ablehnung (Art. 5 KI-VO), keine Werkzeugsuche. - Harte Filter. Werkzeuge aussortieren, die eine Pflichtanforderung nicht erfüllen — Grund je Werkzeug festhalten, auch aussortierte Werkzeuge werden im Ergebnis mit Begründung gezeigt.
- Zweckabgleich. Aus
beschreibung/ergebnisden Zweck ableiten (die zwölf Kategorien ausgueltigeZwecke, siehe Werkzeugkatalog weiter unten) und gegengeeignete_zweckefiltern. - Rangfolge. Verbleibende Werkzeuge sortieren: EU-Verarbeitung, Training standardmäßig aus, kurze Aufbewahrung, Aktualität der Prüfung.
- Auflagen ableiten. Statt Ablehnung Bedingungen erzeugen (Pseudonymisierung, keine Klarnamen, Vier-Augen-Prüfung, Kennzeichnung, Aufbewahrung begrenzen).
- Vorschlag erzeugen. Empfehlung, Alternativen, Auflagen, vollständige Herleitung.
Jede Bewertung wird als eigene Zeile gespeichert (append-only, wie
finding/extraction im alten Produkt) — eine neue Bewertung ersetzt
nicht die alte, sie ergänzt sie, damit Nachvollziehbarkeit auch bei
mehrfacher Neubewertung eines Antrags erhalten bleibt.
Entscheidung, Register, Wiedervorlage (Schritt 5-7 erledigt)
Schritt 5 (Entscheidung, Snapshot, Audit-Log) ist umgesetzt. Ebene 3
(Fachebene) hat einen Posteingang (GET /faelle, alle offenen —
Status "eingereicht" — Anträge des Mandanten, nicht nur die eigenen)
und eine Entscheiden-Seite (GET /faelle/{id}, POST /faelle/{id}/entscheiden, siehe internal/web/fachebene_handlers.go).
Rollen verantwortlicher und pruefer sehen dieselbe Sicht
(requireFachebene), nur verantwortlicher hat Entscheidungsrecht —
ein pruefer-POST auf /entscheiden bekommt 403 (er darf wissen, dass
der Fall existiert, nur nicht entscheiden; anders als bei
requireFachebene/requireBetreiber, die bei falscher Rolle 404
liefern, um die Existenz der Seite selbst zu verbergen).
Der/die Verantwortliche wählt: genehmigt / genehmigt mit Auflagen /
abgelehnt / Rückfrage. Vorschlag-Vergleich: vorschlagFuer leitet
aus der Bewertung ab, was das System vorschlagen würde (abgelehnt bei
verboten, sonst genehmigt wenn mindestens ein zulässiges Werkzeug
existiert, sonst rueckfrage) — weicht die tatsächliche Entscheidung
davon ab, ist das Begründungsfeld Pflicht (sonst 400). Snapshot bei
Entscheidung: entscheidung.werkzeug_snapshot (JSONB) friert den
vollständigen store.Werkzeug-Datensatz zum Entscheidungszeitpunkt ein
— ein späterer Katalog-Wandel darf nicht rückwirkend verändern, worauf
eine Entscheidung beruhte. Bei "genehmigt"/"genehmigt mit Auflagen" ist
ein Werkzeug aus der zulässigen Liste der Bewertung Pflicht (hart
geprüft, kein Override eines ausgeschlossenen Werkzeugs). entscheidung
ist append-only (Migration 0010) wie bewertung; jede Entscheidung
erzeugt zusätzlich einen audit_log-Eintrag (Action
antrag_entschieden).
Genehmigungen erhalten ein Ablaufdatum (gueltig_bis: 12 Monate, bei
hochrisiko 6 — gueltigkeitFuer).
Schritt 7 (Wiedervorlage, Katalog-Benachrichtigung) ist umgesetzt
(internal/web/wiedervorlage_handlers.go, GET /wiedervorlage, Ebene
3). Zum Zeitpunkt dieser Umsetzung gab es noch keine E-Mail-Infrastruktur
(die kam erst 2026-09-01 mit internal/mail, siehe „Passwort-
Zurücksetzen und E-Mail-Versand" weiter unten, bisher nur für
Passwort-Zurücksetzen genutzt) — "Benachrichtigung" ist deshalb bewusst
weiterhin eine In-App-Liste, kein E-Mail-Versand. Eine aktive Genehmigung erscheint dort,
wenn mindestens einer zutrifft: (1) gueltig_bis ist erreicht oder
liegt innerhalb von 30 Tagen, (2) das zugesagte Werkzeug wurde aus dem
Katalog entfernt, (3) werkzeugDiff erkennt eine Abweichung zwischen
dem eingefrorenen werkzeug_snapshot und dem aktuellen Katalogeintrag
bei AVV-Verfügbarkeit, Training-Standard oder Verarbeitungsländern. Keine
gefundene Abweichung → die Genehmigung erscheint nicht (kein stiller
Blanko-Eintrag für jede Genehmigung). store.ListAktiveGenehmigungenForAccount
liefert dafür alle genehmigt/genehmigt_mit_auflagen-Entscheidungen
eines Mandanten (Join über antrag.account_id, da entscheidung
selbst kein account_id trägt).
Schritt 6 (Registereintrag, Export) ist umgesetzt. Jede Genehmigung
erzeugt automatisch einen registereintrag (append-only, Migration
0011): Zweck (antrag.titel), Abteilung, Werkzeug, Datenklasse,
Einstufung, Auflagen (die Anforderungen der Bewertung), Verantwortliche/r,
Entscheidungsdatum, Gültigkeit — siehe internal/web/register_handlers.go.
GET /registereintraege (Ebene 3, verantwortlicher/pruefer) zeigt
sie, GET /registereintraege/export.{csv,pdf} exportiert. Abgelehnte
Entscheidungen und Rückfragen erzeugen bewusst KEINEN Registereintrag —
das Register ist eine Genehmigungsliste, kein vollständiges Antragslog
(das leistet bereits GET /faelle).
Freigabe-Workflow (konfigurierbare Mehrfach-Genehmigung, 2026-08-31)
Bewusste Abkehr von einer dokumentierten v1-Ausschlussentscheidung. Diese CLAUDE.md schloss "konfigurierbare Rollen mit eigener Oberfläche" und "Workflow-Designer" ursprünglich explizit für v1 aus (siehe "Nicht bauen (v1)" unten). Der Nutzer hat das am 2026-08-31 bewusst und nach Rückfrage aufgehoben ("Wir sollten das System schon perfekt bauen! Daher auch gerne komplex") — Auslöser war die Frage, ob ein Datenschutz- beauftragter und/oder die Geschäftsführung zusätzlich in die Entscheidung eingebunden werden müssen. Statt DSB/GF fest zu verdrahten, können Mandanten jetzt selbst definieren, wer bei welcher abgeleiteten Bedingung zusätzlich zustimmen muss.
Datenmodell (Migration 0017): vier neue Tabellen, orthogonal zu den
fünf bestehenden Zugriffsrollen (mitarbeiter/verantwortlicher/
pruefer/admin/betreiber bleiben unverändert):
genehmiger_rolle— eine mandantendefinierte Freigabe-Funktion (z. B. "Datenschutzbeauftragter", "Geschäftsführung"). Kein Zugriffsrecht, nur eine Freigabe-Zuständigkeit.nutzer_genehmiger_rolle— Zuordnung Person↔Genehmiger-Rolle, many-to-many. Eine Person kann null, eine oder mehrere davon haben, unabhängig von ihrerapp_user.role.freigabe_regel— "wenn Bedingung X zutrifft, ist zusätzlich eine Freigabe durch Genehmiger-Rolle Y nötig". Die Bedingung ist bewusst kein freier Regeleditor, sondern auf die drei Vokabulare beschränkt, die die Bewertungslogik ohnehin schon berechnet: Anforderungs-ID (z. B.dsfa_erforderlich), Einstufungs-ID (z. B.hochrisiko) oder Datenklasse-ID (z. B.besondere_kategorie). Kompromiss zwischen "konfigurierbar" (Nutzeranforderung) und "buildbar/auditierbar" (jede mögliche Bedingung ist bereits eine geprüfte, bekannte Ableitung, keine beliebige Freitext-Regel).freigabeschritt— pro Antrag ein Datensatz je ausgelöster Freigabe-Regel, Statusausstehend/genehmigt/abgelehnt.
antrag.status hat einen neuen Zwischenwert wartet_auf_freigabe
zwischen eingereicht und entschieden (CHECK-Constraint erweitert).
Ablauf: handleFallEntscheiden (internal/web/fachebene_handlers.go)
prüft nach einer Genehmigung/Genehmigung-mit-Auflagen, ob die Bewertung
mindestens eine freigabe_regel des Mandanten auslöst
(triggeredGenehmigerRollen). Falls ja: antrag.status wird
wartet_auf_freigabe statt entschieden, pro ausgelöster Regel entsteht
ein freigabeschritt, kein registereintrag entsteht noch. Falls
keine Regel greift, läuft exakt der alte Pfad (registriereGenehmigung)
— verifiziert per dediziertem Test
(TestOhneFreigabeRegelnVerhaeltSichWieVorher), da alle heutigen
Bestandskunden null Freigabe-Regeln haben und sich für sie nichts ändern
darf.
Bestätigte Semantik (2026-08-31, Nutzer: "Beides stimmt!"):
- Alle ausgelösten Genehmiger-Rollen müssen zustimmen, nicht nur
eine von mehreren — geprüft durch erneutes
ListFreigabeschritteForAntragnach jeder Einzelentscheidung. - Eine Ablehnung kippt den gesamten Antrag, geht nicht zurück an
die Fachebene zur Neuentscheidung.
KaskadiereAblehnungsetzt dabei automatisch alle anderen nochausstehenden Freigabeschritte desselben Antrags ebenfalls aufabgelehnt— sonst würde ein Freigabeschritt in der Liste einer anderen Person ewig unbearbeitet hängen bleiben, obwohl der Antrag längst entschieden ist.
Eine Ablehnung (ob direkt oder per Kaskade) mutiert nicht die
ursprüngliche genehmigt-Zeile — entscheidung bleibt append-only wie
bisher. Stattdessen entsteht eine neue entscheidung-Zeile mit
Entscheidung: "abgelehnt"; GetLatestEntscheidungForAntrag (bereits
bestehender "letzte Zeile gewinnt"-Helfer) zeigt den Override überall
dort, wo er ohnehin schon verwendet wird — keine weitere Codeänderung
nötig, um die Überschreibung sichtbar zu machen.
Frontend (internal/web/freigabe_handlers.go + drei neue Templates):
GET/POST /verwaltung/genehmiger-rollen (Admin: Rolle anlegen, Mitglieder
zuordnen/entfernen — Formular nutzt einen einzelnen <select> mit drei
<optgroup>s für die drei Bedingungstypen, kein abhängiges Dropdown,
kein JavaScript), GET/POST /verwaltung/freigabe-regeln (Admin: Regel
anlegen/löschen), GET /freigaben + POST /freigaben/{id}/entscheiden
(jeder eingeloggte Nicht-Betreiber mit zugeordneter Genehmiger-Rolle:
eigene offene Freigaben sehen und entscheiden, mit Pflicht-Kommentarfeld
bei Ablehnung). Fall-Detail (fall_detail.html) zeigt die
Freigabeschritt-Historie mit Ampel-Badge unabhängig vom aktuellen
Antragsstatus — anfangs fälschlich auf wartet_auf_freigabe
beschränkt gebaut, beim Live-Verifizieren aufgefallen: sobald der letzte
Freigebende zustimmt und der Antrag auf entschieden springt, verschwand
die komplette Freigabehistorie aus der Ansicht, was der Grundregel
widerspricht, dass eine Entscheidung nie ohne vollständige Herleitung
gezeigt wird. Der Hinweistext "Genehmigung erst endgültig, wenn alle
erteilt sind" erscheint entsprechend nur noch, solange der Antrag
tatsächlich noch auf Freigabe wartet.
CSS-Bugfix, beim Live-Verifizieren gefunden (sitesweit, nicht nur
diese Seite betreffend): .beitraege-liste > li:not(:has(> a)) form
hatte flex: 0 0 auto (kein Schrumpfen). Bei den bisherigen Listen
(Nutzer, Abteilungen) enthielten diese eingebetteten Formulare nur
Buttons, die naturgemäß schmal bleiben — die Genehmiger-Rollen-Seite ist
die erste mit einem <select> darin, dessen Browser-Eigenbreite
(bestimmt durch die längste Options-Beschriftung, hier E-Mail-Adressen)
den ganzen Zeilen-Container über die Mobile-Viewport-Breite hinaustrieb
(horizontales Scrollen der ganzen Seite). Fix: max-width: 100% auf
derselben Regel ergänzt — generisch, wirkt auf jedes künftige
<select> in diesem Listen-Pattern, nicht nur hier.
Live end-to-end verifiziert (curl gegen echten lokalen Server +
echtes Postgres, nicht nur die Go-Testsuite): Happy Path (eine Regel,
eine Rolle, Genehmigung → wartet_auf_freigabe → Freigabe erteilt →
entschieden + 1 Registereintrag) und Kaskaden-Ablehnung (zwei Regeln,
zwei Rollen, eine lehnt ab → beide Freigabeschritte abgelehnt, Antrag
direkt entschieden als abgelehnt, 0 Registereinträge) — beide exakt
wie spezifiziert. Chromium-Headless-Screenshots (Desktop 1440×900 +
Mobile 390×844) aller vier neuen/geänderten Seiten bestätigen zusätzlich
das responsive Layout nach dem enconf-Card-Pattern.
Standard-Genehmiger-Rollen bei Firmenanlage (2026-08-31, Migration
0018). Jede neue Firma bekommt jetzt automatisch vier leere
Genehmiger-Rollen mit erklärender Beschreibung (standardGenehmigerRollen
in internal/web/freigabe_handlers.go, aufgerufen aus handleRegister
und handleBetreiberAccountCreate direkt nach CreateAccount):
- Datenschutzbeauftragter — "Prüft Anträge mit besonderen Datenschutzanforderungen, z. B. wenn eine Datenschutz-Folgenabschätzung erforderlich ist."
- Geschäftsführer — "Trifft die unternehmerische Entscheidung bei besonders risikobehafteten Vorhaben."
- KI-Manager — "Bewertet den Antrag fachlich."
- CISO — "Prüft den Antrag aus Sicherheitssicht (Auditor)."
Bewusst ohne Mitglieder und ohne Freigabe-Regel — der Admin muss
nur noch Personen zuordnen (/verwaltung/genehmiger-rollen) und
optional eine auslösende Bedingung definieren
(/verwaltung/freigabe-regeln), statt bei null anzufangen. Welche
Bedingung welche Rolle tatsächlich auslöst, bleibt vollständig
Sache der Firma — das System schreibt keine feste fachliche Bindung
vor (z. B. keine hartkodierte Kopplung "Geschäftsführer ⇒ hochrisiko");
die Beschreibung ist reine UI-Orientierungshilfe, kein Ableitungsregel-
Ersatz. genehmiger_rolle.beschreibung ist ein freies, optionales
Textfeld (NOT NULL DEFAULT '') — auch für vom Admin selbst angelegte
zusätzliche Rollen nutzbar. Getestet via TestRegisterSeedsStandardGenehmigerRollen
und live per curl gegen echten Server + Postgres verifiziert.
E-Mail-Vorlagen (2026-09-01, Migration 0022)
Auf Nutzerwunsch: alle vom System versendeten E-Mails sollen editierbar
sein — für E-Mails, die an die eigenen Mitarbeiter einer Firma gehen,
durch den Mandanten selbst; für E-Mails, die die Plattform an
Mandanten-Admins schickt, durch den Betreiber. Zweistufig wie der
Werkzeugkatalog: email_vorlage.account_id NULL = plattformweiter
Standard (GET/POST /betreiber/email-vorlagen, nur Betreiber), gesetzt
= mandantenspezifische Übersteuerung (GET/POST /verwaltung/email- vorlagen, nur Admin). ResolveEmailVorlage liefert die eigene
Übersteuerung, falls vorhanden, sonst den Plattform-Standard — ein
Mandant, der nie etwas anpasst, bekommt automatisch jede künftige
Änderung des Plattform-Standards. emailVorlageTypen in
internal/web/email_vorlage_handlers.go ist die feste, bekannte Liste
der vom System versendeten E-Mails — aktuell nur
passwort_zuruecksetzen (die einzige E-Mail, die es bisher gibt),
bewusst keine generische "beliebige E-Mail anlegen"-UI, da jeder Typ an
eine echte Code-Stelle gebunden ist, die ihn tatsächlich versendet
(Platzhalter wie {{link}} sind pro Typ verschieden und müssten sonst
geraten werden).
Bug beim Live-Verifizieren gefunden und behoben: UNIQUE (account_id, typ) als einzelner Tabellen-Constraint reicht bei
NULLABLE account_id NICHT — SQL behandelt NULL nie als gleich zu
NULL, ein Mandant/Betreiber hätte also bei jedem Speichern eine neue
Plattform-Standard-Zeile statt eines Updates bekommen (genau das ist
beim ersten Testlauf passiert: zwei "Version 1"/"Version 2"-Zeilen
gleichzeitig, ResolveEmailVorlage griff die falsche). Fix: zwei
partielle Unique-Indizes (... WHERE account_id IS NULL /
... WHERE account_id IS NOT NULL) statt eines gemeinsamen Constraints
— UpsertEmailVorlage braucht dafür zwei unterschiedliche
ON CONFLICT-Ziele (SQL erlaubt nur ein Ziel je INSERT-Anweisung), mit
dediziertem Regressionstest (TestUpsertPlattformStandardAktualisiertStattZuDuplizieren)
abgesichert. Live per curl mit echtem SMTP-Test-Server erneut
verifiziert: zweimaliges Speichern des Plattform-Standards ergibt eine
Zeile mit dem aktuellen Stand, eine neu registrierte Firma ohne eigene
Übersteuerung bekommt automatisch den zuletzt gesetzten Plattform-Text.
Row-Level-Security (2026-09-01, Migration 0021)
Ausgangslage: RLS-Policies wirken nie bei Postgres-Superusern, und
nie beim Tabellenbesitzer ohne FORCE ROW LEVEL SECURITY (beides harte,
nicht überschreibbare Postgres-Regeln). Lokal verbindet die Anwendung
als echter Superuser postgres (Docker-Testumgebung) — dort hätte
FORCE allein nichts bewirkt. Auf dem Produktivserver verbindet sie
dagegen über eine eigene, nicht-privilegierte Rolle deklarix
(kein Superuser), die zugleich Eigentümerin der Tabellen ist — dort
reicht FORCE aus. Diese Erkenntnis kam erst nach einem
Fehlversuch (siehe "Incident" weiter unten) — die ursprüngliche
Annahme, auch Produktion verbinde als Superuser, war falsch und beruhte
auf einer Prüfung der falschen Rolle. Migration 0021 deckt seit der
Korrektur beide Fälle ab: FORCE ROW LEVEL SECURITY auf jeder Tabelle
(reicht für Produktion), plus optional eine neue, eingeschränkte Rolle
deklarix_app (kein Superuser, kein Tabellenbesitzer,
NOBYPASSRLS, zunächst NOLOGIN) für Umgebungen mit einer
Superuser-Verbindung wie lokal.
Architektur: internal/store/tenant_scope.go — Store.db(ctx)
liefert entweder die aktive Transaktion (falls WithTenantScope sie
gesetzt hat) oder den Pool direkt; alle ~69 vorherigen s.Pool.Query/ QueryRow/Exec-Aufrufe wurden mechanisch auf s.db(ctx).… umgestellt.
WithTenantScope(ctx, accountID, isBetreiber, fn) öffnet eine
Transaktion, setzt zwei Sitzungsvariablen per set_config(..., true)
(Parameterbindung, kein String-Interpolation — SQL-Injection-frei,
automatisch auf die Transaktion begrenzt) — app.account_id und
app.is_betreiber — und committet am Ende. SetTenantScope erlaubt,
die Variable mitten in einer laufenden Transaktion nachzutragen
(nötig bei handleRegister: die account_id ist erst nach
CreateAccount bekannt). Jede der fünf require*-Middlewares
(requirePage/-API/-Admin/-Fachebene/-Betreiber) umschließt die
komplette Handler-Ausführung jetzt mit withTenantScope — jeder
Request läuft dadurch automatisch in genau einer Transaktion, nicht nur
die Store-Aufrufe, die ein Handler zufällig selbst noch mal prüft.
Nebeneffekt (bewusst, positiv): ein Handler mit mehreren
Schreibzugriffen ist jetzt atomar — schlägt ein späterer Schritt fehl,
werden frühere Schritte desselben Requests zurückgerollt (vorher: keine
Transaktion, ein halb fehlgeschlagener Handler konnte einen verwaisten
Account ohne Nutzer hinterlassen).
Geschützte Tabellen (ENABLE + FORCE ROW LEVEL SECURITY + Policy
tenant_isolation): antrag, registereintrag, abteilung,
werkzeug_sperre, genehmiger_rolle, freigabe_regel,
loeschfrist_einstellung (direkte account_id-Prüfung), werkzeug
(nullable — NULL = zentraler Katalog, für alle lesbar, nur Betreiber
darf NULL-Zeilen schreiben), bewertung/entscheidung/
freigabeschritt (kein eigenes account_id, Policy prüft
antrag_id IN (SELECT id FROM antrag) — erbt automatisch dieselbe
Grenze wie antrag, da diese Unterabfrage in derselben Sitzung
läuft), nutzer_genehmiger_rolle (analog über genehmiger_rolle_id).
Bewusst OHNE RLS (dokumentiert in der Migration, kein Versehen):
account (keine account_id-Spalte, muss bei Registrierung
uneingeschränkt INSERT erlauben, bevor die ID bekannt ist), app_user
(Login/Passwort-Reset suchen per E-Mail über alle Mandanten hinweg —
die Ziel-account_id ist zu dem Zeitpunkt noch nicht bekannt),
session/password_reset_token (werden nur über einen
kryptographisch zufälligen, praktisch unerratbaren Token gesucht, kein
sequentiell erratbarer Schlüssel), audit_log (plattformweites
Protokoll ohne eigene account_id, wird ausschließlich vom Betreiber
gelesen, der ohnehin alle Mandanten sieht).
Live end-to-end verifiziert (nicht nur Unit-Tests): Rohes SQL
gegen deklarix_app direkt per psql (Isolation, Betreiber-Bypass,
Fail-Closed ohne Kontext, INSERT-Schutz gegen fremde account_id —
alle vier Fälle bestätigt), danach derselbe Beweis noch mal über den
echten laufenden HTTP-Server mit DATABASE_URL_APP auf deklarix_app
gesetzt: zwei Firmen registriert, Abteilung/Antrag/Bewertung einer
Firma über die andere nicht erreichbar, zentraler Werkzeugkatalog
(NULL-account_id) für beide sichtbar. Dabei einen echten Bug gefunden
und behoben: handleRegisters startSession-Aufruf nutzte noch
r.Context() statt der lokalen, transaktionsbehafteten ctx-Variable
— die Session-INSERT lief dadurch auf einer anderen (Pool-)Verbindung
als der gerade erst angelegte, noch nicht committete Nutzer, was einen
Fremdschlüssel-Konflikt auslöste und die gesamte Registrierung
zurückrollte. Zwei dedizierte Tests
(internal/store/tenant_scope_test.go, TestRLSAbteilungIsoliertZwischenMandanten,
TestRLSVerhindertInsertFuerFremdenAccount) laufen gegen die echte
eingeschränkte Rolle und überspringen sich selbst sauber, wenn
DATABASE_URL_APP nicht gesetzt ist (analog zum bestehenden
DATABASE_URL-Skip-Muster).
Incident 2026-09-01 (~3 Minuten Downtime) und Korrektur. Die erste
Fassung der Migration ging fälschlich davon aus, dass die Anwendung
überall als Postgres-Superuser postgres verbindet (das hatte ich
nur lokal und via sudo -u postgres psql auf dem Server geprüft — das
ist aber ein SSH/OS-Login-Check, nicht die tatsächliche
DATABASE_URL-Rolle der Anwendung). Tatsächlich verbindet Produktion
über eine eigene, nicht-privilegierte Rolle deklarix (kein
Superuser, kein CREATEROLE), die zugleich Eigentümerin aller Tabellen
ist. Die Migration versuchte dort CREATE ROLE deklarix_app
auszuführen, scheiterte mit "permission denied to create role", blieb
im dirty-Zustand hängen und der Dienst crash-loopte beim Start
(09:27–09:30 Uhr). Behoben durch: schema_migrations manuell auf
Version 20 zurückgesetzt, Paket auf v0.37.0 zurückgestuft, Dienst
stabilisiert — kein Datenverlust, Postgres hatte die fehlgeschlagene
Migration als DDL-Transaktion bereits sauber selbst zurückgerollt, nur
golang-migrates eigene Versions-Buchführung musste von Hand korrigiert
werden.
Korrigierte, robustere Migration: das Anlegen von deklarix_app ist
jetzt an eine Prüfung gekoppelt (SELECT ... WHERE rolname = current_user AND rolcreaterole) und wird bei fehlendem CREATEROLE übersprungen
(RAISE NOTICE, kein Fehler) statt die ganze Migration scheitern zu
lassen. Zusätzlich bekommt jede Tabelle jetzt FORCE ROW LEVEL SECURITY (vorher nur ENABLE) — das bindet auch den Tabellen-
besitzer an die Policies, sofern er kein Superuser ist. Damit deckt
eine einzige Migration beide Fälle ab:
- Produktion (
deklarix, Tabellenbesitzer, kein Superuser):FORCEallein reicht bereits aus. Kein manueller Schritt nötig — nach dem Deploy dieser Migration ist RLS dort sofort aktiv. - Lokales Docker-Postgres (
postgres-Superuser, für denFORCEwirkungslos bleibt):deklarix_appwird zusätzlich angelegt, für lokale Tests weiterhin perDATABASE_URL_APPnutzbar.
Erneut end-to-end verifiziert nach der Korrektur, diesmal
zusätzlich produktionsgetreu: eine zweite, temporäre lokale Rolle
(deklarix_sim, kein Superuser, kein CREATEROLE, Eigentümerin einer
frischen Test-Datenbank — exakt Produktions-Rechtemodell) durchlief die
komplette Migrationskette 0001–0021 fehlerfrei, und die Isolation
griff nachweislich auch für sie als Tabellenbesitzerin (Kontext A sah
ausschließlich Zeile A, trotz voller Tabellen-Eigentümerschaft). Vorher
war nur der deklarix_app-Pfad (Nicht-Eigentümer-Rolle) bewiesen, nicht
der tatsächliche Produktions-Pfad (Eigentümer-Rolle + FORCE) — genau die
Lücke, die den Incident verursachte.
Lehre für künftige Prüfungen dieser Art: "welche DB-Rolle verwendet
die Anwendung" per sudo -u postgres psql zu beantworten prüft die
falsche Sache — maßgeblich ist ausschließlich die Rolle in der
tatsächlichen DATABASE_URL (hier: cat /etc/deklarix/deklarix.env
bzw. SELECT rolname, rolsuper, rolcreaterole FROM pg_roles WHERE rolname = 'deklarix', nicht current_user über einen andere Anmeldung).
Löschfristen je Datenklasse (2026-09-01, Migration 0020)
Schließt rules/OPEN.md, Punkt 4. Recherche ergab: die DSGVO nennt
keine festen Speicherfristen (Art. 5 Abs. 1 lit. e verlangt nur "so
lange wie für den Zweck nötig") — es gibt also keinen fest im Regelwerk
hinterlegbaren "richtigen" Zahlenwert. Lösung: pro Mandant einstellbar
(loeschfrist_einstellung, GET/POST /verwaltung/loeschfristen,
admin-only wie jede andere /verwaltung/*-Seite — bewusst NICHT an den
Namen der Genehmiger-Rolle "Datenschutzbeauftragter" gekoppelt, siehe
Freigabe-Workflow oben: Genehmiger-Rollen sind explizit ohne eigenes
Zugriffsrecht). Jede neue Firma wird mit risikogestaffelten
Vorschlagswerten vorbelegt (seedStandardLoeschfristen, aufgerufen wie
seedStandardGenehmigerRollen direkt nach CreateAccount):
oeffentlich 365 / intern 180 / auftragsdaten 90 / personenbezogen
90 / berufsgeheimnis 30 / besondere_kategorie 30 Tage — eigene
Einschätzung nach Sensibilität gestaffelt, keine Rechtsvorgabe, frei
editierbar (Formular-Hinweistext sagt das ausdrücklich).
Harte Filterung jetzt aktiv (vorher, siehe rules/OPEN.md Punkt 4
alt, wurde loeschfrist_max_tage gar nicht geprüft): ErfuelltAnforderung
und FilterWerkzeuge (internal/rules/evaluate.go) haben einen neuen
Parameter loeschfristMaxTage *int. nil (keine Frist konfiguriert)
verhält sich wie vorher (nicht gefiltert) — Rückwärtskompatibilität.
Ist eine Frist gesetzt, erfüllt ein Werkzeug ohne bezifferte
Aufbewahrungsdauer (werkzeug.aufbewahrung_tage IS NULL) sie NICHT
(fail closed, analog zur leeren Länderliste bei eu_verarbeitung) —
dafür wurde rules.WerkzeugEigenschaften.AufbewahrungTage von int auf
*int geändert, sonst wäre "unbekannt" nicht von "0 Tage" unterscheidbar
gewesen. internal/web/antrag_handlers.go:bewerteAntrag liest die
Einstellung für die aktuelle Datenklasse des Antrags aus
ListLoeschfristEinstellungenForAccount und reicht sie durch.
Passwort-Zurücksetzen und E-Mail-Versand (2026-09-01)
Neues Paket internal/mail: Mailer-Interface (Send(to, subject, body) error), SMTPMailer als echte Implementierung (stdlib net/smtp,
STARTTLS falls vom Server angeboten — deckt den Standard-Submission-Port
587 ab, implizites TLS auf Port 465 wird von net/smtp nicht
unterstützt) und FakeMailer als Test-Doppel (sammelt gesendete Mails
statt sie zu verschicken). Konfiguration über SMTP_HOST/SMTP_PORT/
SMTP_USER/SMTP_PASSWORD/SMTP_FROM (alle optional — ohne
SMTP_HOST startet der Dienst normal, nur der tatsächliche Versand
schlägt dann mit einem klaren Fehler fehl statt einer nie ankommenden
Mail vorzutäuschen; bewusst kein stiller No-op).
Erste Anwendung: Passwort-Zurücksetzen (Ebene 1, öffentlich, bisher
in der Rollentabelle nur als Absicht genannt, nie gebaut). GET/POST /passwort-vergessen nimmt eine E-Mail-Adresse entgegen und zeigt
immer dieselbe Erfolgsmeldung ("Falls diese E-Mail-Adresse bei uns
registriert ist, wurde ein Link verschickt") — unabhängig davon, ob die
Adresse existiert, aktiv ist, oder der Versand tatsächlich geklappt hat.
Das ist derselbe User-Enumeration-Schutz wie beim Login (identische
Fehlermeldung bei falscher E-Mail/falschem Passwort); ein Versandfehler
wird serverseitig geloggt (log.Printf, nicht verschluckt), aber nicht
an den Client durchgereicht. password_reset_token (Migration 0019):
ein Token ist 1 Stunde gültig, genau einmal verwendbar (used_at),
GetValidPasswordResetToken liefert für "nicht gefunden", "abgelaufen"
und "schon verwendet" identisch ErrNotFound — auch hier kein
Unterschied, der einem Angreifer etwas verraten könnte. GET/POST /passwort-zuruecksetzen/{token} prüft den Token, setzt bei Erfolg das
neue Passwort (store.SetUserPassword) und verbraucht den Token.
Live end-to-end verifiziert — nicht nur gegen mail.FakeMailer in
Tests, sondern gegen einen echten, minimalen SMTP-Server (selbst
geschriebener TCP-Listener, der die EHLO/MAIL FROM/RCPT TO/DATA-Sequenz
korrekt beantwortet): Registrierung → Anfrage → tatsächlich empfangene
Mail mit funktionierendem Link → Formular → neues Passwort → Login mit
altem Passwort abgelehnt, mit neuem Passwort erfolgreich. Dabei einen
echten Bug gefunden und behoben: der Versandfehler wurde zunächst mit
_ = s.mailer.Send(...) still verschluckt (Verstoß gegen die
"keine stillen Fallbacks"-Regel) — beim Debuggen selbst schmerzhaft
aufgefallen, jetzt geloggt statt verworfen.
Onboarding
Firma (Ebene 1, öffentlich, POST /register): Registrierungsformular
mit Firmendaten → Testzugang sofort aktiv → Freischaltung des
Bezahlbetriebs nach Prüfung durch den Betreiber (letzteres noch nicht
gebaut). Beim Anlegen entstehen automatisch: Account, erster Nutzer mit
Rolle admin (siehe Rollentabelle — vereinfacht v1: nicht zusätzlich
separat als verantwortlicher angelegt, siehe Offene Punkte),
leere Abteilungsliste, Werkzeugkatalog aus der Plattform vorbelegt
(noch nicht automatisiert — der zentrale Katalog ist aktuell leer, bis
Ebene 5/Betreiber-Pflege gebaut ist).
Mitarbeiter, geplante Reihenfolge: (1) Einladungslink —
erledigt (internal/web/einladung_handlers.go: account.einladung_token,
Migration 0013, ein Sammellink pro Mandant statt Einmal-Token pro
Person, kein Ablaufdatum, per Admin unter GET /verwaltung/einladung
jederzeit erneuerbar [macht den alten Link sofort ungültig].
GET/POST /einladung/{token} — öffentlich, Ebene 1 — legt einen Login
mit Rolle mitarbeiter im Mandanten des Links an und meldet direkt an,
ohne E-Mail-Bestätigung [noch nicht gebaut, siehe unten]. Die Abteilung
wählt die Person weiterhin erst beim ersten Antrag, nicht bei der
Einladung.), (2) CSV-Import — erledigt (internal/web/csv_import_handlers.go,
GET /verwaltung/nutzer/import: CSV mit Kopfzeile, Pflichtspalte
"email", optionale Spalte "role" [Standard mitarbeiter]. Wie beim
einzelnen Nutzer-Anlegen gibt es keine Einladungsmail — pro Zeile wird
ein Einmalpasswort erzeugt [wiederverwendet auth.NewSessionToken()],
das NUR auf der Ergebnisseite direkt nach dem Import angezeigt wird und
danach nirgendwo mehr abrufbar ist. Kein Alles-oder-nichts-Import:
gültige Zeilen werden angelegt, eine ungültige Rolle oder bereits
vergebene E-Mail in einer Zeile blockiert nicht die übrigen Zeilen —
jede Zeile bekommt ihr eigenes Ergebnis [Passwort oder Fehlermeldung].),
(3) SSO (Entra ID/Google Workspace), (4) Antrag ganz ohne vorheriges
Konto über Mandantenlink + E-Mail-Bestätigungscode (Konto entsteht
nebenbei). (3)-(4) sind weiterhin nicht gebaut.
Abrechnung: nach vom Kunden angegebener Mitarbeiterzahl, nicht nach angelegten Nutzern — sonst entsteht der Anreiz, Mitarbeitende nicht anzulegen. Noch nicht gebaut (kein Abo-System).
Frontend
Anspruch (2026-08-29 explizit vom Nutzer bestätigt, da Deklarix ein
buchbarer Service werden soll): für Browser UND Mobile optimiert,
und jede Entität im Datenmodell muss über das Frontend steuerbar sein
— eine reine Anzeige-Seite ohne CRUD ist ein Defizit, kein v1-Kompromiss.
Stack bleibt html/template + htmx, kein React/Vue/SPA (siehe
Baureihenfolge/Architektur-Entscheidungen) — die responsive Optik wird
rein mit CSS erreicht, ohne das Template-Modell zu ändern.
Navigation (internal/web/templates/layout.html, "nav"-Block,
einmal definiert und von jeder Seite eingebunden — Änderungen hier
wirken auf das ganze Produkt): ein Checkbox-Hack (versteckte Checkbox +
<label for=...> als Klick-Ziel) klappt auf dem Handy ein Menü auf,
ganz ohne JavaScript. Nicht das naheliegendere <details>/<summary>
verwenden — aktuelle Chromium-Versionen steuern dessen Auf-/Zuklapp-
Zustand über eine interne, mit CSS (auch nicht mit !important)
nicht überschreibbare Animation; ein Versuch, .app-nav { display: flex !important } bei geschlossenem <details> zu erzwingen, bleibt
wirkungslos (mit Chromium-Headless-Screenshot verifiziert). Ab
960px Breite (@media (min-width: 960px), app.css) wird exakt
dieselbe Markup-Struktur per CSS zu einer permanenten, dunklen
Seitenleiste (Farb-Tokens aus design/enterprise.css übernommen) —
kein separates Desktop-Template.
Tabellen (table/th/td in app.css) sind seit dieser Änderung
gestylt (vorher komplett ungestylt, betraf v. a. register_liste.html);
breite Tabellen stehen in einem .table-scroll-Wrapper (horizontales
Scrollen auf dem Handy statt gequetschter Spalten). Seit 2026-08-31 hat
.table-scroll zusätzlich einen rechten Fade-Rand (mask-image) —
ohne den sah eine am Fensterrand abgeschnittene breite Tabelle wie ein
Rendering-Fehler aus statt wie "hier geht's mit Scrollen weiter" (per
Screenshot am Werkzeugkatalog beobachtet: die letzten zwei Spalten
waren nur als einzelne Buchstaben sichtbar, ganz ohne Hinweis auf mehr
Inhalt).
Formulare (.form-card/.form-section/.form-grid/.chip-group
in app.css, seit 2026-08-31, erstmals angewendet auf
betreiber_werkzeug_form.html): vorher war jedes Formular eine flache
Spalte aus <label>+<input> ohne Gruppierung — bei einem Formular
mit über einem Dutzend Feldern (Werkzeug anlegen) wirkte das "klobig,
einfach nur untereinander" (O-Ton Nutzer). Jetzt: .form-card (weiße
Karte mit Rahmen/Schatten, Ant-Design-Tokens aus design/enterprise.css
übernommen) enthält mehrere <fieldset class="form-section">
(thematische Abschnitte mit Titel, per border-top getrennt), darin
.form-grid (2 Spalten ab 640px für kurze Felder wie Name/Anbieter
nebeneinander, .form-full erzwingt volle Breite für lange Felder).
Checkbox-Gruppen mit überschaubar vielen Optionen (Zusicherungen, die
zwölf Zwecke) sind .chip-group — abgerundete Pillen statt einer
langen Untereinander-Liste, ausgewählte Chips werden über
:has(input:checked) blau hervorgehoben (kein JavaScript). Ein
Löschen-Button steht nicht mehr einfach unter dem Formular, sondern in
einer abgesetzten .form-gefahrenzone-Fußzeile der Karte. Dieselben
Klassen sind bewusst generisch (nicht an ein Formular gebunden) — noch
nicht auf andere Formulare (Login/Registrierung, Fragebogen, übrige
Admin-Formulare) angewendet, das wäre der nächste Schritt, falls
gewünscht. Beim Umbau auffällig gewordener, vorbestehender Bug: das
Lösch-Formular in betreiber_werkzeug_form.html war fest auf
/betreiber/werkzeuge/{id}/loeschen verdrahtet, obwohl dieselbe
Vorlage auch vom Mandanten-Formular (/verwaltung/werkzeuge/...)
genutzt wird — ein Mandant hätte beim Löschen eines eigenen Werkzeugs
403 bekommen. Jetzt {{.ActionBase}}/{{.ID}}/loeschen, wie die
Formular-action selbst.
Typografie-Nachbesserung (2026-08-31): Nutzer-Feedback nach dem
ersten Formular-Redesign: Überschriften wirkten zu klein und zu dicht
am Rand. Ursache für Letzteres: <legend> in <fieldset class="form-section"> positioniert sich per Spec an der Border-Box
des Fieldsets, nicht an dessen Padding-Box — ohne eigenes Padding sitzt
die Abschnittsüberschrift optisch enger am Kartenrand als der übrige,
per .form-section { padding: 20px } eingerückte Inhalt. `.form-section
legend
hat jetzt selbstpadding: 0 0 16px 0und eine größere Schrift (1.0625rem/700 statt 0.9375rem/600). Gleichzeitigh2(1.25rem) undh3(1.0625rem) erstmals explizit gesetzt (vorher reiner Browser-Default) und.fragebogen legend` auf dieselbe Gewichtsstufe wie die Formular-Abschnitte angehoben (600/1rem statt 500/0.9375rem) — für eine einheitliche Überschriften-Hierarchie statt Zufallsgrößen je nach UA-Stylesheet.
enconf-Patterns übertragen (2026-08-31): enconf (Schwesterprojekt)
ist React + Ant Design (ProTable/StatCard/PageHeader-Komponenten)
— nicht 1:1 übernehmbar, da Deklarix bewusst kein SPA ist, aber die
Muster wurden als reines CSS/HTML nachgebaut (recherchiert über die
Architect-RAG-Suche, ac_search_code_global, da das enconf-Repo selbst
außerhalb des Arbeitsverzeichnisses liegt):
.table-responsive(ersetzt.table-scrollschrittweise) — enconfsProTable.tsxschaltet auf schmalen Screens von Tabelle auf eine Card-Liste um (renderMobileCardviaGrid.useBreakpoint()), statt horizontal zu scrollen. Nachgebaut rein in CSS: unter 640px werden<tr>zu Karten,<td>zu Label/Wert-Zeilen (content: attr(data-label)im::before) — jedes<td>braucht dafür eindata-label="Spaltenname"-Attribut. Ab 640px normale Tabelle mit Scroll-Fade als Sicherheitsnetz. Bisher angewendet auf beide Werkzeugkatalog-Tabellen;.table-scroll(nur Scroll-Fade, keine Card-Umschaltung) bleibt für noch nicht migrierte Tabellen (Register, Nutzer-Import-Ergebnis) bestehen..page-header(Titel+Unterzeile links, primäre Aktion rechts, stapelt auf Mobile) — ersetzt einen nackten<h1>mit einem Link irgendwo unten auf der Seite. Bisher auf den Werkzeugkatalog angewendet (Betreiber + Mandant, inkl. "Eigene Werkzeuge"-Abschnitt)..stat-cards/.stat-card— Kennzahl-Kacheln mit farbigem oberem Akzentrand (blau normal, gelb/rot nur wenn der Wert > 0 ist — keine Farbe ohne Grund) statt einfacher Text-Links;.stat-card-actionfür die primäre Handlungs-Kachel (z. B. "Antrag stellen"). Kein Icon-Set/keine Icon-Bibliothek eingeführt (enconf nutzt@ant-design/icons, das ist React-spezifisch) — bewusste Vereinfachung. Angewendet auf die Startseite (index.html).
Ausgerollt (2026-08-31): Register, Nutzerverwaltung, Abteilungen, Fälle-Posteingang, Betreiber-Dashboard.
- Register (
register_liste.html) und Fälle-Posteingang (faelle_liste.html) haben jetzt.page-header+ Freitext-Filter (q) + Paginierung. Register zusätzlich.table-responsive(war vorher.table-scroll) mitdata-labelje<td>. - Nutzerverwaltung (
nutzer_liste.html) hat.page-header+ Freitext-Filter (E-Mail/Rolle) + Paginierung — blieb.beitraege-liste(schon Karten-artig), keine.table-responsive-Umstellung nötig. - Abteilungen (
abteilung_liste.html) bekam nur.page-header— bewusst ohne Paginierung, Abteilungslisten sind typischerweise klein (Handvoll Stammdaten pro Mandant). - Betreiber-Dashboard (
betreiber_dashboard.html) von.admin-kachelnauf.stat-cardsumgestellt (analog zuindex.html).
Neue gemeinsame Helfer in pagination.go: matchesQuery(q string, felder ...string) bool — eine einzige Freitext-Filter-Basis für alle
Listen-Seiten, damit nicht jede ihre eigene Such-Logik erfindet.
Beim Ausrollen gefundener, bereits live gewesener Bug: .filter-bar
direkt auf einem <form>-Element (statt auf einem <div> innerhalb
des Formulars) verlor gegen die allgemeine form { flex-direction: column }-Regel — beide Selektoren haben für die Property
flex-direction dieselbe Spezifität, aber nur form setzte sie
explizit, .filter-bar überließ sie dem Initialwert. Ergebnis: die
Filterleiste stapelte senkrecht statt in einer Zeile. Betraf bereits
die im vorigen Schritt ausgelieferte Mandanten-Werkzeugkatalog-Seite.
Behoben durch explizites flex-direction: row in .filter-bar selbst
— jetzt unabhängig davon robust, ob die Klasse auf einem <form> oder
einem <div> sitzt.
Ebenfalls ausgerollt (2026-08-31, zweite Runde): Betreiber-Accounts
(.page-header + Filter über Firmenname + Paginierung), Betreiber-
Audit-Log (.page-header + Filter über Aktion/Zieltyp/Details +
Paginierung), "Meine Anträge" (.page-header + Filter über Titel/
Status/Häufigkeit + Paginierung), Wiedervorlage (nur .page-header,
bewusst ohne Paginierung — natürlich begrenzt auf "braucht gerade
Aufmerksamkeit").
Bekannte Einschränkung beim Audit-Log: ListAuditLog holt weiterhin
nur die neuesten auditLogFetchLimit (1000) Einträge aus der DB, dann
wird in Go gefiltert/paginiert (auditLogFetchLimit-Konstante in
betreiber_handlers.go) — keine echte DB-seitige Offset-Paginierung.
Für ein potenziell sehr groß werdendes, append-only Audit-Log ist das
mittelfristig nicht die saubere Lösung (Einträge jenseits der 1000
neuesten sind über die Such-/Paginierungs-UI nicht erreichbar), aber
proportional zum aktuellen Datenvolumen und konsistent mit dem
In-Memory-Paginierungsmuster der übrigen Listen. Bei echtem Bedarf:
store.ListAuditLog um offset/echtes SQL-LIMIT/OFFSET erweitern.
Verifikationsmethode für CSS-Änderungen: Live-Cookie-Auth per
Chromium-Headless/CDP ist im Sandbox-Environment nicht möglich (kein
websocket-Python-Modul) — stattdessen: Seite per curl -b cookies.txt
authentifiziert abrufen, /static/...-Pfade im HTML auf eine absolute
http://localhost:<port>/static/...-URL umschreiben (Assets brauchen
kein Auth-Cookie), dann chromium --headless --disable-gpu --no-sandbox --window-size=<W>,<H> --screenshot=out.png file:///pfad/zur/seite.html
bei mobiler (z. B. 390×844) und Desktop-Breite (z. B. 1440×900)
screenshotten. Für einen Menü-Aufklapp-Zustand: checked-Attribut
manuell ins gespeicherte HTML einfügen vor dem Screenshot.
Projektstruktur
/var/www/deklarix/
├── cmd/
│ └── deklarix/
│ └── main.go # Entry Point: Migration, Regelwerk laden, HTTP-Server
├── internal/
│ ├── auth/ # Passwort-Hashing, Session-Token (produktunabhängig)
│ ├── rules/ # Lädt/validiert Datenklasse-/Einstufungs-/Anforderungs-YAML
│ ├── store/ # Postgres, Migrationen
│ └── web/ # Handler, Templates
├── rules/ # YAML-Regelwerk + OPEN.md (offene fachliche Fragen)
├── design/
│ └── enterprise.css # enconf Ant-Design-Theme — Referenz für Design-Tokens
│ (Farben/Radius/Schatten), nicht direkt eingebunden
│ (Ant-Design-Klassennamen passen nicht zu Deklarix'
│ reinem HTML/htmx-Stack, siehe internal/web/static/app.css)
├── packaging/
│ └── DEBIAN/
│ └── control.tmpl # .deb Package-Control-Template
├── scripts/
│ ├── build.sh # Cross-Compile amd64 + arm64
│ ├── test.sh # Tests + vet + build-check
│ └── release.sh # Vollständiger Release-Prozess
├── go.mod
├── go.sum
└── CLAUDE.md
Datenmodell
account— ein Mandant (ein Unternehmen). Jeder Antrag gehört genau einem Account.app_user— ein Login innerhalb eines Accounts. Rolle ist eine vonmitarbeiter,verantwortlicher,pruefer,admin(alle vier Mandanten-bezogen) oderbetreiber(plattformweit, Ebene 5).session— eine angemeldete Sitzung (Token, Ablaufzeit); echte Tabelle statt zustandsloser signierter Tokens, damit Logout eine Sitzung wirklich beendet.audit_log— Protokoll (append-only): jede Antragsänderung, jede Bewertung, jede Entscheidung, jede Katalogänderung mit Zeitstempel und Akteur.abteilung— Stammdaten für Fragebogen-Feld A.abteilung, je Mandant.werkzeug— Katalogeintrag (siehe oben);account_idNULL = zentral, gesetzt = mandantenspezifische Ergänzung. NICHT append-only — der Katalog wird gepflegt, eine Entscheidung friert den zu ihrem Zeitpunkt gültigen Datensatz separat ein (geplant).werkzeug_sperre— Sperrung eines (auch zentralen) Katalogeintrags durch einen einzelnen Mandanten.antrag— das Vorhaben aus Fragebogen-Abschnitt A plus die vollständigen Antworten aus B/C/D als JSON (antworten, JSONB) — ein starres Spaltenschema könnte den adaptiven Fragebogen nicht abbilden. NICHT append-only (normale Zustandsänderungentwurf→eingereicht→entschieden, wiesubmissiones im alten Produkt war).bewertung— der berechnete Vorschlag (Schritt 4).entscheidung— die Entscheidung eines/einer Verantwortlichen über einen Antrag, mit Snapshot des gewählten Werkzeugs (werkzeug_snapshot, JSONB) und Ablaufdatum (gueltig_bis) bei Genehmigung (Schritt 5).registereintrag— automatisch bei jeder Genehmigung erzeugt (Schritt 6), denormalisierter Nachweis (eigene Textspalten statt Joins über bewertung/entscheidung/werkzeug).
Append-only: kein UPDATE auf audit_log (Trigger forbid_update_delete,
wiederverwendet aus dem alten Produkt), ebenso bewertung,
entscheidung und registereintrag.
Baureihenfolge
Datenmodell, Regelwerk als YAML, Katalogstruktur— erledigt (dieser Stand:account/app_user/session/audit_log/abteilung/werkzeug/werkzeug_sperre/antragin Postgres,internal/ruleslädt und validiert die drei YAML-Regelwerke, Web-Layer kompiliert mit Auth + Plattform-Bereich-Gerüst).Fragebogen mit adaptiver Logik— erledigt (GET /antraege/neueinseitiges Formular,POST /antraegelegt an und reicht direkt ein,GET /antraegeeigene Anträge,GET /antraege/{id}Detail. Adaptive Folgefragen C2/C3 rein per CSS:has()ein-/ausgeblendet, kein JavaScript.antworten-JSON nutzt exakt die Fakten-Schlüssel ausrules/*.yaml(b1-b7, c1-c5, c2_folge, c3_art) — das hat auch die OPEN.md-Frage zu den "verboten"-Fragebogen-Feldern final geklärt.)Ableitungen und harte Filter— erledigt (internal/rules:EvaluateDatenklasse/EvaluateEinstufung/DeriveAnforderungen/IstVerboten/FilterWerkzeuge, vollständig getestet gegen die echtenrules/*.yaml-Dateien.)Ergebnisdarstellung mit Herleitung— erledigt (POST /antraegeberechnet und speichert einebewertungsofort beim Einreichen,GET /antraege/{id}zeigt Datenklasse, KI-VO-Einstufung, Anforderungen, zulässige/ausgeschlossene Werkzeuge — jeweils mit Herleitung — sowie RegelwerkVersion/KatalogVersion und den Pflicht-Hinweis "Das System entscheidet nicht. Es bereitet vor.".bewertungist append-only. Getestet inkl. K.-o.-Prüfung (verbotenüberspringt die Werkzeugsuche) und hartem Filter gegen den Katalog.)Entscheidung, Snapshot, Audit-Log— erledigt (Ebene 3:GET /faellePosteingang,GET /faelle/{id}+POST /faelle/{id}/entscheiden.verantwortlicherentscheidet,pruefersieht dieselbe Seite ohne Entscheidungsrecht [403 bei Entscheidungsversuch]. Begründung ist Pflicht bei Abweichung vom abgeleiteten Vorschlag (vorschlagFuer), Genehmigung friert den vollständigen Werkzeugdatensatz ein und braucht ein zulässiges Werkzeug aus der Bewertung,entscheidungist append-only, jede Entscheidung erzeugt einenaudit_log-Eintrag.)Registereintrag und Export— erledigt (internal/web/register_handlers.go:GET /registereintraegezeigt alle Registereinträge des Mandanten,GET /registereintraege/export.{csv,pdf}exportiert sie. Jede Genehmigung —genehmigtodergenehmigt_mit_auflagen— erzeugt inhandleFallEntscheidenautomatisch einenregistereintrag[append-only, Migration 0011]: Zweck [antrag.titel], Abteilung, Werkzeug, Datenklasse, Einstufung, Auflagen [die Anforderungen der Bewertung], Verantwortliche/r, Entscheidungsdatum, Gültigkeit — bewusst denormalisiert [eigene Textspalten statt Joins], damit ein Eintrag nicht von einem späteren Katalog-/Abteilungs-Wandel abhängt. Abgelehnte Entscheidungen und Rückfragen erzeugen keinen Eintrag. Der PDF-Export nutzt das bereits vorhandene, bis dahin ungenutztego-pdf/fpdf[keine neue Abhängigkeit] mitUnicodeTranslatorFromDescriptorfür deutsche Umlaute.)Wiedervorlage und Katalog-Benachrichtigung— erledigt (GET /wiedervorlage: abgelaufene/bald ablaufende Genehmigungen und Genehmigungen, deren Werkzeug sich seither im Katalog geändert hat oder entfernt wurde. In-App-Liste, kein E-Mail-Versand.)Konfigurierbarer Freigabe-Workflow (Mehrfach-Genehmigung)— erledigt (siehe "Freigabe-Workflow" weiter oben: Genehmiger-Rollen, Freigabe-Regeln, Freigabeschritte, Migration 0017 — bewusste Abkehr von der ursprünglichen v1-Ausschlussentscheidung, siehe "Nicht bauen (v1)" unten).
Nicht bauen (v1): automatische Genehmigung ohne Mensch, Erkennung tatsächlicher Werkzeug-Nutzung, Mitarbeiterüberwachung (nichts, was Nutzung einzelner Personen auswertet), Konzernstrukturen mit Vererbung, mandantenspezifische Regelwerke, SAML, Schnittstellen zu Fremdsystemen.
konfigurierbare Rollen mit eigener Oberfläche / Workflow-Designer
— am 2026-08-31 bewusst aufgehoben, siehe "Freigabe-Workflow"
weiter oben: Genehmiger-Rollen und Freigabe-Regeln sind jetzt genau das,
nur mit der Bedingung auf die drei bereits von der Bewertungslogik
berechneten Vokabulare beschränkt (kein beliebiger Regeleditor).
Go Commands
export PATH=$PATH:/usr/local/go/bin # Immer setzen!
# Entwickeln
go run ./cmd/deklarix/
# Tests
./scripts/test.sh
# oder direkt:
go test -race ./...
go vet ./...
# Build (amd64 + arm64)
./scripts/build.sh 1.0.0
# Build (nur amd64)
./scripts/build.sh 1.0.0 amd64
Build & Release-Prozess
Versioning (Semantic Versioning: MAJOR.MINOR.PATCH)
- MAJOR — Breaking changes, API-Inkompatibilitäten
- MINOR — Neue Features, rückwärtskompatibel
- PATCH — Bugfixes
Release-Schritte
# 1. Alle Änderungen committen
git add -p && git commit -m "feat: ..."
# 2. Release-Skript (macht Tests → .deb-Build → Gitea-Upload → Tag → Push)
./scripts/release.sh 1.2.0
# Danach liegt in dist/:
# deklarix_1.2.0_amd64.deb
# deklarix_1.2.0_arm64.deb
Was das Release-Skript tut
- Prüft: sauberer Git-Status (keine uncommitted changes)
- Führt
./scripts/test.shaus (vet + race tests + build-check, brauchtDATABASE_URL) - Baut
.deb-Pakete fürlinux/amd64undlinux/arm64(scripts/build.sh) - Lädt die Pakete in Giteas Debian-Paketregistrierung hoch (Kanal
testing, Suitesbookworm+trixie) — siehe „Deployment“ unten - Setzt Git-Tag
v<version>mit Annotierung - Pusht
main+ Tag nachorigin
Deployment (apt, Infrastruktur-Standard wie bei enconf)
Updates laufen ausschließlich über ein apt-Paket, nie über manuelles
Kopieren eines Binaries. .deb-Pakete landen in Giteas eingebauter
Debian-Paketregistrierung unter der Gitea-Org projekte (gleicher
Mechanismus wie beim Schwesterprojekt enconf-webpanel). Zielserver
richten sich einmalig ein:
sudo mkdir -p /etc/apt/keyrings
sudo curl -fsSL https://git.netcell-it.de/api/packages/projekte/debian/repository.key \
-o /etc/apt/keyrings/deklarix-gitea.asc
echo "deb [signed-by=/etc/apt/keyrings/deklarix-gitea.asc] https://git.netcell-it.de/api/packages/projekte/debian $(lsb_release -cs) testing" \
| sudo tee /etc/apt/sources.list.d/deklarix.list
sudo apt-get update && sudo apt-get install deklarix
Danach: apt upgrade für Updates, systemctl {start,stop,status} deklarix
für den Dienst. Konfiguration liegt in /etc/deklarix/deklarix.env
(aus deklarix.env.example beim Erstinstall kopiert, DATABASE_URL ist
dort absichtlich auskommentiert — der Dienst startet erst, wenn sie
gesetzt ist; postinst startet ihn sonst bewusst nicht, um keine
Crash-Restart-Schleife gegen einen Platzhalter-Host zu erzeugen).
Aktuell gibt es nur den Kanal testing (kein stable — es gibt noch
keine Kunden, für die eine Trennung nötig wäre).
Testing-Pattern & Qualitätssicherung
// Datei: internal/web/health_test.go
package web_test
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestHealth(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/health", nil)
w := httptest.NewRecorder()
HealthHandler(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
}
- Tests liegen neben dem Code:
paket/foo_test.go - Package:
package foo_test(Black-Box-Test) oderpackage foo(White-Box) - Race-Detector immer an:
go test -race ./... - Tabellenbasierte Tests für mehrere Inputs
DATABASE_URL und Testsystem. internal/store hat Integrationstests
gegen echtes Postgres (u. a. die Append-only-Garantie). Ohne
DATABASE_URL überspringt go test diese Tests einfach — praktisch für
schnelle lokale Iteration ohne lokales Postgres. ./scripts/test.sh
(und damit release.sh) ist strenger: es bricht ohne DATABASE_URL mit
Fehler ab, statt die Append-only-Garantie still zu überspringen. Es
zieht dafür aber kein eigenes Postgres hoch (kein Docker-Zwang auf
dem lokalen Dev-Rechner) — das ist Aufgabe des dedizierten Testsystems,
dort muss DATABASE_URL vorkonfiguriert sein.
Leitplanken, die dauerhaft gelten
- Keine Rechtsdienstleistung. Deklarix bereitet eine Einstufung vor, entscheidet nicht. Keine Formulierung darf eine individuelle rechtliche Bewertung suggerieren oder Erfolgsaussichten behaupten.
- Keine erfundenen Einstufungen. Jede Ableitungsregel steht in einer
Regel-YAML und wurde dort von Hand geprüft; offene fachliche Fragen
stehen in
rules/OPEN.md, werden nicht geraten. - Werkzeugkatalog-Zusicherungen müssen belegbar sein.
quelleundletzte_pruefungsind Pflichtfelder, keine Ausnahme. - Keine Mitarbeiterüberwachung. Nichts im Produkt darf auswerten, welche einzelne Person welches Werkzeug wie oft nutzt — das widerspricht dem Vertrauensmodell, auf dem die freiwillige Antragstellung beruht.
- DSGVO und Hosting in der EU.
Wichtige Hinweise
Go PATH
# Immer setzen — ist nicht im Standard-PATH des Servers
export PATH=$PATH:/usr/local/go/bin
Git Push
git push origin main
# Remote: https://git.netcell-it.de/projekte/deklarix.git
Server-Prozess
# Dienst läuft über systemd (installiert per apt, siehe Deployment oben)
sudo systemctl start deklarix
sudo systemctl status deklarix
# Config: /etc/deklarix/deklarix.env (DATABASE_URL, PORT, RULES_DIR)
# Logs prüfen
journalctl -u deklarix -f
Bekannte Server
| Rolle | Host | Zugang |
|---|---|---|
Testsystem + app.deklarix.de (DB-Integrationstests, apt-Verifikation, nginx-Reverse-Proxy + Let's-Encrypt-TLS auf den lokalen Dienst) |
89.163.205.4 | SSH noroot, sudo passwortlos |
Installer-Endpoint (get.deklarix.com) |
89.163.205.110 | noch nicht eingerichtet |
Marketing-Site (deklarix.de/.com) |
89.163.205.80 | noch nicht eingerichtet |
Arbeitsweise für Claude Code
- Kleine Commits, ein Thema pro Commit
- Erst Test, dann Implementierung
- Keine neue Abhängigkeit ohne Rückfrage
- Fehler nicht verschlucken, keine stillen Fallbacks
- Bei fachlicher Unsicherheit: nicht raten, sondern als offene Frage in
rules/OPEN.mdnotieren
Vor Änderungen
go vet ./...— keine Fehler./scripts/test.sh— alle Tests grün- Bestehenden Code lesen — nicht raten
Nach Änderungen
./scripts/test.sh→ 0 Fehler./scripts/build.sh <version>→ erfolgreich- Commit mit semantischer Message:
feat:,fix:,refactor:,docs: - Bei Release:
./scripts/release.sh <version>
Offene Punkte
Rangfolge der Datenklassen(rules/OPEN.md, Punkt 1) — am 2026-08-29 vom Produktverantwortlichen bestätigt, keine Änderung nötig.Anforderung(kein_training_auf_eingabeundauftragsdatenrules/OPEN.md, Punkt 2) — bestätigt,auftragsdatenbleibt eingeschlossen.Genaue Fragebogen-Felder für die drei "verboten"-Varianten— erledigt in Schritt 2, C3 hat jetzt eine Folgefragec3_art. Ob die drei Varianten fachlich vollständig sind, wurde am 2026-08-29 ebenfalls bestätigt (sieherules/OPEN.md, Punkt 3).Fallback ohne zutreffende Datenklasse(rules/OPEN.md, Punkt 5) — bestätigt,internbleibt die konservative Standardannahme.- Welche Anforderungen hart gegen ein Werkzeug gefiltert werden
(
rules/OPEN.md, Punkt 6 —avv_erforderlich/eu_verarbeitung/kein_training_auf_eingabehart,menschliche_aufsicht/kennzeichnungspflicht/dsfa_erforderlichnur als Auflage vermerkt) — weiterhin nicht ausdrücklich bestätigt, aber plausibel, keine Rückmeldung dazu bisher eingeholt. Löschfristen je Datenklasse— erledigt 2026-09-01, siehe „Löschfristen je Datenklasse" weiter oben: pro Mandant einstellbar statt hartkodiert, da die DSGVO selbst keine festen Fristen nennt.Mandantenisolation auf Datenbankebene (Postgres Row-Level Security)— Code/Migration/Tests erledigt 2026-09-01, Produktivbetrieb noch ausstehend (siehe „Row-Level-Security" weiter oben für den vollen Stand und was auf dem Produktivserver noch manuell nachzuziehen ist)."Admin und KI-Verantwortlicher" beim Firma-Onboarding— pragmatisch gelöst, kein Datenmodell-Umbau:app_user.rolebleibt ein einzelner Wert (keinroles-Array, keine zweiapp_user-Zeilen pro Person). Stattdessen bekommt die RolleadmininrequireFachebene(Ebene 3) dieselben Rechte wieverantwortlicher— sieht Posteingang/Register/ Wiedervorlage und darf entscheiden (hatEntscheidungsrechtinfachebene_handlers.go). Grund: ohne das könnte eine frisch registrierte Firma mit nur einemadmin-Login keinen einzigen eingereichten Antrag sehen oder bearbeiten — der Kernablauf wäre für Einzelpersonen-/Kleinfirmen-Onboarding komplett blockiert.prueferbleibt unverändert nur lesend, ohne Entscheidungsrecht. Kompromiss statt "sauberer" Lösung — falls künftig eine Firma admin und verantwortlicher bewusst auf zwei verschiedene Personen verteilen will, funktioniert das weiterhin unverändert (zwei separate Logins mit den jeweiligen Rollen).Zentraler Werkzeugkatalog muss noch befüllt werden— erledigt, 20 recherchierte Einträge seit 2026-08-31 (siehe „Werkzeugkatalog" weiter oben).— erledigt 2026-09-01 (siehe dort für Details je Anbieter). "Werkzeugkatalog aus der Plattform vorbelegt" beim Firma-Onboarding bleibt weiterhin nicht automatisiert — jede neue Firma sieht sofort den vollen zentralen Katalog (kein Kopiervorgang nötig,dpf_zertifiziertrecherchierenaccount_id IS NULList für alle Mandanten gleich sichtbar), es gibt nur keine mandantenspezifische Vorauswahl/Kuratierung beim Onboarding.- Markenanmeldung Deklarix, sobald erster Kunde zahlt.