Files
deklarix/CLAUDE.md
noroot b0d6b00045 fix: Migration 0021 crashte in Produktion — falsche Rollen-Annahme
v0.38.0 verursachte einen ~3-minütigen Ausfall: Migration 0021 nahm an,
die Anwendung verbinde überall als Postgres-Superuser "postgres" (nur
lokal via sudo geprüft, nicht die tatsächliche Produktions-DATABASE_URL)
und versuchte dort eine neue Rolle anzulegen - production verbindet
aber über die nicht-privilegierte, tabellenbesitzende Rolle "deklarix"
ohne CREATEROLE, das INSERT/CREATE ROLE schlug fehl und der Dienst
crash-loopte im "dirty migration"-Zustand. Kein Datenverlust (Postgres
hat die DDL-Transaktion selbst zurückgerollt), Dienst wurde auf v0.37.0
zurückgestuft und stabilisiert.

Fix: Rollen-Anlage ist jetzt an eine CREATEROLE-Prüfung gekoppelt und
wird bei fehlender Berechtigung übersprungen statt zu scheitern.
Zusätzlich FORCE ROW LEVEL SECURITY auf jeder Tabelle - das bindet auch
den Tabellenbesitzer (wie Productions "deklarix"), ganz ohne die
zusätzliche Rolle. Produktivbetrieb braucht dadurch jetzt gar keinen
manuellen Schritt mehr. Erneut end-to-end verifiziert, diesmal
zusätzlich produktionsgetreu simuliert (temporäre nicht-privilegierte,
tabellenbesitzende Rolle lokal).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 09:38:51 +02:00

78 KiB
Raw Permalink Blame History

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, Middleware requireAdmin): GET /verwaltung/nutzer listet alle Logins des eigenen Mandanten mit Status (aktiv/deaktiviert), GET/POST /verwaltung/nutzer/neu legt einen weiteren Login mit einer der vier Mandanten-Rollen an (mitarbeiter, verantwortlicher, pruefer, adminbetreiber kann 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_user wird von antrag/entscheidung/audit_log per Foreign Key referenziert — ein Hard-Delete würde die Historie zerstören), sondern über POST /verwaltung/nutzer/{id}/deaktivieren bzw. .../aktivieren (de-)aktiviert (Spalte app_user.active, Migration 0012). Ein deaktivierter Login kann sich nicht mehr anmelden (handleLogin prüft Active erst NACH der Passwortprüfung, um keine Kontoexistenz zu verraten) und verliert eine bereits laufende Sitzung sofort (authenticate-Middleware prüft Active bei 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}/loeschenwerkzeug.account_id = der eigene Account). Nutzt dasselbe Formular wie der zentrale Katalog (werkzeugFormData/betreiber-werkzeug-form.html, ein ActionBase-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, 150180 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+)

  1. K.-o.-Prüfung. Einstufung verboten → sofort Ablehnung (Art. 5 KI-VO), keine Werkzeugsuche.
  2. Harte Filter. Werkzeuge aussortieren, die eine Pflichtanforderung nicht erfüllen — Grund je Werkzeug festhalten, auch aussortierte Werkzeuge werden im Ergebnis mit Begründung gezeigt.
  3. Zweckabgleich. Aus beschreibung/ergebnis den Zweck ableiten (die zwölf Kategorien aus gueltigeZwecke, siehe Werkzeugkatalog weiter unten) und gegen geeignete_zwecke filtern.
  4. Rangfolge. Verbleibende Werkzeuge sortieren: EU-Verarbeitung, Training standardmäßig aus, kurze Aufbewahrung, Aktualität der Prüfung.
  5. Auflagen ableiten. Statt Ablehnung Bedingungen erzeugen (Pseudonymisierung, keine Klarnamen, Vier-Augen-Prüfung, Kennzeichnung, Aufbewahrung begrenzen).
  6. 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 ihrer app_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, Status ausstehend/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!"):

  1. Alle ausgelösten Genehmiger-Rollen müssen zustimmen, nicht nur eine von mehreren — geprüft durch erneutes ListFreigabeschritteForAntrag nach jeder Einzelentscheidung.
  2. Eine Ablehnung kippt den gesamten Antrag, geht nicht zurück an die Fachebene zur Neuentscheidung. KaskadiereAblehnung setzt dabei automatisch alle anderen noch ausstehenden Freigabeschritte desselben Antrags ebenfalls auf abgelehnt — 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.


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.goStore.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:2709: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): FORCE allein 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 den FORCE wirkungslos bleibt): deklarix_app wird zusätzlich angelegt, für lokale Tests weiterhin per DATABASE_URL_APP nutzbar.

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 00010021 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) Einladungslinkerledigt (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-Importerledigt (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

legendhat 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-scroll schrittweise) — enconfs ProTable.tsx schaltet auf schmalen Screens von Tabelle auf eine Card-Liste um (renderMobileCard via Grid.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 ein data-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-action fü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) mit data-label je <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-kacheln auf .stat-cards umgestellt (analog zu index.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 von mitarbeiter, verantwortlicher, pruefer, admin (alle vier Mandanten-bezogen) oder betreiber (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_id NULL = 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änderung entwurfeingereichtentschieden, wie submission es 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

  1. Datenmodell, Regelwerk als YAML, Katalogstrukturerledigt (dieser Stand: account/app_user/session/audit_log/ abteilung/werkzeug/werkzeug_sperre/antrag in Postgres, internal/rules lädt und validiert die drei YAML-Regelwerke, Web-Layer kompiliert mit Auth + Plattform-Bereich-Gerüst).
  2. Fragebogen mit adaptiver Logikerledigt (GET /antraege/neu einseitiges Formular, POST /antraege legt an und reicht direkt ein, GET /antraege eigene 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 aus rules/*.yaml (b1-b7, c1-c5, c2_folge, c3_art) — das hat auch die OPEN.md-Frage zu den "verboten"-Fragebogen-Feldern final geklärt.)
  3. Ableitungen und harte Filtererledigt (internal/rules: EvaluateDatenklasse/EvaluateEinstufung/DeriveAnforderungen/ IstVerboten/FilterWerkzeuge, vollständig getestet gegen die echten rules/*.yaml-Dateien.)
  4. Ergebnisdarstellung mit Herleitungerledigt (POST /antraege berechnet und speichert eine bewertung sofort 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.". bewertung ist append-only. Getestet inkl. K.-o.-Prüfung (verboten überspringt die Werkzeugsuche) und hartem Filter gegen den Katalog.)
  5. Entscheidung, Snapshot, Audit-Logerledigt (Ebene 3: GET /faelle Posteingang, GET /faelle/{id} + POST /faelle/{id}/entscheiden. verantwortlicher entscheidet, pruefer sieht 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, entscheidung ist append-only, jede Entscheidung erzeugt einen audit_log-Eintrag.)
  6. Registereintrag und Exporterledigt (internal/web/register_handlers.go: GET /registereintraege zeigt alle Registereinträge des Mandanten, GET /registereintraege/export.{csv,pdf} exportiert sie. Jede Genehmigung — genehmigt oder genehmigt_mit_auflagen — erzeugt in handleFallEntscheiden automatisch einen registereintrag [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 ungenutzte go-pdf/fpdf [keine neue Abhängigkeit] mit UnicodeTranslatorFromDescriptor für deutsche Umlaute.)
  7. Wiedervorlage und Katalog-Benachrichtigungerledigt (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.)
  8. 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-Designeram 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

  1. Prüft: sauberer Git-Status (keine uncommitted changes)
  2. Führt ./scripts/test.sh aus (vet + race tests + build-check, braucht DATABASE_URL)
  3. Baut .deb-Pakete für linux/amd64 und linux/arm64 (scripts/build.sh)
  4. Lädt die Pakete in Giteas Debian-Paketregistrierung hoch (Kanal testing, Suites bookworm + trixie) — siehe „Deployment“ unten
  5. Setzt Git-Tag v<version> mit Annotierung
  6. Pusht main + Tag nach origin

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) oder package 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. quelle und letzte_pruefung sind 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.md notieren

Vor Änderungen

  1. go vet ./... — keine Fehler
  2. ./scripts/test.sh — alle Tests grün
  3. Bestehenden Code lesen — nicht raten

Nach Änderungen

  1. ./scripts/test.sh → 0 Fehler
  2. ./scripts/build.sh <version> → erfolgreich
  3. Commit mit semantischer Message: feat:, fix:, refactor:, docs:
  4. 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_eingabe und auftragsdaten (rules/OPEN.md, Punkt 2) — bestätigt, auftragsdaten bleibt eingeschlossen.
  • Genaue Fragebogen-Felder für die drei "verboten"-Variantenerledigt in Schritt 2, C3 hat jetzt eine Folgefrage c3_art. Ob die drei Varianten fachlich vollständig sind, wurde am 2026-08-29 ebenfalls bestätigt (siehe rules/OPEN.md, Punkt 3).
  • Fallback ohne zutreffende Datenklasse (rules/OPEN.md, Punkt 5) — bestätigt, intern bleibt die konservative Standardannahme.
  • Welche Anforderungen hart gegen ein Werkzeug gefiltert werden (rules/OPEN.md, Punkt 6 — avv_erforderlich/eu_verarbeitung/ kein_training_auf_eingabe hart, menschliche_aufsicht/ kennzeichnungspflicht/dsfa_erforderlich nur als Auflage vermerkt) — weiterhin nicht ausdrücklich bestätigt, aber plausibel, keine Rückmeldung dazu bisher eingeholt.
  • Löschfristen je Datenklasseerledigt 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-Onboardingpragmatisch gelöst, kein Datenmodell-Umbau: app_user.role bleibt ein einzelner Wert (kein roles-Array, keine zwei app_user-Zeilen pro Person). Stattdessen bekommt die Rolle admin in requireFachebene (Ebene 3) dieselben Rechte wie verantwortlicher — sieht Posteingang/Register/ Wiedervorlage und darf entscheiden (hatEntscheidungsrecht in fachebene_handlers.go). Grund: ohne das könnte eine frisch registrierte Firma mit nur einem admin-Login keinen einzigen eingereichten Antrag sehen oder bearbeiten — der Kernablauf wäre für Einzelpersonen-/Kleinfirmen-Onboarding komplett blockiert. pruefer bleibt 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 werdenerledigt, 20 recherchierte Einträge seit 2026-08-31 (siehe „Werkzeugkatalog" weiter oben). dpf_zertifiziert recherchierenerledigt 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, account_id IS NULL ist für alle Mandanten gleich sichtbar), es gibt nur keine mandantenspezifische Vorauswahl/Kuratierung beim Onboarding.
  • Markenanmeldung Deklarix, sobald erster Kunde zahlt.