Compare commits

...

49 Commits

Author SHA1 Message Date
Benjamin Admin 1451873194 fix(audit): parse_flat_cookie_text fuer VW-Style Flat-Tabellen
CI / loc-budget (push) Failing after 19s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 3m4s
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 43s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
CI / detect-changes (push) Successful in 12s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 19s
VW Cookie-Doc liefert die Tabelle als FLACHEN Text ohne Spalten-Trenner:
'IDE Tracking Cookies (Marketing) Beschreibung 13 Monate Permanent
TAID Tracking Cookies (Marketing) ...'

parse_flat_cookie_text matched mit Regex:
  NAME [Tracking|Session|Funktional|...] Cookies ... [13 Monate|Session|Permanent]

Backend faellt bei parse_cookie_table=[] auf parse_flat zurueck. Damit
holen wir aus dem 65k VW Cookie-Doc ~30-50 Cookies + Vendors deterministisch,
auch wenn der HTML-Table-DOM-Extract leer ist (was passiert wenn die
Tabelle aus mehreren append-Code-Pfaden geladen wird).

Bonus: _extract_dom_tables Helper in dsi_discovery.py vorbereitet fuer
spaeteres Einhaengen an allen 7 DiscoveredDSI.append-Stellen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 21:24:14 +02:00
Benjamin Admin dfac940272 feat(licenses): attribution renderer — Stufe 1 (overview) + Stufe 3 (SourceBadge)
Backend
- backend-compliance/compliance/api/licenses_routes.py: three endpoints
  built on the now-complete license_rule classification
  - GET  /api/compliance/licenses/overview
       global aggregation by rule + per-source breakdown (Stufe 1)
  - POST /api/compliance/licenses/aggregate
       per-control-set aggregation for PDF footer (Stufe 2) and
       tech-file appendix (Stufe 4) — consumed later
  - GET  /api/compliance/licenses/source-info/{control_uuid}
       single-control lookup for the inline source badge (Stufe 3)
- registered in api/__init__.py via the existing safe-import loader

Frontend
- app/sdk/licenses/page.tsx (Stufe 1): the /sdk/licenses overview page.
  Renders rule legend cards + per-rule source tables. Drives the
  /licenses footer link and gives auditors a one-page view of what
  licence classes the platform is operating under.
- components/sdk/SourceBadge.tsx (Stufe 3): reusable React component.
  Small R1/R2/R3 pill with click-expand tooltip showing source
  regulation + attribution string + render-full-text policy. Will be
  embedded into IACE hazards/mitigations, VVT items, DSFA controls in
  follow-up commits.

Two stages of the four-stage renderer are now ready. Stufe 2 (PDF
auto-footer) + Stufe 4 (tech-file appendix) follow once the existing
PDF generators are extended to call /licenses/aggregate.
2026-05-21 21:00:10 +02:00
Benjamin Admin cb5dad1a2f feat(audit): A Audit-Transparenz + B Tabellen-Parse + D HTML-Tables aus DOM
CI / detect-changes (push) Successful in 10s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-python-backend (push) Successful in 45s
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 20s
CI / loc-budget (push) Failing after 17s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
Drei zusammenhaengende Fixes fuer den VW-Befund (6 Vendors statt 100+):

A — audit_quality_checks.py: drei systemische Vorbehalte die IMMER prominent
gezeigt werden:
* banner_detected=False trotz Cookie-Doc → HIGH 'CMP-Tool ungeladen'
* cookie_doc >= 30k chars aber cmp_vendors < 15 → HIGH/MEDIUM
  'Vendor-Liste auffaellig kurz fuer Doc-Groesse'
* submitted URL aber 0/Mini-Text → MEDIUM 'URL nicht ladbar'
Rote Audit-Vorbehalt-Box ueber dem GF-1-Pager. GF-Summary sagt
'Audit unvollstaendig' statt faelschlich 'Keine kritischen Themen'.
gf_one_pager nimmt audit_quality_findings in top_findings auf
(BEVOR andere Findings).

B — cookies_table_parser laeuft jetzt auch auf gecrawltem Cookie-Doc-
Text (nicht nur bei User-Paste). Wenn der dsi-discovery-Response Tab/
Pipe-getrennte Tabellen-Reihen liefert, parsen wir sie deterministisch.

D — consent-tester/dsi-discovery extrahiert jetzt zusaetzlich zum
Text die <table>-Elemente aus dem DOM als list[str] (Tab-getrennt pro
Zeile, mind. 2 Zellen, mind. 3 Zeilen, max 10 Tabellen pro Doc). Backend
schleust diese als 'html_table'-cmp_payload ein und jagt sie zuerst durch
cookies_table_parser → 100% deterministische Vendor-Extraktion ohne LLM.

VW-Erwartung: aus der 65k-Cookie-Tabelle werden jetzt 30-50 Vendors
deterministisch geparst statt 6 vom LLM-Cascade.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:21:28 +02:00
Benjamin Admin e411c4f0d3 feat(audit): Text-Paste-Mode pro Row — Crawler optional umgehen
CI / detect-changes (push) Successful in 12s
CI / branch-name (push) Has been skipped
CI / nodejs-build (push) Successful in 3m27s
CI / iace-gt-coverage (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 17s
CI / loc-budget (push) Failing after 20s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go (push) Has been skipped
CI / test-python-backend (push) Successful in 47s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
Hintergrund: VW liefert ueber URL-Crawler nur 6 Vendors statt der 100+
die in der echten Cookie-Tabelle stehen. Wenn der User die Tabelle aber
direkt von der Site kopieren kann (was bei den meisten OEM-Sites moeglich
ist), umgehen wir den Crawler komplett und parsen den Text deterministisch.

Backend:
* doc_type_classifier.py — 7 Pattern-Gruppen (§5 TMG, Art.13 DSGVO,
  AGB-Klauseln, Widerrufs-Frist, Cookie-Tabellen-Header, etc). Wenn der
  User Text ins falsche Doc-Type-Feld kopiert (Impressum->DSE),
  detect_mismatch liefert detected + action ('reclassify' bei sehr hoher
  Konfidenz, 'warn' bei medium).
* cookies_table_parser.py — Tab/Pipe/Komma/Semicolon-Separator-Auto-
  Detection, Spalten-Mapping per Header-Keyword. Aggregiert Cookie-
  Eintraege zu Vendor-Records (mit _guess_vendor-Fallback). Voll
  deterministisch, kein LLM.
* doc_input_warnings.py — Mail-Block ueber dem Audit, der Mismatches +
  Auto-Reclassifies dem User transparent macht.
* Pipeline: text gewinnt ueber url (war schon im Schema vermerkt), neue
  Felder declared_doc_type / input_source / reclassify_hint in doc_entries.
  Pasted-Tabellen-Vendors haben Vorrang vor Library-Fallback + LLM-Cascade
  (sind 100% genau).

Frontend (DocCheckTab):
* Pro Row Mode-Toggle 'URL' / 'Text einfuegen' (lila wenn aktiv).
* Textarea (h-32, monospace) im text-mode mit kontext-spezifischem
  Placeholder (Cookie-Hinweis ggue. anderen Doc-Types) und Live-
  Zeichen-/Wort-Counter.
* Submit-Button accepted entries mit URL ODER text.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:58:32 +02:00
Benjamin Admin 7335f64f4f feat(founding-wizard): Per-Person IP-Assignment + Prefill + E2E-Tests
CI / loc-budget (push) Failing after 20s
CI / detect-changes (push) Successful in 12s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 19s
CI / nodejs-build (push) Successful in 3m17s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 43s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
Wizard unterstuetzt jetzt 2-4 Gesellschafter mit individuellem IP-Bereich:
- Pro Gruender ein IP-Assignment-Vertrag (z.B. Benjamin: Compliance+RAG;
  Sharang: Security+Infrastruktur). Pro GF ein eigener Dienstvertrag.
- Step 1: Prefill-Button aus Unternehmensprofil + Felder Registergericht
  und HRB-Nr.
- Step 2: Rollen-Dropdown (CEO/CTO/CFO/COO/CPO/GF/Sonstige) statt freie
  Texteingabe, IP-Bereiche-Textarea pro Person.

Backend:
- generate_documents() iteriert pro Person fuer PER_PERSON_DOCS.
- _build_person_context() injiziert ASSIGNOR_*, GF_*, IP_LIST_DETAILS
  aus person.ip_areas.
- base_context() propagiert basics.register_court und basics.hrb_number.

Tests:
- 30/30 Pytest gruen (6 neue: Per-Person-Context, Slug-Helper,
  Registergericht-Propagation).
- 4 neue Playwright-E2E-Specs (hermetisch via route.fulfill, mit
  Console-/Page-Error-Traps): kompletter 8-Step-Flow, Prefill-Fehlerpfad,
  Step-Navigation/Reset, Rollen-Dropdown + IP-Areas.
- Spec setzt 'bp-sdk-cookie-consent' im addInitScript damit der
  CookieBannerOverlay nicht die Wizard-Buttons ueberlagert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:49:10 +02:00
Benjamin Admin 138d9068c4 fix(audit): VW-Cookie-Tabelle — Library-Fallback + Pattern-Extract verstaerkt
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / detect-changes (push) Successful in 11s
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 16s
CI / loc-budget (push) Failing after 18s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 41s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
VW-Lehre: cmp_vendors=6 (alle LLM-grob) wurde als ausreichend gewertet,
obwohl die echte Cookie-Tabelle 30+ Eintraege hat. 3 Fixes:

1. fallback_vendors_for_run skip-Schwelle: existing_vendor_count >= 3
   war zu niedrig. Jetzt nur skip wenn < 5 Cookies UND >= 5 Vendors
   schon vorhanden.

2. Library-Fallback wird jetzt aufgerufen bei < 20 cmp_vendors (statt
   < 3). VW-typische Setups (6 LLM-grob + 30 aus Library) bekommen
   damit eine vollstaendige Vendor-Liste.

3. _extract_cookie_names_from_doc: regex-Pattern-Extract aus dem
   Cookie-Doc-Text selbst — sucht nach 'NAME Tracking Cookies (Marketing)'
   etc. Findet Cookie-Namen die NICHT im Browser-Jar landen (z.B. nur
   nach Consent geladen werden). Diese werden zusaetzlich durch die
   Library matched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:32:07 +02:00
Benjamin Admin c281464071 feat(audit): P71 JC-vs-AVV Entscheidungsbaum
CI / detect-changes (push) Successful in 10s
CI / branch-name (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 15s
CI / test-python-backend (push) Successful in 39s
CI / test-python-document-crawler (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / loc-budget (push) Failing after 17s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
jc_avv_decision.py: detect_ambiguous_jc_avv prueft ob DSE-Text sowohl
JC-Signale (gemeinsame Auswertung, Schwesterunternehmen, Konzern...)
als auch AVV-Signale (Auftragsverarbeiter, weisungsgebunden...) enthaelt.
Bei Treffer rendert build_jc_avv_decision_html einen Block mit 4 EDPB-
basierten Leitfragen + jeweiliger Empfehlung.

Quellen: EDPB Guidelines 7/2020, EuGH C-25/17, C-40/17.

In Mail-Render zwischen Solutions-Block und VVT eingehaengt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:31:37 +02:00
Benjamin Admin 6dc427a754 fix(audit): VW-404-Recovery + P52 LLM-Merge + P51 Banner-UX-Checks
CI / detect-changes (push) Successful in 10s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 14s
CI / loc-budget (push) Failing after 17s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 42s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
VW-404-Fix: submitted_types zaehlt jetzt nur Doc-Types mit >= 200 Zeichen
echtem Text. Eine eingegebene URL die 404/Mini-Text liefert (VW cookie-
richtlinie.html) wird als 'missing' behandelt, sodass Auto-Discovery
alternative URLs auf der Homepage probiert. In-place-Update statt
Duplicate-Entry, rejected_url wird fuer Audit-Transparenz aufgehoben.

P52 LLM-Cascade Merge: vendor_llm_extractor laeuft jetzt bei < 5 Vendors
(nicht nur bei 0), und die Ergebnisse werden MIT existing cmp_vendors
gemerged statt zu ueberschreiben. VW-typische Setups (Generic CMP +
0 cmp_payloads) bekommen damit den Text-basierten Vendor-Layer dazu.

P51 — banner_consistency_checks erweitert:
* check_banner_copyability: scannt banner_html nach user-select:none /
  oncopy=return false / onselectstart. MEDIUM Finding wenn Banner-Text
  nicht kopierbar (Art. 7 (2) DSGVO).
* check_consent_history: prueft auf 'Meine Einwilligungen' / Consent-
  Historie / Datenschutz-Cockpit. MEDIUM wenn keine sichtbare Historie
  (Art. 7 (3) — Widerruf muss so einfach wie Erteilung sein).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:27:55 +02:00
Benjamin Admin 309c10c203 feat(audit): P72 MC-Scope-Filter + P73 MC-Solution-Generator
CI / detect-changes (push) Successful in 12s
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 15s
CI / loc-budget (push) Failing after 18s
CI / go-lint (push) Has been skipped
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 41s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
P72 — rag_document_checker LEFT JOINs canonical_controls.scope_doc_type.
_filter_by_canonical_scope wirft MCs raus deren scope explizit auf
einen inkompatiblen Doc-Type zeigt (Mapping in _SCOPE_COMPATIBLE).
Konservativ: 'other'/NULL/'process' bleiben drin — Heuristik v1 ist
noch nicht stark genug fuer hartes Filtern.

Erwartete Wirkung: ~10-15% weniger irrelevante MCs pro Doc, weil z.B.
ein TOM-MC nicht mehr als DSE-Finding auftaucht.

P73 — mc_solution_generator.py: Qwen->OVH Cascade generiert pro HIGH/
CRITICAL-Fail eine konkrete Einfuege-Empfehlung mit Anchor (wo + was)
und Aufwand-Schaetzung. JSON-Schema {solution_text, anchor_hint,
effort_min}. In-process LRU-Cache (500 entries) per (mc_id, doc_md5).

Max 3 Solutions pro Doc-Type, global Cap 8 — haelt Latenz < 60s. Bloecke
werden im Mail-Render unter VVT als 'Loesungs-Vorschlaege (KI-generiert)'
eingehaengt. Disclaimer: kein Rechts-Beratung, mit DSB pruefen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:21:19 +02:00
Benjamin Admin 4183379dc5 feat(audit): P33 3-Spalten-Vendor-Konsistenz (DSE/Cookie-Doc/Banner)
CI / detect-changes (push) Successful in 11s
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 15s
CI / loc-budget (push) Failing after 20s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 44s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Has been skipped
check_three_source_vendor_consistency: scannt DSE-, Cookie-Doc- und
Banner-Vendor-Liste auf 15 typische Vendor-Signaturen (Google Analytics,
Meta Pixel, Hotjar, HubSpot, LinkedIn Insight, ...). Listet Vendors die
in mind. einer Quelle stehen, aber nicht in allen sources_with_data.

Liefert MEDIUM-Finding mit konkreter 'fehlt in: DSE, Banner-Liste'-
Liste pro Vendor. Empfehlung: zentrale Vendor-Liste pflegen + in alle
drei Dokumenttypen propagieren. (Art. 13(1)(c)+(e) DSGVO)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:11:47 +02:00
Benjamin Admin c93c88577c feat(audit): P88 PDF-Export via WeasyPrint
CI / detect-changes (push) Successful in 9s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / test-python-backend (push) Successful in 42s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 14s
CI / loc-budget (push) Failing after 17s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
GET /api/compliance/agent/snapshots/{id}/pdf liefert application/pdf
mit dem vollen Audit-Mail-Inhalt im A4-Print-Layout (Header mit
Site/Timestamp/Snapshot-ID, Seitenzahlen unten rechts).

check_replay.py liefert jetzt zusaetzlich 'full_html' (nicht nur
500-char-preview), damit der PDF-Renderer das komplette HTML hat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:06:48 +02:00
Benjamin Admin 3207acea3e fix(audit): Replay-Pipeline um P35/P77/P78/P36 Signals-Block ergaenzen
CI / detect-changes (push) Successful in 10s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 14s
CI / loc-budget (push) Failing after 16s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 44s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
check_replay.py rendert jetzt auch die Textsignal-Findings (Save-Label-
Ambiguitaet, Cookies-in-DSE-Akzeptanz, JC-Klausel positiv, Social-Embeds).
Damit hat der Replay-Test parity mit der echten Mail-Pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:04:02 +02:00
Benjamin Admin 9f06911ff9 feat(audit): Cookie-Library-Fallback fuer VW-Pattern (kein bekanntes CMP)
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
CI / detect-changes (push) Successful in 10s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 17s
CI / loc-budget (push) Failing after 17s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 41s
Wenn nach Standard-Extract + Phase-G + LLM-Cascade weiterhin < 3 cmp_vendors
aber >= 5 Cookies im after_accept stehen (typisch: Custom-CMP wie VW
'cookiemgmt'), matcht der Fallback die Cookie-Namen gegen die
compliance.cookie_library und rekonstruiert Vendor-Records aus den
Library-Eintraegen.

Hintergrund: VW Run de2a029e zeigt 4 Vendors trotz 28 after_accept-Cookies.
cmp_payloads ist 0 (kein bekanntes IAB-Tool erkannt) und die hinterlegte
Cookie-URL liefert 404. Die DSE ist mit 34k zwar substanziell, listet aber
keine Vendor-Tabelle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:00:49 +02:00
Benjamin Admin 338e03d3b0 feat(audit): P34 Exec-Summary Score-Einordnung — 'wo Sie stehen sollten'
CI / detect-changes (push) Successful in 10s
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 14s
CI / loc-budget (push) Failing after 16s
CI / go-lint (push) Has been skipped
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 2m46s
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / test-python-backend (push) Successful in 43s
CI / test-python-document-crawler (push) Has been skipped
_score_band_explanation: vier Baender (Sehr gut/Akzeptabel/Handlungs-
bedarf/Erhoehtes Risiko) liefern Label + erwartete Handlung. Wird als
neue Zeile unter den KPIs in der Exec-Summary gerendert (mit
score-farbiger Linkmark).

Sachlicher Ton — kein 'Vorstand muss sofort handeln', sondern
realistische Empfehlung (z.B. '70-84: Branchen-Median, einmaliges
Aufraeumen + Halbjahres-Check').

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:51:34 +02:00
Benjamin Admin c491af5d02 feat(audit): P47 localStorage-Quota — safeSetItem mit Auto-Prune
CI / detect-changes (push) Successful in 8s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 13s
CI / loc-budget (push) Failing after 16s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 2m47s
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 41s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
storageHelpers.ts: safeSetItem faengt QuotaExceededError, prunet
alte doc-check-result-*-Eintraege (oldest first, MAX_KEEP=10) und
retried. Bei zweitem Fail aggressiver pruefen.

DocCheckTab.tsx nutzt safeSetItem statt setItem fuer doc-check-results,
result-Keys und history. Verhindert silent-data-loss + Crash wenn
~5MB localStorage-Limit erreicht.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:47:42 +02:00
Benjamin Admin 4171cf0efd feat(audit): P36 Social-Media-Einbindungs-Check
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 16s
CI / loc-budget (push) Failing after 18s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / detect-changes (push) Successful in 9s
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
CI / test-python-backend (push) Successful in 44s
CI / test-python-document-crawler (push) Has been skipped
check_social_embedding: erkennt direkte FB/Insta/Twitter/YouTube-
Embeds (connect.facebook.net, platform.twitter.com etc) vs
Heise-Shariff vs 2-Klick-Loesungen (Embetty).

Direkte Embeds ohne Schutz = HIGH (EuGH C-40/17 Fashion-ID — der
Site-Betreiber wird zum gemeinsam Verantwortlichen und braucht
Einwilligung VOR dem Drittanbieter-Call).
Shariff oder 2-Klick erkannt = INFO (positives Signal).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:45:12 +02:00
Benjamin Admin 30e43afba6 feat(audit): P86 Branchen-Benchmark + P35/P77/P78 Textsignale
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / detect-changes (push) Successful in 11s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / loc-budget (push) Failing after 19s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 41s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 15s
P86 — industry_benchmark.py: zieht alle Snapshots mit derselben
scan_context.industry, berechnet Median + Percentile, rendert
'Sie 42% — Automotive-Median 58% (Stichprobe: 12)'. Min Sample 3.

P35 — banner_text 'Speichern' ohne 'Ablehnen' = MEDIUM. Mehrdeutiges
Label nach EDPB 03/2022 Deceptive-Design-Guidelines.

P77 — DSE mit prominenter Cookie-Sektion (Vendor-Hints: Speicherdauer,
Anbieter, Datenkategorie) ersetzt die Forderung nach separater
Cookie-Richtlinie. Positives Signal statt False-Positive.

P78 — Art. 26-Klausel im DSE-Text erkannt → positives Signal
'JC-Konstrukt dokumentiert'. Vermeidet False-Positive bei
Konzern-Schwester-Kooperationen.

Alle in Mail eingehaengt: Branchen-Block nach GF-1-Pager, Signale-Block
nach Konsistenz-Check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:43:15 +02:00
Benjamin Admin df8832c521 feat(audit): P75 Banner-vs-CMP + P84 Diff-Mode + P74/P96/P97 Doc-Types
CI / detect-changes (push) Successful in 11s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 15s
CI / loc-budget (push) Failing after 18s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 42s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
P75 — check_banner_vs_cmp_partner_count: wenn Banner-Text 'N Partner'
nennt und N < cmp_vendors * 0.6, HIGH-Finding (Art. 13(1)(e) DSGVO).
Erkennt Verharmlosung der tatsaechlichen Vendor-Anzahl.

P84 — run_diff.py: vergleicht aktuellen Lauf mit letztem Snapshot
derselben Site (set-Diff auf normalisierten Finding-Labels). Block
ueber dem GF-1-Pager: 'Seit letztem Lauf: X Findings weg, Y neue'.
USP — keiner der grossen Anbieter hat das.

P74/P96/P97 — Labels fuer legal_notice (Rechtliche Hinweise / IP /
Forward-Looking), dsa (Art. 12+17 Digital Services Act), lizenzhinweise
(OSS-Compliance) in _DOC_TYPE_LABELS registriert. Echte Pflichtangaben-
Checks kommen separat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:38:25 +02:00
Benjamin Admin 7842c95532 feat(audit): P92 CMP-Tool-Verfuegbarkeit + P94 Banner-vs-Cookie-Doc-Konsistenz
CI / detect-changes (push) Successful in 11s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 16s
CI / loc-budget (push) Failing after 17s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 42s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
P92 — Wenn der Nutzer 'Anpassen'/'Einstellungen' klickt und der
CMP-Settings-Bereich kein Fehlerfreies Laden zeigt (Error, Timeout,
<80 Zeichen ohne Kategorien, keine Toggles), ist das ein HIGH-
Finding. Granulare Wahl formal vorhanden, faktisch nicht
funktionsfaehig (Art. 7 (3) DSGVO + EDPB 03/2022).

P94 — Cookie-Liste im Banner-Settings vs Cookie-Richtlinie. Heuristik
extrahiert Cookie-Namen aus dem Cookie-Doc-Text (regex auf typische
camelCase/_underscored Patterns + Vendor-Prefixes _ga/_gid/ot_/uc_).
Wenn |only_in_doc| >= 5 ODER |only_in_banner| >= 3 → MEDIUM-Finding.
|only_in_doc| >= 15 UND |only_in_banner| >= 5 → HIGH.

Beide Findings landen im neuen Mail-Block 'Banner-Konsistenz-Pruefung'
(amber-yellow) zwischen Mismatch-Block und VVT. Auch in
check_replay.py eingehaengt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:31:19 +02:00
Benjamin Admin 08671adfdf feat(audit): P82 GF-1-Pager + P87 Konfidenz-Score pro Finding
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / detect-changes (push) Successful in 12s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / test-python-backend (push) Successful in 43s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 18s
CI / loc-budget (push) Failing after 19s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
P82 — gf_one_pager.py: kompakte 5-Bullet-Kurzfassung ganz oben in der
Mail. Score (gross + Farbe), Delta-zu-Vorlauf, Top-Findings nach
HIGH/MEDIUM sortiert mit zustaendiger Rolle (DSB / Marketing / IT /
Legal / Web-Team) und Klassifizierungsbits aus dem Wizard.
Sachlicher Ton — keine 4%-Drohung, '4-8 Wochen' als realistischer
Zeitrahmen. Eingehaengt vor Critical-Findings-Block in Mail-Composition
und Replay-Pipeline.

P87 — finding_confidence.py: 13 Regex-Regeln liefern (confidence_pct,
reason) pro Finding-Label. Direkt im DOM beobachtbar = 95-98%,
Library-Mismatch = 82%, Textmuster-Match auf Pflichtangaben = 75-88%.
Im 1-Pager als kleines '(NN% Konfidenz)'-Tag mit Reason-Tooltip
hinter jedem Finding gerendert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:20:19 +02:00
Benjamin Admin 50fc0ecc59 feat(audit): P79 Pre-Scan-Wizard (8 Pflichtfelder) + P99 erweitert + P102 Replay-Fix
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 14s
CI / nodejs-lint (push) Has been skipped
CI / detect-changes (push) Successful in 11s
CI / branch-name (push) Has been skipped
CI / loc-budget (push) Failing after 17s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 2m56s
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 40s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
P79: PreScanWizard.tsx mit 8 Pflichtfeldern (Branche, B2B/B2C,
Direkt-Vertrieb, Rechtsform, Konzern-Struktur, MA-Zahl, Besondere
Daten, Drittland). Scan-Button disabled bis alle 8 ausgefuellt. Werte
landen in scan_context und ueber Backend in compliance_check_snapshots.

P99: DOC_TYPES um dsa + legal_notice + lizenzhinweise + nutzungsbedingungen
erweitert. URL-hinzufuegen-Button war schon da.

P102 (Replay-Bug): check_replay.py liest jetzt e.get('text') statt
nur full_text — Snapshot-Schema verwendet 'text'. Library-Mismatch-
Block wird damit auch im Replay angezeigt.

Backend: ComplianceCheckRequest.scan_context optional; save_snapshot
persistiert ihn in compliance_check_snapshots.scan_context.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:59:01 +02:00
Benjamin Admin 94057b1536 feat(audit): VW-Cookie-Bug-Fix + P101/P102 Cookie-Library-Mismatch-Findings
CI / loc-budget (push) Failing after 19s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 42s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
CI / detect-changes (push) Successful in 11s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 15s
VW-Bug B1: extract_vendors_via_llm hatte max_text_chars=12000 -> bei
VW-Cookie-Doc (60k chars, 100 Cookies in Tabelle) wurden 80% abgeschnitten,
LLM extrahierte nur 1 Vendor. Fix: max_text_chars=50000, num_predict
6000->16000 fuer mehr Vendor-Output, Ollama-Timeout 120s->420s.

P101 Aggregator-Script (backend-compliance/scripts/cookie_library_enrich.py)
geht alle compliance_check_snapshots durch und extrahiert (cookie_name,
declared_category, observed_sites). Erste Auswertung ueber 8 Snapshots:
101 unique Cookies, 47 in Library, 54 unbekannt, 18 Mismatches.

P102 Cookie-Klassifikations-Pruefung als Mail-Block. Vergleicht
Site-deklarierte Kategorie vs Library + Vendor-Doku. HIGH wenn Library
sagt 'marketing' aber Site als 'essential'/'statistics' deklariert
(faktische Drittland-/Werbe-Verarbeitung versteckt). MEDIUM sonst.
In agent_compliance_check_routes Mail-Komposition + Replay-Pipeline
eingebaut.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:47:11 +02:00
Benjamin Admin 9c11b5463c fix(audit): P98 + P100 — Cookie-Tabellen-Whitespace + Anpassen-Button-Check
CI / detect-changes (push) Successful in 11s
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / go-lint (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 18s
CI / loc-budget (push) Failing after 17s
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
P98: HTML-Tabellen-Zellen wurden bei VW-Cookie-Richtlinie ohne Whitespace
verkettet ('smartSignals2UiDsmartSignals2sUiDsmartSignals2CPs...'). Grund:
el.textContent ignoriert Block-Element-Grenzen. Fix: innerText (whitespace-
respecting) statt textContent. Cookie-Namen werden jetzt einzeln erkannt —
VW-Lauf sollte ~100 Cookies statt 1 finden.

P100: Banner-Check fuer 'Anpassen'/'Einstellungen'-Button im Initial-Banner.
VW-Pattern: nur 2 Buttons (Nur technisch notwendige / Alle akzeptieren),
keine granulare Wahl vor Akzeptanz/Ablehnung. Faktische Manipulation
Richtung Pauschal-Akzeptanz. HIGH-Finding nach EDPB 5/2020 §82.
Pattern: anpassen/einstellungen/cookie-einstellungen/manage cookies/
preferences/customize.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:08:33 +02:00
Benjamin Admin 50ed0f45af fix(replay): P80 — DocCheckResult-Import entfernt (gibt es nicht in runner)
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 36s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
CI / detect-changes (push) Successful in 10s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 14s
CI / loc-budget (push) Failing after 15s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
Vorher hatte ich den Container hotfixed aber den Fix nicht committed.
Beim naechsten Rebuild kam der Bug aus dem Image zurueck.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:25:04 +02:00
Benjamin Admin e1df24cad7 fix(audit): P93+P95 — Reject-Wording erweitert + Vendor-zentrisches Cookie-Format akzeptiert
CI / detect-changes (push) Successful in 10s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / test-python-backend (push) Successful in 38s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 16s
CI / loc-budget (push) Failing after 16s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
P93: 'Cookies verbieten', 'Tracking ablehnen', 'verweigern' usw. zaehlen
nun als expliziter Reject-Mechanismus. EDPB 5/2020 schreibt kein bestimmtes
Wort vor — BMW False-Positive 'Kein Ablehnen-Mechanismus' weg.

P95: cookie_table-Check akzeptiert nun zwei gleichwertige Formate:
(a) klassische Tabelle, (b) Vendor-Detailseite mit Block pro Anbieter
(Name+Anschrift, Zweck, Speicherdauer aggregiert, Cookie-Namen-Liste,
Opt-Out-Link). BMW-Stil mit Adform-Block ist DSK-OH 2024 konform.
False-Positive 'tabellarisches Cookie-Verzeichnis fehlt' wird seltener.

Hinweis-Text in cookie_table umformuliert: nennt beide akzeptablen
Formate, weniger normativ.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:21:29 +02:00
Benjamin Admin e5b4672f2a fix(audit): P90 — auto-discovery Timeout 180s -> 300s fuer BMW-Homepage
CI / detect-changes (push) Successful in 10s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 15s
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / loc-budget (push) Failing after 15s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 39s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:05:41 +02:00
Benjamin Admin 0d5c76ea98 fix(audit): P90-B1 — DSI-Discovery Timeout 120s -> 240s fuer BMW-Impressum
CI / detect-changes (push) Successful in 11s
CI / branch-name (push) Has been skipped
CI / go-lint (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 13s
CI / loc-budget (push) Failing after 15s
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 38s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
BMW-fafcb090 zeigte exception 'ReadTimeout' beim consent-tester-Call fuer
anbieterkennzeichnung.html. Der Discovery-Lauf folgt 3 Sub-Documents
(Versicherungsvermittler, Aufsicht, Berufsrecht) plus ePaaS-Captures —
braucht regelmaessig >120s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:52:59 +02:00
Benjamin Admin 54f5a06c2f fix(audit): P90-Diagnose — verbose Exception fuer fetch+auto-discovery
CI / detect-changes (push) Successful in 10s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 14s
CI / loc-budget (push) Failing after 15s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 38s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
BMW-Lauf 760de886 hat 0 cmp_payloads obwohl consent-tester ePaaS 4x captured.
Backend-Log zeigt 'Consent-tester fetch failed for ...anbieterkennzeichnung.html: '
mit LEEREM Exception-String. Auch 'auto-discovery failed for https://www.bmw.de/: '
ist leer. Quick-Fix: str(e) + type(e).__name__ in beiden Except-Bloecken,
damit naechster BMW-Lauf den echten Fehler sichtbar macht.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:45:28 +02:00
Benjamin Admin 86b4a263d2 fix(audit): P90-B1 — cmp_payloads bei kurzem DSE-Text nicht verwerfen
CI / detect-changes (push) Successful in 9s
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / test-go (push) Failing after 41s
CI / iace-gt-coverage (push) Successful in 25s
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 16s
CI / loc-budget (push) Failing after 15s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-python-backend (push) Successful in 35s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
BMW-Lauf 9811eba1 hatte 0 cmp_vendors obwohl consent-tester ePaaS 4x
captured (~393KB). Root-Cause in _fetch_text Z.1254:

  if merged and len(merged.split()) > 100:
      return merged, cmp_payloads

Wenn DSE/Cookie-URL nur kurzen SPA-Shell-Text liefert (BMW: 10 Worte),
greift die Schwelle nicht — Code faellt durch zum HTTP-Fallback der
return text, []  zurueckgibt. Die zuvor captured CMP-Payloads (ePaaS-JSON
mit allen Vendor-Daten) werden komplett verworfen.

Fix: vor dem HTTP-Fallback pruefen ob cmp_payloads vorhanden sind. Wenn ja,
diese zurueckgeben mit dem (kurzen) Text oder dem rekonstruierten
cmp_cookie_text. Auch ohne 100-Wort-Schwelle.

Effekt: BMW-VVT-Tabelle wird gefuellt (~90 Vendors aus ePaaS-JSON).
Mercedes/andere OEMs unveraendert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:29:41 +02:00
Benjamin Admin 7938e377b6 feat(audit-tonality): P89/P76/P91 — Co-Pilot statt Roboter-Anwalt
CI / branch-name (push) Has been skipped
CI / detect-changes (push) Successful in 11s
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 14s
CI / loc-budget (push) Failing after 15s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Failing after 48s
CI / iace-gt-coverage (push) Successful in 25s
CI / test-python-backend (push) Successful in 43s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
User-Feedback in einer Session: "Wir erzeugen nur Panik. Egal was da steht,
es dauert Wochen. Wir sind Tool an der Seite von CMO/GF/CIO, nicht Gegner."
Memory: feedback_breakpilot_tonalitaet.md (gilt fuer ALLE Module + Marketing).

P89  Critical-Findings-Block ENTFERNT/UMGEBAUT — keine Panik-Rot-Box mehr.
     - Statt "🚨 SOFORTMASSNAHMEN ERFORDERLICH" -> "Zusammenfassung fuer
       die Geschaeftsfuehrung", blauer dezenter Block
     - Statt "VERSTOSSE" -> "Themen zur Besprechung mit DSB, Marketing
       und Entwicklung"
     - Statt "Bussgeldrahmen 4% Weltumsatz" als Erstes -> realistische
       Einordnung (0,1-1%) in dezenter Schluss-Notiz mit Konfidenz-Hinweis
     - "Sofortmassnahme" -> "Empfehlung"
     - "Themen 1, 2, 3..." statt "HIGH"-Badges (P87-Vorbereitung)
     - Explizite Zeitschaetzung "4-8 Wochen (DSB -> Agentur -> Dev -> Freigabe)"

P76  Mercedes-Sekundaer-Buttons (Datenschutzerklaerung + Impressum klein
     unter den 3 Haupt-Buttons) erkennen. Walker scant jetzt label-basiert
     ALLE klickbaren Elemente im Shadow-DOM (wb7-link, wb7-link-secondary,
     wb7-button-text, span[onclick], small a, [role=button], etc.).
     Vermeidet Mercedes-Impressum-False-Positive der Phase 1.

P91  VVT-Tabellen-Renderer in neuer Co-Pilot-Tonalitaet. Statt
     "Verstoss-Liste mit Bussgeldpotenzial" -> Wahrscheinlichkeits-Aussage:
     "Bei Anbieter-Reduktion + Wechsel zu europaeischen Alternativen ist
     Reduktion des Tracking-Footprints + Lizenz-Einsparung wahrscheinlich.
     Fundierte Bewertung erfordert DSB-Abstimmung."

BMW-Bug B1-B4 (P90) bewusst nicht in diesem Commit: BMW-Lauf hat ePaaS
4x captured im consent-tester, aber Backend bekommt 0 cmp_payloads.
Wiring-Bug zwischen consent-tester /dsi-discovery und Backend
_fetch_text — eigene Diagnose-Session noetig (siehe Task P90).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:24:57 +02:00
Benjamin Admin f534b52817 feat(iace): pattern audit suite + library hygiene wave
Add cmd/iace-audit CLI with 5 deterministic methods that find engine
gaps without ground truth:

- A reachability: 1058 patterns vs achievable tag universe
- B consistency: components vs their declared hazard categories
- C vocabulary: limits-form tokens vs keyword dictionary
- D echo: limits-form sentences vs generated hazards (jaccard)
- E hierarchy: hazards vs ISO 12100 design/protection/info levels

Library fixes triggered by A+B+C findings:

- tag_resolver: synonym map for electrical/pneumatic/hydraulic aliases
- component_library: crush_point + EN03 (gravitational) on C014/C128
  (Hubwerk family) - fixes HP1014/1015/1017/1018 which were silently
  weakly_reachable. noise_source added on 7 components (C006/C011/
  C017/C020/C031/C041/C096). electrical_part on 8 drive components
  (C031/C032/C033/C034/C035/C036/C037/C038/C077/C092). cyber tag
  on 10 sensors (C081-C090) + 3 IT components (C111/C112/C116) +
  KI module C119 (ai_model added). pneumatic_part+hydraulic_part
  on valves C091/C093, hydraulic_part+chemical_risk on pump C097,
  moving_part on motion controller C075
- keyword_dictionary: EN03 added to aufzug/lift/hubwerk/hubgeraet
  (was wrongly EN04-only). New keyword entries for hub-action verbs:
  absenken/senken/anheben/heben + hubhoehe/hubweg/hubgeschwindig

Audit impact:
- A: weakly_reachable 409 -> 358 (-51 patterns now fully reachable)
- B: incomplete components 46 -> 30 (-16, -33%)
- HP1018 (Person unter absenkendem Maschinenteil eingeklemmt):
  weakly_reachable -> reachable

Why: methods A/B/C surfaced that the Kistenhubgeraet test project
generated 0 crush-under-load hazards despite OSHA 1910.212(a)(3) +
EN ISO 12100 6.3.5.5 explicitly requiring them. Three orthogonal
bugs (missing crush_point tag, wrong energy source mapping, missing
action verbs in dictionary) silently disabled the entire lift crush
pattern family.
2026-05-21 10:51:08 +02:00
Benjamin Admin 4946571863 feat(audit-pipeline): P72-v2 Heuristik nachgeschaerft + P80 Mini-Replay-Endpoint
CI / detect-changes (push) Successful in 9s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 13s
CI / loc-budget (push) Failing after 14s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 36s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
CI / nodejs-build (push) Has been skipped
P72-v2  MC-Scope-Classifier Heuristik v2 — v1 hatte 79% 'other'-Bucket
        (Patterns zu strict). v2 deckt deutlich breiter ab:
          - DSE: Art. 13/14 + Betroffenenrechte (Art. 15-22) + DSB +
            Aufsichtsbehoerde + Speicherdauer + besondere Kategorien
          - TOM: Art. 32 + Verschluesselung/Backup/Pseudonymisierung +
            Zugriffskontrolle + ISO 27001 + BSI-Grundschutz + Audit-Log
          - cookie_richtlinie: Tracking-Pixel + Webstorage + GA/Matomo/
            Hotjar/Pixel/GTM
          - process: VVT (Art. 30) + DSFA (Art. 35) + Datenpannen
            (Art. 33/34) + HinSchG + Schulungen + Loeschkonzept
        Script `backfill_mc_scope_v2.py` re-classifiziert NUR den
        'other'-Bucket (spezifische v1-Buckets bleiben unangetastet).

P80    Mini-Replay-Endpoint (v1):
          POST /compliance-check/snapshots/{id}/replay
          ?recipient=foo@bar.com & dry_run=false
        Laedt Snapshot, rendert Mail mit AKTUELLEM Render-Code (P63-P67,
        P59b/P61/P62). Sendet [REPLAY]-prefixed Mail oder gibt nur
        HTML-Stats zurueck (dry_run).
        Effekt: 7min Re-Scan -> 2-5sec fuer Mail-Layout-Iterationen.
        v2 (spaeter): MC-Scorecard mit aktuellem scope_doc_type-Filter
        ueber Snapshot — erfordert _run_compliance_check Refactoring.

Plus Bugfix: GET /snapshots/{id} raised jetzt HTTPException statt
Tuple-Return (FastAPI hat Tuple als JSON-Array zurueckgegeben).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 10:21:56 +02:00
Benjamin Admin cde670617e feat(audit-pipeline): P72 MC-Scope-Classifier + P80 Snapshot/Replay-Foundation [migration-approved]
CI / detect-changes (push) Successful in 11s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 14s
CI / loc-budget (push) Failing after 14s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 37s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
P72  MC-Scope-Classifier — pro MC den ECHTEN Doc-Adressaten festlegen
     (cookie_richtlinie/dse/banner_implementation/cmp_audit/tom/avv/jc/
      impressum/agb/widerruf/process/accounting/other).
     - Migration 145: scope_doc_type Spalte + Index auf canonical_controls
     - Backfill-Script mit Regex-Heuristik (12 Regeln, Prioritaet-sortiert)
     - Erste 11k-Sample-Distribution: 76% other (Heuristik v1 zu strict —
       v2 muss lockerere Patterns fuer DSE/TOM nachschaerfen)
     - Ziel: bevor MC-Scorecard filtert, weiss jeder MC welches Dokument
       er adressiert. Bisher landeten eHealth-/HGB-MCs im Cookie-Audit.

P80  Snapshot + Replay-Foundation — Roh-Daten persistieren damit
     Audit-Pipeline ohne erneuten Crawl rebuildbar ist.
     - Migration 146: compliance_check_snapshots Tabelle (JSONB pro
       doc_entries/banner_result/profile/cmp_vendors/scan_context)
     - services.check_snapshot.save_snapshot/load_snapshot/list
     - Endpoints GET /snapshots, GET /snapshots/{id}
     - Hook in _run_compliance_check: nach Mail-Send automatischer
       Snapshot-Save via separater SessionLocal (background-task safe)
     - Replay-Endpoint folgt im naechsten PR (braucht Refactoring
       von _run_compliance_check in crawl_phase + interpret_phase)
     - Effekt: Test-Cycle 7min -> 5sec bei reinen Logik-Aenderungen
       (P73/P79/P81+ profitieren direkt). Snapshots dienen auch als
       Regression-Test-Corpus (P81 Golden-Truth-Library).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 08:53:31 +02:00
Benjamin Admin 603381a67f feat(audit-mail): P58/P59c/P60b/P61/P62 — Mercedes-Cycle Phase 1 abgeschlossen
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 38s
CI / test-python-document-crawler (push) Has been skipped
CI / detect-changes (push) Successful in 12s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 14s
CI / loc-budget (push) Failing after 15s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
P58  Anti-Audit-Detection robuster (script-domain + settings-spezifisch —
     war bereits im Code, jetzt sauber als completed dokumentiert).

P59c DACH-Custom-Cookies in compliance.cookie_library: Borlabs,
     etracker, Matomo/Piwik, Userlike, Cookiebot/Cookieyes/Usercentrics,
     Akamai/Cloudflare/Datadome Bot-Manager + HubSpot. 21 neue Eintraege
     (3 von 24 schon via Open-Cookie-Database vorhanden).
     Script: backend-compliance/scripts/seed_dach_cookies.py.

P60b Vendor-Pattern-Dedupe mit Fuzzy-Match (Jaccard >= 0.7) statt exakter
     Tuple-Equality. Vendors mit teilweise befuellten Feldern (z.B.
     Sitzland eingetragen) fallen nicht mehr aus der globalen Notice —
     Bug: Amazon/Psyma/Qualtrics hatten zuvor wiederholte per-row Actions.

P61  "Untergeschobene Cookies"-Erkennung — wenn ein deklarierter Vendor
     (z.B. Google Tag Manager) automatisch weitere mitbringt (GA + GCL_AU
     + DoubleClick), werden diese als separater Mail-Block (gelb) mit
     COOKIE/VENDOR-Badges + Quellen-Doku ausgewiesen. Neuer Service:
     compliance.services.vendor_package_cookies (8 Primary-Vendors mit
     je 2-4 implicit Cookies/Vendors).

P62  Marketing-Manager-Disclaimer "Was wir sehen / nicht sehen" als
     blauer Box-Block direkt unter dem Critical-Findings-Block. Erklaert
     Grenzen unseres Audits (Server-Side-Tracking, Vendor-interne
     Datenweitergabe, Cross-Page-Banner) und Risiko des Falschvertrauens
     in einen 100%-Score. Neuer Renderer: compliance.api.scope_disclaimer.

Architektur: VVT-Tabellen-Renderer aus agent_doc_check_extras.py (552
LOC -> 242 LOC) in compliance.api.vvt_table_renderer ausgelagert, um den
500-LOC-Hardcap einzuhalten.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 08:01:27 +02:00
Benjamin Admin 57c0f940a2 feat(consent+report): P56-P67 Mercedes-Audit-Cycle (Anti-Audit, Phase G Vendors, Cookie-Behavior-Validator + 5 Mail-Polish-Items) [migration-approved]
CI / detect-changes (push) Successful in 11s
CI / branch-name (push) Has been skipped
CI / nodejs-build (push) Successful in 2m19s
CI / test-go (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 16s
CI / loc-budget (push) Failing after 15s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 37s
P56  Anti-Auditing-Detection als constructive Compliance-Finding (Audit-API-
     Empfehlung statt Anklage, weil Mercedes berechtigt Bots blockiert)
P57  Phase G vendor_details Union mit cmp_vendors -> 42 Anbieter sichtbar
P58  Anti-Audit-Detection robuster (Script-Domain-Check + Settings-spezifisch)
P59  Cookie-Behavior-Validator (4 Layer, 3-Tier-Severity: MEDIUM=Kategorie-
     Mismatch / HIGH=Zweck-Mismatch / CRITICAL=beide=Vorsatz-Indiz)
     + Open Cookie Database (CC0) als Library-Seed (2264 Cookies)
P59b Cookie-Behavior in Banner-Check verdrahtet + Mail-Block (BUGFIX:
     SessionLocal selbst oeffnen, db war im Background-Task nicht im Scope)

Mail-Polish nach Mercedes-Review:
P63  Banner-Footer-Links auch im wb7-link/role=link erkennen (Shadow-DOM-
     Walker label-based statt nur <a href>)
P64  Re-Access-Severity: MEDIUM statt HIGH, wenn Footer "Einstellungen" oder
     Mercedes-typisch existiert; OEM-Footer-Detection (wb7-footer)
P65  Text-Truncation: Word-Boundary statt Zeichen-Cut (kein "einfa"-Bruch
     mehr in Sofortmassnahmen)
P66  GF-Aktionen: Service-Zweck vs Cookie-Zweck explizit erklaert
     (haeufige Verwechslung Marketing/GF: "Akamai-Beschreibung" != Cookie-
     Zweck pro DSK-OH 2024)
P67  Stirring-Finding mit "Verlust-Framing"-Erklaerung + Alt-vs-Neutral-
     Beispiel, statt nur EDPB-Fachbegriff

Compliance-Advisor FAQ (admin agent-core/soul):
  + CNIL/EDPB Top-Bussgelder (Google 100M, Meta 60M, Amazon 35M)
  + Deutsche Praezedenz (LG Muenchen Google Fonts, EuGH Planet49, BGH I ZR 7/16)
  + 4 Risiko-Pfade (Bussgeld/Abmahnung/Sammelklage/NOYB) + Berechnungs-Methodik

Document-Generator Templates: AGB-DE (142), Impressum (140), Widerrufs-
formular-Anlage (143), DSR-Process-Dedup (139), Cookie-Library (144).

Architektur: doc_action_mappings.py + banner_dom_walkers.py +
cookie_behavior_validator.py + vendor_detail_extractor.py rausgezogen,
um die 500-LOC-Caps in agent_doc_check_report.py und
banner_text_checker.py einzuhalten.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 06:28:25 +02:00
Benjamin Admin badb356740 fix(founding-wizard): nested IF-Bloecke korrekt aufloesen (innermost-first)
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 42s
CI / detect-changes (push) Successful in 10s
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 13s
CI / loc-budget (push) Successful in 16s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
2026-05-20 19:21:08 +02:00
Benjamin Admin f08eb71480 fix(founding-wizard): default values fuer alle 8 Notar-Templates Platzhalter
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 15s
CI / nodejs-build (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
CI / loc-budget (push) Successful in 18s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go (push) Has been skipped
CI / detect-changes (push) Successful in 9s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 40s
CI / test-python-document-crawler (push) Has been skipped
2026-05-20 18:45:12 +02:00
Benjamin Admin 0477a2f2dc fix(founding-wizard): RESSORT_N_NAME/_GF/_AUFGABEN aus GF-Liste ableiten
CI / detect-changes (push) Successful in 9s
CI / branch-name (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 15s
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / loc-budget (push) Successful in 17s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 42s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
2026-05-20 18:42:36 +02:00
Benjamin Admin 93cedbecbd fix(founding-wizard): missing context vars (P_INFO etc) + italic regex no longer eats snake_case underscores
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 16s
CI / loc-budget (push) Successful in 19s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / detect-changes (push) Successful in 10s
CI / branch-name (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 41s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
2026-05-20 18:37:12 +02:00
Benjamin Admin 28f9e13c1f fix: remove jsonb_array_length from all 14 template migrations [migration-approved]
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 19s
CI / detect-changes (push) Successful in 9s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / loc-budget (push) Successful in 18s
CI / go-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 46s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
2026-05-20 17:49:05 +02:00
Benjamin Admin 35c1bbdaa5 fix: migration verification-SELECT (placeholders is TEXT not JSONB) [migration-approved]
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 17s
CI / detect-changes (push) Successful in 10s
CI / loc-budget (push) Successful in 20s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 47s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
2026-05-20 17:46:04 +02:00
Benjamin Admin b7df4709bc fix(founding-wizard): set license_id='mit' (NOT NULL constraint) [migration-approved]
CI / loc-budget (push) Successful in 17s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / detect-changes (push) Successful in 10s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 17s
CI / nodejs-build (push) Successful in 2m58s
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 43s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
2026-05-20 16:48:22 +02:00
Benjamin Admin 6f3301d246 fix(founding-wizard): add python-docx dep + Lifecycle filter UI
CI / detect-changes (push) Successful in 10s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 17s
CI / loc-budget (push) Successful in 18s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 2m53s
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 44s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
- requirements.txt: python-docx==1.2.0 (Container hatte das modul nicht)
- document-generator: Lifecycle-Filter (Pre-Founding/Founding/Startup/KMU/Konzern)
  zeigt nur relevante Templates fuer aktuelle Phase
2026-05-20 16:41:36 +02:00
Benjamin Admin 4478b7f479 fix(founding-wizard): mypy/ruff cleanup for CI
CI / detect-changes (push) Successful in 10s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 14s
CI / loc-budget (push) Successful in 17s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 42s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
- markdown_to_docx.py: type annotations + unused import
- founding_wizard_routes.py: drop unused get_db import
2026-05-20 09:58:38 +02:00
Benjamin Admin 39c39b1254 Merge feat/founding-wizard: Gründungs-Wizard + 14 Notar-Templates [migration-approved]
CI / detect-changes (push) Successful in 9s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 14s
CI / loc-budget (push) Successful in 17s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 2m57s
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 39s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
2026-05-20 09:32:24 +02:00
Benjamin Admin 7a5f1e48dd feat(founding-wizard): Gründungs-Wizard für 2-Mann GmbH + 14 Notar-Templates
[migration-approved]

Templates (Migrations 123-136):
- 123 GO-GF (Geschäftsordnung Geschäftsführung)
- 124 SHA (Shareholders' Agreement, 56 Platzhalter)
- 125 Satzung (Articles of Association mit UG-Variante)
- 126 GF-Dienstvertrag (Trennungsprinzip Organ/Anstellung)
- 127 Arbeitsvertrag (AGG-neutral, NachwG, eAU)
- 128 Gesellschafterliste (§ 40 GmbHG)
- 129 GF-Bestellungsbeschluss (mit § 6 Abs. 2 Versicherung)
- 130 HRB-Anmeldung (§§ 7, 8, 39 GmbHG, § 12 HGB)
- 131 IP-Assignment Agreement (Gründer→GmbH)
- 132 Term Sheet (Pre-Seed/Seed VC-Standard)
- 133 Wandeldarlehensvertrag (Convertible Loan)
- 134 Beteiligungsvertrag (Subscription Agreement)
- 135 ESOP/VSOP-Plan (3 Varianten)
- 136 Cap Table

Kategorisierung (Migrations 137-138):
- ALTER TABLE compliance_legal_templates ADD lifecycle_stage TEXT[],
  functional_category TEXT (mit CHECK Constraints + GIN-Index)
- Backfill aller 105 Templates: lifecycle_stage (pre_founding|founding|
  startup|kmu|konzern) + functional_category (founding_legal|employment|
  investor_funding|...)

Backend Founding-Wizard Service:
- template_renderer.py: Handlebars-light ({{VAR}}, {{#IF FLAG}}...{{/IF}})
- wizard_to_context.py: Mapping Wizard-State → SCREAMING_SNAKE_CASE Vars
- markdown_to_docx.py: Markdown → DOCX via python-docx
- founding_wizard_routes.py: POST /v1/founding-wizard/generate
  → liefert base64-DOCX-Files für ausgewählte Templates

Frontend Founding-Wizard (/sdk/founding-wizard):
- 8-Step Wizard (Basics, Gesellschafter, GF, Kapital, Notar, SHA, GF-Verträge, Generate)
- useFoundingWizardForm Hook mit localStorage-Persistenz
- TypeScript Code-Registry (template-categories.ts) als Backup zur DB
- Word-Download via data:URLs (base64)

Tests:
- 20 Unit-Tests grün (Renderer, Context-Mapping, DOCX-Conversion)
- Playwright E2E-Test mit 2-Mann GmbH (Benjamin + Sharang) Test-Daten
2026-05-20 09:30:51 +02:00
Benjamin Admin 98ec6d4284 fix(report): Anti-Pattern-Aufgabe — "muss entfernt werden" statt "ergaenzt werden"
CI / detect-changes (push) Successful in 9s
CI / secret-scan (push) Has been skipped
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 17s
CI / loc-budget (push) Successful in 17s
CI / go-lint (push) Has been skipped
CI / test-python-backend (push) Successful in 40s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
Bug: bei invertierten Checks (P9 #7 illegal_disclaimer) sagte die
GF-Aufgaben-Liste "muss ergaenzt werden" — semantisch falsch, weil der
Disclaimer ja schon da IST und entfernt werden soll.

Fix: _check_to_action() erkennt jetzt Anti-Pattern-Labels
(rechtswidrig/illegal/haftungsausschluss/disclaimer) und gibt
"muss entfernt werden (Anti-Pattern, rechtlich wirkungslos)" zurueck.

Smoke-Test BMW d2f7bcc0: vorher 'Rechtswidriger Haftungsausschluss
muss ergaenzt werden' -> jetzt 'muss entfernt werden'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:40:24 +02:00
Benjamin Admin 6f16507c5f feat(banner): P19 + P20 — Per-Category-Click-Test + Frontend-Drilldown
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 2m54s
CI / test-go (push) Has been skipped
CI / detect-changes (push) Successful in 10s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 17s
CI / loc-budget (push) Successful in 17s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 43s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
P19 (consent-tester):
- dp-cookieconsent (TYPO3, Safetykon-Pattern) als CMP-Profil hinzu —
  Selektoren #dp--cookie-statistics/marketing + a.cc-allow Save-Button
- Neues Signal provider_details_visible: nach Kategorie-Toggle prueft
  Playwright ob im Banner sichtbare Provider-/Cookie-Detail-Elemente
  erscheinen. Bei dp-cookieconsent (Banner ohne Listing) immer False
  -> HIGH-Violation "Kategorie zeigt keine Provider-/Cookie-Details —
  Nutzer kann nicht informiert einwilligen (Art. 7 Abs. 1 DSGVO)"
- main.py serialisiert provider_details_visible + cookies_set pro Kategorie

P20 (Frontend-Drilldown):
- Backend: check_payloads-Tabelle um Spalte 'banner' (JSON) — voller
  banner_result persistiert (vorher nur in-memory). ALTER TABLE
  Migration idempotent.
- Neuer Endpoint GET /api/compliance/agent/banner/<check_id> — liefert
  Quality-Score, Phases, Category-Tests, Banner-Checks, alle 46
  structured_checks.
- Frontend: BannerTab im /sdk/agent/audit/<id> mit Quality-Cards,
  3-Phasen-Cookie-Tabelle, Per-Category-Listing (mit P19-Signal
  rot/gruen), Banner-Verstoesse + Rechtsgrundlagen, 46-Check-Drilldown
  filterbar nach Severity.
- Tab-Switcher in page.tsx um "Cookie-Banner-Analyse" erweitert.
- Bonus: 2 alte route.ts auf Next.js 15 Promise-params umgestellt
  (Build-Fix).

Plus: Critical-Findings-Block nutzt provider_details_visible als
primaeres Signal statt nur tracking_services-Anzahl.

Smoke-Test Safetykon: 4 Critical Findings im Mail, banner-Endpoint
liefert 46 checks + 3 phases + 2 categories mit provider_details_visible=False.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:31:13 +02:00
Benjamin Admin d4d9b60007 feat(email): P18 — Critical-Findings-Box + Banner-Deep-Block
CI / branch-name (push) Has been skipped
CI / nodejs-build (push) Successful in 3m8s
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / detect-changes (push) Successful in 12s
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 15s
CI / loc-budget (push) Successful in 20s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-python-backend (push) Successful in 47s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
Backend wirft 90% der consent-tester-Daten weg — nur 4 Felder von einem
vollen Banner-Scan landeten im Email. Phases (before_consent / after_reject
/ after_accept), banner_checks.violations mit Rechtsgrundlagen,
category_tests, 46 structured_checks, completeness/correctness-Scores
waren alle nicht sichtbar.

Backend: agent_compliance_check_routes leitet jetzt das volle banner_result
durch (15 Felder statt 4).

Renderer (2 neue Module):
1) agent_doc_check_critical.build_critical_findings_html
   - ROTER Sofortmassnahmen-Block GANZ OBEN in der Email
   - Erkennt: banner-violations (HIGH/CRITICAL), leere Per-Category-Lists,
     DSE-Score <30%, fehlende Cookie-Richtlinie, US-Tracker ohne SCC/DPF
   - Pro Issue: konkrete Sofortmassnahme + Rechtsgrundlage + Bussgeld-
     Praezedenz (CNIL TikTok 5 Mio, LfDI BW 30k, EuGH Schrems II, ...)
   - Wird nur gerendert wenn echte Issues vorliegen

2) agent_doc_check_banner.build_banner_deep_html
   - Banner-Quality-Score-Cards (Vollstaendigkeit / Korrektheit / Verstoesse)
   - 3-Phasen-Cookie-Tabelle: vor Consent / nach Ablehnung / nach Annahme
     mit Cookie-Count, Tracker-Count, Auffaelligkeiten
   - Per-Category-Tracker-Listing (Statistik/Marketing) — zeigt explizit
     wenn eine Kategorie keine Provider listet (Safetykon-Pattern)
   - Violations-Liste mit Severity-Badge + Quellen-Hint (LG Rostock, EDPB)

Smoke-Test Safetykon: alle 6 neuen Blocks rendern, kein Regression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:34:17 +02:00
130 changed files with 20808 additions and 522 deletions
@@ -56,6 +56,44 @@ Bei ALLEN Fragen zu IFRS/IAS-Standards MUSST du folgende Punkte beachten:
4. Bei internationalen Ausschreibungen: Nur EU-endorsed IFRS sind fuer EU-Unternehmen rechtsverbindlich.
5. Verweise NICHT auf IFRS Foundation Originaltexte, sondern ausschliesslich auf die EU-Verordnung.
## FAQ — Cookie-Banner-Bussgelder + Risiken (haeufige Mandantenfragen)
Bei Fragen nach Bussgeldern, Risiko-Hoehe oder konkreten Faellen gib **konkrete Praezedenzen** an:
### Top-Bussgelder (CNIL Frankreich — strengste EU-Aufsicht):
- **Google France 2020 (CNIL)** — 100 Mio EUR — Cookies ohne Einwilligung (CNIL Beschluss vom 07.12.2020)
- **Meta/Facebook France 2022 (CNIL)** — 60 Mio EUR — Cookies ohne Einwilligung
- **Amazon France 2020 (CNIL)** — 35 Mio EUR — Cookies ohne Einwilligung
- **Carrefour France 2020 (CNIL)** — 2,25 Mio EUR — Cookies + sonstige Verstoesse
### Deutsche Praezedenzen + Sammelklagen-Risiken:
- **LG Muenchen I 2022** — 100 EUR pro Besucher Schadensersatz fuer Google Fonts ohne Consent (Az. 3 O 17493/20). Spaeter durch BGH "Rechtsmissbrauchs"-Argument bei Massenabmahnungen eingeschraenkt.
- **EuGH Planet49 (C-673/17)** — vorausgewaehlte Cookie-Checkboxen sind unwirksame Einwilligung (praejudiziell fuer alle EU-Sites)
- **BGH Cookie-Einwilligung II (I ZR 7/16)** — bestaetigt Planet49 fuer Deutschland
- **DSK Beschluss 2023** — Cookie-Banner mit "Akzeptieren" deutlich prominenter als "Ablehnen" = Dark Pattern = unwirksame Einwilligung
### Deutscher Aufsichtsmarkt:
Deutsche Aufsicht (BfDI + 16 Landes-DSB) ist moderater als CNIL — bislang keine 100 Mio-EUR-Bussgelder. ABER: DSK-Beschluesse + LfDI-Verfahren haeufen sich. Federfuehrung bei Konzernen via "One-Stop-Shop" nach Hauptsitz.
### Vier Risiko-Pfade fuer Mandanten:
1. **Art. 83 DSGVO Bussgeld** — bis 4% des weltweiten Konzernumsatzes. Realistisch 0,1-1% bei Erstverstoss.
2. **Verbraucherschutz-Abmahnung** (vzbv, Wettbewerbszentrale, Verbraucherverbaende) — 50-500k EUR Streitwert + Unterlassung.
3. **Sammelklage Art. 82 DSGVO** — Schadensersatz pro Person, BGH 50-100 EUR pro Fall. Sammelklage-Trusts: myRight, RightNow, helpcheck.de.
4. **NOYB-Beschwerde** (Max Schrems) — oeffentliches Aufsichtsverfahren, Reputationsschaden + Bussgeld.
### Geschaeftsfuehrer-Haftung (haeufig unterschaetzt):
GF haftet **persoenlich** nach §43 GmbHG bzw. §93 AktG wenn Compliance-Pflichten verletzt wurden. Das ist der eigentliche Druckpunkt — nicht die Firma, sondern der GF persoenlich. Bei Mandantengespraechen mit GF-Beteiligung: dieser Punkt zuerst ansprechen.
### Wie berechne ich das konkrete Risiko fuer einen Mandanten:
Frage den Mandanten nach: (a) Jahresumsatz, (b) ungefaehre Besucherzahl pro Jahr, (c) Anzahl Trackingtools im Banner. Dann:
- Max-Bussgeld = 4% × Jahresumsatz (Obergrenze, nicht realistisch)
- Realistisch-Bussgeld = 0,1-1% × Jahresumsatz (CNIL/LfDI-Maßstab)
- Sammelklage-Theorie = Besucherzahl × 50 EUR (BGH-Untergrenze) — meist nicht durchsetzbar, aber Drohpotential
- NICHT konkrete Zahlen einer fremden Firma zitieren ("BMW haette X EUR" etc.) — Mandant koennte das falsch weitergeben
### Marktwissen (intern, nicht 1:1 zitieren):
Externe DSB-Stundensaetze: 350-450 EUR/h (NOERR, GSK, vergleichbare Kanzleien). Mittelstands-DSB-Mandate: 5-15k EUR/Jahr. Cookie-Audit manuell: typisch 10 Std = 4-5k EUR Kosten. BreakPilot reduziert das auf 30 Min.
## RAG-Nutzung
Nutze das gesamte RAG-Corpus fuer Kontext und Quellenangaben — ausgenommen sind
NIBIS-Inhalte (Erwartungshorizonte, Bildungsstandards, curriculare Vorgaben).
@@ -10,9 +10,9 @@ const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:80
export async function GET(
request: NextRequest,
{ params }: { params: { checkId: string } },
{ params }: { params: Promise<{ checkId: string }> },
) {
const checkId = params.checkId
const { checkId } = await params
const qs = request.nextUrl.searchParams.toString()
const url = `${BACKEND_URL}/api/compliance/agent/audit/${checkId}${qs ? `?${qs}` : ''}`
try {
@@ -0,0 +1,28 @@
/**
* Proxy: GET /api/sdk/v1/agent/banner/<checkId>
* -> backend GET /api/compliance/agent/banner/<checkId>
*
* Liefert das volle banner_result (phases, structured_checks, category_tests).
*/
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ checkId: string }> },
) {
const { checkId } = await params
try {
const resp = await fetch(
`${BACKEND_URL}/api/compliance/agent/banner/${checkId}`,
{ signal: AbortSignal.timeout(15000) },
)
const data = await resp.json().catch(() => ({}))
return NextResponse.json(data, { status: resp.status })
} catch {
return NextResponse.json(
{ error: 'Banner-Abfrage fehlgeschlagen' }, { status: 503 },
)
}
}
@@ -10,9 +10,9 @@ const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:80
export async function GET(
request: NextRequest,
{ params }: { params: { checkId: string } },
{ params }: { params: Promise<{ checkId: string }> },
) {
const checkId = params.checkId
const { checkId } = await params
const qs = request.nextUrl.searchParams.toString()
const url = `${BACKEND_URL}/api/compliance/agent/findings/${checkId}${qs ? `?${qs}` : ''}`
try {
@@ -0,0 +1,58 @@
/**
* Next.js Proxy: leitet POST /api/v1/founding-wizard/generate an Backend.
*
* Konvertiert das Backend-Response (base64 DOCX) in data: URLs,
* die das Frontend direkt als Download anbieten kann.
*/
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_COMPLIANCE_URL || 'http://bp-compliance-backend:8002'
export async function POST(req: NextRequest) {
try {
const body = await req.json()
const backendRes = await fetch(`${BACKEND_URL}/v1/founding-wizard/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!backendRes.ok) {
const errorText = await backendRes.text()
return NextResponse.json(
{ error: 'Backend-Generierung fehlgeschlagen', detail: errorText },
{ status: backendRes.status }
)
}
const data = await backendRes.json()
const documents = (data.documents || []).map((doc: {
document_type: string
title: string
filename: string
content_base64: string
size_bytes: number
generated_at: string
}) => ({
document_type: doc.document_type,
title: doc.title,
filename: doc.filename,
download_url: `data:application/vnd.openxmlformats-officedocument.wordprocessingml.document;base64,${doc.content_base64}`,
size_bytes: doc.size_bytes,
generated_at: doc.generated_at,
}))
return NextResponse.json({
documents,
warnings: data.warnings || [],
})
} catch (e: unknown) {
const message = e instanceof Error ? e.message : 'Unbekannter Fehler'
return NextResponse.json(
{ error: 'Proxy-Fehler', detail: message },
{ status: 500 }
)
}
}
@@ -2,30 +2,40 @@
import React, { useState } from 'react'
import { ChecklistView } from './ChecklistView'
import { PreScanWizard, useScanContext, isContextComplete } from './PreScanWizard'
import { safeSetItem } from './storageHelpers'
interface DocEntry {
id: string
type: string
label: string
url: string
text: string // P-Paste: User kopiert Doc-Text direkt rein
mode: 'url' | 'text' // welcher Input wird aktiv genutzt
}
const DOC_TYPES = [
{ id: 'dse', label: 'DSI (Datenschutzinformation)' },
{ id: 'dse', label: 'Datenschutzerklärung / DSI' },
{ id: 'cookie', label: 'Cookie-Richtlinie' },
{ id: 'impressum', label: 'Impressum' },
{ id: 'agb', label: 'AGB' },
{ id: 'nutzungsbedingungen', label: 'Nutzungsbedingungen' },
{ id: 'widerruf', label: 'Widerrufsbelehrung' },
{ id: 'social_media', label: 'DSE Social Media (Art. 26)' },
{ id: 'dsfa', label: 'DSFA (Art. 35)' },
{ id: 'agb', label: 'AGB / Nutzungsbedingungen' },
{ id: 'impressum', label: 'Impressum' },
{ id: 'cookie', label: 'Cookie-Richtlinie' },
{ id: 'widerruf', label: 'Widerrufsbelehrung' },
{ id: 'dsa', label: 'DSA / Digital Services Act' },
{ id: 'legal_notice', label: 'Rechtliche Hinweise (IP, Forward-Looking)' },
{ id: 'lizenzhinweise', label: 'Lizenzhinweise Dritter (OSS)' },
{ id: 'other', label: 'Sonstiges' },
]
function newEntry(): DocEntry {
return { id: crypto.randomUUID().slice(0, 8), type: 'dse', label: '', url: '' }
return { id: crypto.randomUUID().slice(0, 8), type: 'dse', label: '',
url: '', text: '', mode: 'url' }
}
export function DocCheckTab() {
const [scanContext, setScanContext] = useScanContext()
const [entries, setEntries] = useState<DocEntry[]>(() => {
if (typeof window === 'undefined') return [newEntry()]
try { const s = localStorage.getItem('doc-check-entries'); return s ? JSON.parse(s) : [newEntry()] } catch { return [newEntry()] }
@@ -74,7 +84,7 @@ export function DocCheckTab() {
}
const handleSubmit = async () => {
const validEntries = entries.filter(e => e.url.trim())
const validEntries = entries.filter(e => e.url.trim() || e.text.trim())
if (validEntries.length === 0) return
setLoading(true)
@@ -89,11 +99,17 @@ export function DocCheckTab() {
body: JSON.stringify({
entries: validEntries.map(e => ({
doc_type: e.type,
label: e.label || e.url.split('/').pop() || 'Dokument',
url: e.url.trim(),
label: e.label
|| (e.url ? e.url.split('/').pop() : '')
|| `${e.type}-paste`,
url: e.mode === 'text' ? '' : e.url.trim(),
// Backend nimmt text > url. Wenn beide gefuellt sind und
// mode='url', schicken wir den text NICHT mit.
text: e.mode === 'text' ? e.text.trim() : '',
})),
check_cookie_banner: checkCookieBanner,
use_agent: useAgent,
scan_context: scanContext,
}),
})
if (!startRes.ok) throw new Error(`Pruefung konnte nicht gestartet werden: ${startRes.status}`)
@@ -111,13 +127,13 @@ export function DocCheckTab() {
if (pollData.status === 'completed' && pollData.result) {
setResults(pollData.result)
setProgress('')
localStorage.setItem('doc-check-results', JSON.stringify(pollData.result))
safeSetItem('doc-check-results', JSON.stringify(pollData.result))
const resultKey = `doc-check-result-${Date.now()}`
try { localStorage.setItem(resultKey, JSON.stringify(pollData.result)) } catch { /* quota */ }
safeSetItem(resultKey, JSON.stringify(pollData.result))
const entry = { date: new Date().toISOString(), urls: validEntries.length, findings: pollData.result.total_findings || 0, resultKey }
const updated = [entry, ...history].slice(0, 30)
setHistory(updated)
localStorage.setItem('doc-check-history', JSON.stringify(updated))
safeSetItem('doc-check-history', JSON.stringify(updated))
break
}
if (pollData.status === 'failed') {
@@ -133,12 +149,18 @@ export function DocCheckTab() {
}
}
const contextReady = isContextComplete(scanContext)
return (
<div className="space-y-4">
{/* URL Entries */}
<div className="space-y-2">
{/* P79 Pre-Scan-Wizard — 8 Pflichtfelder */}
<PreScanWizard value={scanContext} onChange={setScanContext} />
{/* URL / Text Entries */}
<div className="space-y-3">
{entries.map((entry, i) => (
<div key={entry.id} className="flex items-center gap-2">
<div key={entry.id} className="space-y-1.5">
<div className="flex items-center gap-2">
<select
value={entry.type}
onChange={e => updateEntry(entry.id, 'type', e.target.value)}
@@ -155,6 +177,24 @@ export function DocCheckTab() {
placeholder={entry.type === 'other' ? 'Dokumentname' : 'Version / Stand (optional)'}
className="w-40 px-3 py-2.5 border border-gray-300 rounded-lg text-sm shrink-0"
/>
{/* Mode-Toggle URL / Text */}
<div className="inline-flex border border-gray-300 rounded-lg overflow-hidden text-xs shrink-0">
<button type="button"
onClick={() => updateEntry(entry.id, 'mode', 'url')}
className={`px-3 py-2 ${entry.mode === 'url'
? 'bg-purple-600 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'}`}>
URL
</button>
<button type="button"
onClick={() => updateEntry(entry.id, 'mode', 'text')}
className={`px-3 py-2 ${entry.mode === 'text'
? 'bg-purple-600 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'}`}>
Text einfügen
</button>
</div>
{entry.mode === 'url' && (
<input
type="url"
value={entry.url}
@@ -163,6 +203,8 @@ export function DocCheckTab() {
placeholder="https://example.com/datenschutz"
className="flex-1 px-3 py-2.5 border border-gray-300 rounded-lg text-sm"
/>
)}
{entries.length > 1 && (
<button onClick={() => removeEntry(entry.id)}
className="p-2 text-gray-400 hover:text-red-500 shrink-0">
@@ -172,6 +214,27 @@ export function DocCheckTab() {
</button>
)}
</div>
{entry.mode === 'text' && (
<div className="ml-[400px]">
<textarea
value={entry.text}
onChange={e => updateEntry(entry.id, 'text', e.target.value)}
placeholder={
entry.type === 'cookie'
? 'Kopiere hier die komplette Cookie-Tabelle rein (Tab-getrennt oder mit | als Trenner — wir parsen alle Spalten deterministisch)…'
: 'Kopiere hier den vollständigen Doc-Text rein. Wir erkennen automatisch ob es zu „' + (DOC_TYPES.find(t => t.id === entry.type)?.label ?? entry.type) + '" passt.'
}
className="w-full h-32 px-3 py-2 border border-gray-300 rounded-lg text-xs font-mono resize-y"
/>
<div className="text-[10px] text-gray-500 mt-1">
{entry.text.trim().length > 0
? `${entry.text.trim().length.toLocaleString('de-DE')} Zeichen · ${entry.text.trim().split(/\s+/).length.toLocaleString('de-DE')} Wörter`
: 'Der Crawler wird übersprungen — die Analyse läuft direkt auf dem eingefügten Text.'}
</div>
</div>
)}
</div>
))}
</div>
@@ -212,8 +275,11 @@ export function DocCheckTab() {
{/* Submit */}
<button
onClick={handleSubmit}
disabled={loading || entries.every(e => !e.url.trim())}
disabled={loading
|| entries.every(e => !e.url.trim() && !e.text.trim())
|| !contextReady}
className="w-full px-4 py-3 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 disabled:opacity-50 transition-colors text-sm flex items-center justify-center gap-2"
title={!contextReady ? 'Bitte zuerst die 8 Pflichtfelder ausfüllen' : undefined}
>
{loading ? (
<>
@@ -223,6 +289,8 @@ export function DocCheckTab() {
</svg>
Pruefe...
</>
) : !contextReady ? (
`Klassifizierung unvollständig (8 Pflichtfelder)`
) : (
`${entries.filter(e => e.url.trim()).length} Dokument${entries.filter(e => e.url.trim()).length !== 1 ? 'e' : ''} pruefen`
)}
@@ -0,0 +1,269 @@
'use client'
/**
* P79 — Pre-Scan-Wizard (8 Pflichtfelder).
*
* 8 Pflichtfelder die vor dem Lauf abgefragt werden. Werte landen im
* scan_context und filtern später die MC-Auswertung (zusammen mit P72
* scope_doc_type + applicable_industries). Erwartete Noise-Reduktion:
* 70-80% bei falsch zugeordneten HIGH-MCs.
*/
import React, { useState, useEffect } from 'react'
export interface ScanContext {
industry: string
business_model: string
direct_sales: string
legal_form: string
group_structure: string
employee_count: string
special_data: string[]
third_country_transfer: string
}
const INDUSTRIES = [
{ id: '', label: '— bitte wählen —' },
{ id: 'automotive', label: 'Automotive / OEM' },
{ id: 'ecommerce', label: 'E-Commerce / Online-Handel' },
{ id: 'saas', label: 'SaaS / Software' },
{ id: 'banking', label: 'Banking / Finance' },
{ id: 'insurance', label: 'Insurance / Versicherung' },
{ id: 'healthcare', label: 'Healthcare / Gesundheit' },
{ id: 'education', label: 'Bildung / Schule' },
{ id: 'public', label: 'Öffentliche Verwaltung' },
{ id: 'manufacturing', label: 'Industrie / Manufacturing' },
{ id: 'media', label: 'Medien / Verlag' },
{ id: 'other', label: 'Sonstige' },
]
const LEGAL_FORMS = [
{ id: '', label: '— bitte wählen —' },
{ id: 'ag', label: 'AG (Aktiengesellschaft)' },
{ id: 'gmbh', label: 'GmbH' },
{ id: 'gmbh_co_kg', label: 'GmbH & Co. KG' },
{ id: 'kg', label: 'KG' },
{ id: 'ohg', label: 'OHG' },
{ id: 'ug', label: 'UG (haftungsbeschränkt)' },
{ id: 'ek', label: 'e.K. / Einzelunternehmen' },
{ id: 'verein', label: 'Verein' },
{ id: 'stiftung', label: 'Stiftung' },
{ id: 'behoerde', label: 'Behörde / Körperschaft öff. Rechts' },
{ id: 'other', label: 'Sonstige' },
]
const GROUP_STRUCTURES = [
{ id: '', label: '— bitte wählen —' },
{ id: 'standalone', label: 'Eigenständig' },
{ id: 'parent', label: 'Konzern-Mutter' },
{ id: 'subsidiary', label: 'Konzern-Tochter' },
{ id: 'joint_venture', label: 'Joint Venture' },
{ id: 'processor', label: 'Reiner Auftragsverarbeiter' },
]
const EMPLOYEE_COUNTS = [
{ id: '', label: '— bitte wählen —' },
{ id: 'lt10', label: 'unter 10' },
{ id: '10_19', label: '10-19' },
{ id: '20_49', label: '20-49 (DSB ab 20 Pflicht)' },
{ id: '50_249', label: '50-249 (Whistleblower-Pflicht)' },
{ id: '250_499', label: '250-499' },
{ id: '500_999', label: '500-999' },
{ id: '1000_plus', label: '1.000+ (Konzern)' },
]
const SPECIAL_DATA_OPTIONS = [
{ id: 'health', label: 'Gesundheitsdaten' },
{ id: 'biometric', label: 'Biometrische Daten' },
{ id: 'ethnicity', label: 'Religiöse / ethnische Herkunft' },
{ id: 'sexual', label: 'Sexuelle Orientierung' },
{ id: 'criminal', label: 'Strafrechtliche Daten' },
{ id: 'minors', label: 'Minderjährige (<16)' },
{ id: 'none', label: 'Keine besonderen Daten' },
]
const STORAGE_KEY = 'compliance-scan-context'
function emptyContext(): ScanContext {
return {
industry: '',
business_model: '',
direct_sales: '',
legal_form: '',
group_structure: '',
employee_count: '',
special_data: [],
third_country_transfer: '',
}
}
export function isContextComplete(ctx: ScanContext): boolean {
return Boolean(
ctx.industry &&
ctx.business_model &&
ctx.direct_sales &&
ctx.legal_form &&
ctx.group_structure &&
ctx.employee_count &&
ctx.special_data.length > 0 &&
ctx.third_country_transfer
)
}
export function PreScanWizard({
value,
onChange,
}: {
value: ScanContext
onChange: (ctx: ScanContext) => void
}) {
const update = <K extends keyof ScanContext>(key: K, val: ScanContext[K]) => {
onChange({ ...value, [key]: val })
}
const toggleSpecialData = (id: string) => {
const next = value.special_data.includes(id)
? value.special_data.filter(x => x !== id)
: [...value.special_data.filter(x => x !== 'none' || id === 'none'), id]
onChange({ ...value, special_data: id === 'none' ? ['none'] : next.filter(x => x !== 'none') })
}
return (
<div style={{
background: '#f0f9ff',
border: '1px solid #bfdbfe',
borderRadius: 8,
padding: '14px 16px',
marginBottom: 14,
}}>
<div style={{ fontSize: 11, color: '#1e40af', textTransform: 'uppercase',
letterSpacing: 1.2, marginBottom: 4, fontWeight: 600 }}>
Pflichtangaben zur Klassifizierung des Audits
</div>
<h3 style={{ margin: '0 0 6px', fontSize: 14, color: '#1e293b' }}>
Vor dem Scan: 8 Angaben zum Unternehmen
</h3>
<p style={{ margin: '0 0 12px', fontSize: 11, color: '#475569', lineHeight: 1.5 }}>
Diese Angaben filtern irrelevante Compliance-Themen heraus (z.B. eHealth-
Vorschriften bei einem Autobauer) und liefern eine realistische
Einschätzung statt pauschaler Verstoss-Listen.
</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 10 }}>
<Field label="1. Branche*">
<select value={value.industry} onChange={e => update('industry', e.target.value)} style={inputStyle}>
{INDUSTRIES.map(o => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
</Field>
<Field label="2. Geschäftsmodell*">
<select value={value.business_model} onChange={e => update('business_model', e.target.value)} style={inputStyle}>
<option value=""> bitte wählen </option>
<option value="b2b">B2B</option>
<option value="b2c">B2C</option>
<option value="both">Beides (B2B + B2C)</option>
</select>
</Field>
<Field label="3. Direkt-Vertrieb (Webshop/Buchung)*">
<select value={value.direct_sales} onChange={e => update('direct_sales', e.target.value)} style={inputStyle}>
<option value=""> bitte wählen </option>
<option value="yes">Ja</option>
<option value="no">Nein</option>
<option value="lead_funnel">Nur Lead-Funnel (Probefahrten, Anfragen)</option>
</select>
</Field>
<Field label="4. Rechtsform*">
<select value={value.legal_form} onChange={e => update('legal_form', e.target.value)} style={inputStyle}>
{LEGAL_FORMS.map(o => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
</Field>
<Field label="5. Konzern-Struktur*">
<select value={value.group_structure} onChange={e => update('group_structure', e.target.value)} style={inputStyle}>
{GROUP_STRUCTURES.map(o => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
</Field>
<Field label="6. Mitarbeiterzahl*">
<select value={value.employee_count} onChange={e => update('employee_count', e.target.value)} style={inputStyle}>
{EMPLOYEE_COUNTS.map(o => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
</Field>
<Field label="7. Besondere Datenkategorien*" colSpan={2}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{SPECIAL_DATA_OPTIONS.map(o => (
<label key={o.id} style={{ fontSize: 12, display: 'inline-flex',
alignItems: 'center', gap: 4,
padding: '4px 8px', background: '#fff',
border: '1px solid #cbd5e1',
borderRadius: 4 }}>
<input type="checkbox"
checked={value.special_data.includes(o.id)}
onChange={() => toggleSpecialData(o.id)} />
{o.label}
</label>
))}
</div>
</Field>
<Field label="8. Bekannter Drittland-Transfer*" colSpan={2}>
<select value={value.third_country_transfer} onChange={e => update('third_country_transfer', e.target.value)} style={inputStyle}>
<option value=""> bitte wählen </option>
<option value="yes">Ja (USA, CN, IN, UK, ...)</option>
<option value="no">Nein (nur EU/EWR)</option>
<option value="unknown">Weiß nicht (bitte automatisch prüfen)</option>
</select>
</Field>
</div>
{!isContextComplete(value) && (
<div style={{ marginTop: 10, fontSize: 11, color: '#92400e',
background: '#fef3c7', padding: '6px 10px',
borderRadius: 4, border: '1px solid #fde68a' }}>
Bitte alle 8 Pflichtfelder ausfüllen der Scan-Button wird erst aktiv,
wenn die Klassifizierung komplett ist.
</div>
)}
</div>
)
}
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '6px 8px',
fontSize: 12,
border: '1px solid #cbd5e1',
borderRadius: 4,
background: '#fff',
}
function Field({ label, children, colSpan }: { label: string; children: React.ReactNode; colSpan?: number }) {
return (
<div style={{ gridColumn: colSpan ? `span ${colSpan}` : undefined }}>
<label style={{ display: 'block', fontSize: 11, color: '#475569',
marginBottom: 4, fontWeight: 600 }}>
{label}
</label>
{children}
</div>
)
}
export function useScanContext(): [ScanContext, (ctx: ScanContext) => void] {
const [ctx, setCtx] = useState<ScanContext>(() => {
if (typeof window === 'undefined') return emptyContext()
try {
const s = localStorage.getItem(STORAGE_KEY)
return s ? { ...emptyContext(), ...JSON.parse(s) } : emptyContext()
} catch {
return emptyContext()
}
})
useEffect(() => {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(ctx)) } catch {}
}, [ctx])
return [ctx, setCtx]
}
@@ -0,0 +1,71 @@
/**
* P47 — localStorage-Quota-Management.
*
* Wenn alte Compliance-Check-Ergebnisse den Browser-Storage fuellen,
* versucht das setItem mit QuotaExceededError zu fangen, prunet
* alte doc-check-result-*-Eintraege (oldest first) und retried.
*
* Wird von DocCheckTab/BannerCheckTab/etc beim Persistieren der
* Result-Bloebs benutzt.
*/
const RESULT_KEY_PREFIX = 'doc-check-result-'
const MAX_KEEP = 10 // Maximal 10 alte Result-Bloebs behalten.
export function safeSetItem(key: string, value: string): boolean {
try {
localStorage.setItem(key, value)
return true
} catch (err: any) {
if (err?.name !== 'QuotaExceededError'
&& err?.code !== 22 && err?.code !== 1014) {
console.warn('localStorage setItem failed:', err)
return false
}
pruneOldResults()
try {
localStorage.setItem(key, value)
return true
} catch {
// Pruning hat nicht gereicht — aggressiver pruefen
pruneOldResults(0)
try {
localStorage.setItem(key, value)
return true
} catch {
console.warn('localStorage immer noch voll, wert wird verworfen')
return false
}
}
}
}
function pruneOldResults(keep: number = MAX_KEEP): void {
try {
const keys: { key: string; ts: number }[] = []
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i)
if (!k || !k.startsWith(RESULT_KEY_PREFIX)) continue
const ts = Number(k.slice(RESULT_KEY_PREFIX.length)) || 0
keys.push({ key: k, ts })
}
keys.sort((a, b) => a.ts - b.ts) // oldest first
const toRemove = keys.slice(0, Math.max(0, keys.length - keep))
for (const k of toRemove) {
try { localStorage.removeItem(k.key) } catch {}
}
} catch {}
}
export function getStorageUsageMB(): number {
let bytes = 0
try {
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i)
if (!k) continue
const v = localStorage.getItem(k) || ''
bytes += k.length + v.length
}
} catch {}
return bytes / (1024 * 1024)
}
@@ -0,0 +1,302 @@
'use client'
import React, { useEffect, useState } from 'react'
type Phase = {
cookies?: string[]
scripts?: string[]
tracking_services?: (string | { name?: string })[]
new_tracking?: unknown[]
violations?: Array<{ severity?: string; text?: string }>
undocumented?: unknown[]
}
type CategoryTest = {
category: string
category_label: string
tracking_services?: (string | { name?: string })[]
cookies_set?: string[]
provider_details_visible?: boolean
violations?: Array<{ severity?: string; text?: string; legal_ref?: string }>
}
type BannerViolation = {
severity?: string
text?: string
legal_ref?: string
}
type StructuredCheck = {
id: string
label: string
passed: boolean
skipped?: boolean
severity: string
level?: number
hint?: string
}
type BannerResp = {
found: boolean
check_id: string
banner?: {
banner_provider?: string
banner_detected?: boolean
completeness_pct?: number
correctness_pct?: number
phases?: Record<string, Phase>
banner_checks?: { violations?: BannerViolation[] }
category_tests?: CategoryTest[]
structured_checks?: StructuredCheck[]
summary?: Record<string, number>
}
}
const PHASE_LABEL: Record<string, string> = {
before_consent: 'Vor Consent',
after_reject: 'Nach Ablehnung',
after_accept: 'Nach Annahme',
}
const SEV_BADGE: Record<string, string> = {
CRITICAL: 'bg-red-600 text-white',
HIGH: 'bg-red-100 text-red-800',
MEDIUM: 'bg-amber-100 text-amber-800',
LOW: 'bg-blue-100 text-blue-800',
INFO: 'bg-gray-100 text-gray-600',
}
function pctColor(pct?: number): string {
if (pct === undefined || pct === null) return 'text-gray-400'
return pct >= 80 ? 'text-green-700' : pct >= 50 ? 'text-amber-700' : 'text-red-700'
}
export default function BannerTab({ checkId }: { checkId: string }) {
const [data, setData] = useState<BannerResp | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [checkFilter, setCheckFilter] = useState<'all' | 'fail' | 'critical'>('fail')
useEffect(() => {
let cancelled = false
setLoading(true)
fetch(`/api/sdk/v1/agent/banner/${checkId}`)
.then(r => r.json())
.then(d => { if (!cancelled) setData(d) })
.catch(e => { if (!cancelled) setError(String(e)) })
.finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [checkId])
if (loading) return <div className="p-6 text-sm text-gray-500">Lade Banner-Daten</div>
if (error) return <div className="p-6 text-sm text-red-600">Fehler: {error}</div>
if (!data?.found || !data.banner) {
return <div className="p-6 text-sm text-gray-500">Keine Banner-Daten zu diesem Check.</div>
}
const b = data.banner
const phases = b.phases || {}
const cats = b.category_tests || []
const violations = b.banner_checks?.violations || []
const checks = b.structured_checks || []
const summary = b.summary || {}
const filteredChecks = checks.filter(c => {
if (checkFilter === 'all') return true
if (checkFilter === 'fail') return !c.passed && !c.skipped
return !c.passed && !c.skipped && ['CRITICAL', 'HIGH'].includes(c.severity)
})
return (
<div className="space-y-6">
{/* Quality Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
<div className="border rounded p-3">
<div className="text-[10px] uppercase text-gray-500">Vollstaendigkeit</div>
<div className={`text-2xl font-semibold ${pctColor(b.completeness_pct)}`}>
{b.completeness_pct ?? ''}{b.completeness_pct !== undefined && '%'}
</div>
</div>
<div className="border rounded p-3">
<div className="text-[10px] uppercase text-gray-500">Korrektheit</div>
<div className={`text-2xl font-semibold ${pctColor(b.correctness_pct)}`}>
{b.correctness_pct ?? ''}{b.correctness_pct !== undefined && '%'}
</div>
</div>
<div className="border rounded p-3">
<div className="text-[10px] uppercase text-gray-500">Verstoesse</div>
<div className="text-2xl font-semibold text-red-700">
{summary.total_violations ?? violations.length}
</div>
<div className="text-[10px] text-gray-500 mt-1">
crit:{summary.critical ?? 0} · high:{summary.high ?? 0}
</div>
</div>
<div className="border rounded p-3">
<div className="text-[10px] uppercase text-gray-500">CMP</div>
<div className="text-sm font-medium text-gray-800 truncate">
{b.banner_provider || 'unbekannt'}
</div>
<div className="text-[10px] text-gray-500 mt-1">
{b.banner_detected ? 'Banner erkannt' : 'kein Banner'}
</div>
</div>
</div>
{/* Phases */}
<div className="border rounded-lg overflow-hidden">
<div className="px-4 py-2 bg-gray-50 border-b text-sm font-medium text-gray-700">
Cookie-Setzungen pro Phase (echter Browser-Test)
</div>
<table className="w-full text-xs">
<thead className="bg-gray-50 text-gray-600">
<tr>
<th className="px-3 py-2 text-left">Phase</th>
<th className="px-3 py-2 text-center">Cookies</th>
<th className="px-3 py-2 text-center">Tracker</th>
<th className="px-3 py-2 text-left">Auffaelligkeiten</th>
</tr>
</thead>
<tbody>
{(['before_consent', 'after_reject', 'after_accept'] as const).map(key => {
const p = phases[key] || {}
const nc = (p.cookies || []).length
const nt = (p.tracking_services || []).length
const issues: string[] = []
if (p.violations?.length) issues.push(`${p.violations.length} Verstoss`)
if (p.new_tracking?.length) issues.push(`${p.new_tracking.length} neue Tracker`)
if (p.undocumented?.length) issues.push(`${p.undocumented.length} undokumentiert`)
const color = key === 'before_consent'
? (nc === 0 ? 'text-green-600' : 'text-red-600')
: key === 'after_reject'
? (nc <= 1 ? 'text-green-600' : 'text-amber-600')
: 'text-gray-700'
return (
<tr key={key} className="border-t">
<td className="px-3 py-2 font-medium">{PHASE_LABEL[key]}</td>
<td className={`px-3 py-2 text-center font-semibold ${color}`}>{nc}</td>
<td className="px-3 py-2 text-center">{nt}</td>
<td className="px-3 py-2 text-gray-500">{issues.join(', ') || '—'}</td>
</tr>
)
})}
</tbody>
</table>
</div>
{/* Per-Category */}
{cats.length > 0 && (
<div className="border rounded-lg overflow-hidden">
<div className="px-4 py-2 bg-gray-50 border-b text-sm font-medium text-gray-700">
Provider-Listing pro Kategorie (P19 Click-Through-Test)
</div>
<table className="w-full text-xs">
<thead className="bg-gray-50 text-gray-600">
<tr>
<th className="px-3 py-2 text-left">Kategorie</th>
<th className="px-3 py-2 text-center">Anbieter sichtbar</th>
<th className="px-3 py-2 text-center">Tracker erkannt</th>
<th className="px-3 py-2 text-left">Violations</th>
</tr>
</thead>
<tbody>
{cats.map(c => {
const pdv = c.provider_details_visible
const pdv_label = pdv === true ? 'Ja' : pdv === false ? 'Nein' : ''
const pdv_color = pdv === false ? 'text-red-700' : pdv === true ? 'text-green-700' : 'text-gray-400'
return (
<tr key={c.category} className="border-t">
<td className="px-3 py-2">{c.category_label}</td>
<td className={`px-3 py-2 text-center font-semibold ${pdv_color}`}>{pdv_label}</td>
<td className="px-3 py-2 text-center">{(c.tracking_services || []).length}</td>
<td className="px-3 py-2 text-red-700 text-[10px]">
{(c.violations || []).map(v => v.text?.slice(0, 80)).join('; ') || '—'}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
{/* Banner-Checks Violations */}
{violations.length > 0 && (
<div className="border rounded-lg overflow-hidden">
<div className="px-4 py-2 bg-gray-50 border-b text-sm font-medium text-gray-700">
Banner-Verstoesse ({violations.length})
</div>
<ul className="text-xs divide-y">
{violations.map((v, i) => {
const sev = (v.severity || 'MEDIUM').toUpperCase()
return (
<li key={i} className="px-3 py-2">
<div className="flex items-start gap-2">
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${SEV_BADGE[sev] || 'bg-gray-100'}`}>{sev}</span>
<div>
<div className="text-gray-900">{v.text}</div>
{v.legal_ref && <div className="text-[10px] text-gray-400 italic mt-1">Quelle: {v.legal_ref}</div>}
</div>
</div>
</li>
)
})}
</ul>
</div>
)}
{/* 46 structured_checks Drilldown */}
<div className="border rounded-lg overflow-hidden">
<div className="px-4 py-2 bg-gray-50 border-b text-sm font-medium text-gray-700 flex items-center gap-3">
<span>Banner-Checks ({checks.length})</span>
<div className="ml-auto flex gap-1">
{(['all', 'fail', 'critical'] as const).map(f => (
<button key={f}
onClick={() => setCheckFilter(f)}
className={`px-2 py-1 rounded text-[10px] border ${
checkFilter === f ? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-600 border-gray-200'
}`}>
{f === 'all' ? 'Alle' : f === 'fail' ? 'Nur Fail' : 'Nur CRIT/HIGH'}
</button>
))}
</div>
</div>
<table className="w-full text-xs">
<thead className="bg-gray-50 text-gray-600">
<tr>
<th className="px-3 py-2 text-left">Status</th>
<th className="px-3 py-2 text-left">Sev</th>
<th className="px-3 py-2 text-left">Check</th>
</tr>
</thead>
<tbody>
{filteredChecks.map(c => (
<tr key={c.id} className="border-t">
<td className="px-3 py-2">
{c.passed ? <span className="text-green-600"></span>
: c.skipped ? <span className="text-gray-400"></span>
: <span className="text-red-600"></span>}
</td>
<td className="px-3 py-2">
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${SEV_BADGE[c.severity] || 'bg-gray-100'}`}>
{c.severity}
</span>
</td>
<td className="px-3 py-2">
<div className="text-gray-900">{c.label}</div>
{c.hint && !c.passed && (
<div className="text-[10px] text-gray-500 mt-1">{c.hint.slice(0, 200)}</div>
)}
</td>
</tr>
))}
{filteredChecks.length === 0 && (
<tr><td colSpan={3} className="px-3 py-4 text-center text-gray-400">Keine Checks fuer den Filter.</td></tr>
)}
</tbody>
</table>
</div>
</div>
)
}
@@ -3,6 +3,7 @@
import React, { useEffect, useState, useMemo } from 'react'
import { use as useUnwrap } from 'react'
import FindingsTab from './FindingsTab'
import BannerTab from './BannerTab'
type MCRow = {
id: number
@@ -92,7 +93,7 @@ export default function AuditPage(
const [filterReg, setFilterReg] = useState<string>('')
const [filterDoc, setFilterDoc] = useState<string>('')
const [expanded, setExpanded] = useState<number | null>(null)
const [tab, setTab] = useState<'mc' | 'all'>('all')
const [tab, setTab] = useState<'mc' | 'all' | 'banner'>('all')
useEffect(() => {
let cancelled = false
@@ -155,6 +156,7 @@ export default function AuditPage(
<div className="flex gap-2 border-b border-gray-200">
{([
{ key: 'all', label: 'Voll-Audit (alle Findings)' },
{ key: 'banner', label: 'Cookie-Banner-Analyse' },
{ key: 'mc', label: 'Nur MC-Scorecard' },
] as const).map(t => (
<button key={t.key}
@@ -168,6 +170,7 @@ export default function AuditPage(
</div>
{tab === 'all' && <FindingsTab checkId={checkId} />}
{tab === 'banner' && <BannerTab checkId={checkId} />}
{tab === 'mc' && <>
{/* Scorecard */}
@@ -0,0 +1,124 @@
'use client'
/**
* Lifecycle-Phasen-Filter für den Document-Generator.
*
* Zeigt 5 Phasen-Tabs (Pre-Founding, Founding, Startup, KMU, Konzern) und
* filtert die angezeigten Templates entsprechend ihres `lifecycle_stage`-Arrays.
*
* Phasen-Definitionen synchron zu lib/sdk/founding/template-categories.ts
*/
import {
LIFECYCLE_STAGE_LABELS,
type LifecycleStage,
TEMPLATE_CATEGORIES,
} from '@/lib/sdk/founding/template-categories'
interface Props {
activeStage: LifecycleStage | 'all'
onChange: (stage: LifecycleStage | 'all') => void
/** Template-Counts pro Stage (optional, sonst aus Code-Registry berechnet) */
countsByStage?: Record<string, number>
}
const STAGE_ORDER: (LifecycleStage | 'all')[] = [
'all',
'pre_founding',
'founding',
'startup',
'kmu',
'konzern',
]
const STAGE_ICONS: Record<LifecycleStage | 'all', string> = {
all: '📚',
pre_founding: '🌱',
founding: '⚖️',
startup: '🚀',
kmu: '🏢',
konzern: '🏛️',
}
const STAGE_HINTS: Record<LifecycleStage, string> = {
pre_founding: 'Vor dem Notartermin — Term Sheet, IP-Sicherung, Wandeldarlehen',
founding: 'Für den Notartermin — Satzung, Gesellschafterliste, HRB-Anmeldung',
startup: '03 Jahre, <25 Mitarbeiter — Arbeitsverträge, AVV, Datenschutz',
kmu: '3+ Jahre, 25250 MA — ISMS, Whistleblower, vollständige TOM',
konzern: '250+ MA — Konzern-Compliance, ISO 27001',
}
export function LifecycleFilter({ activeStage, onChange, countsByStage }: Props) {
const counts = countsByStage || computeCountsFromRegistry()
return (
<div className="mb-6" data-testid="lifecycle-filter">
<div className="flex items-center gap-2 mb-2">
<h3 className="text-sm font-semibold text-gray-700">Phase Deines Unternehmens</h3>
<span className="text-xs text-gray-500"> filtert Dokumente nach Lifecycle</span>
</div>
<div className="flex flex-wrap gap-2">
{STAGE_ORDER.map(stage => {
const isAll = stage === 'all'
const count = isAll
? Object.values(counts).reduce((s, c) => s + c, 0)
: (counts[stage] || 0)
const label = isAll ? 'Alle' : LIFECYCLE_STAGE_LABELS[stage as LifecycleStage].split(' (')[0]
const isActive = activeStage === stage
return (
<button
key={stage}
type="button"
data-testid={`stage-tab-${stage}`}
onClick={() => onChange(stage)}
className={`px-3 py-2 rounded-lg border text-sm font-medium transition ${
isActive
? 'bg-purple-600 text-white border-purple-600 shadow-sm'
: 'bg-white text-gray-700 border-gray-200 hover:border-purple-300 hover:bg-purple-50'
}`}
>
<span className="mr-1.5">{STAGE_ICONS[stage]}</span>
{label}
<span className={`ml-2 px-1.5 py-0.5 text-xs rounded-full ${
isActive ? 'bg-white/20' : 'bg-gray-100 text-gray-600'
}`}>
{count}
</span>
</button>
)
})}
</div>
{activeStage !== 'all' && (
<p className="mt-2 text-sm text-gray-500" data-testid="stage-hint">
{STAGE_HINTS[activeStage as LifecycleStage]}
</p>
)}
</div>
)
}
function computeCountsFromRegistry(): Record<string, number> {
const counts: Record<string, number> = {
pre_founding: 0, founding: 0, startup: 0, kmu: 0, konzern: 0,
}
for (const cat of Object.values(TEMPLATE_CATEGORIES)) {
for (const stage of cat.lifecycle_stage) {
counts[stage] = (counts[stage] || 0) + 1
}
}
return counts
}
export function filterTemplatesByStage<T extends { document_type?: string; type?: string }>(
templates: T[],
stage: LifecycleStage | 'all'
): T[] {
if (stage === 'all') return templates
return templates.filter(t => {
const docType = t.document_type || t.type
if (!docType) return false
const cat = TEMPLATE_CATEGORIES[docType]
if (!cat) return stage === 'startup' // Fallback: unkategorisierte zeigen wir in Startup
return cat.lifecycle_stage.includes(stage)
})
}
@@ -39,7 +39,7 @@ export const CATEGORIES: { key: string; label: string; types: string[] | null }[
]},
// Datenschutz-Informationen (alle DSI-Typen):
{ key: 'dsi', label: 'Datenschutzinfos', types: ['privacy_policy', 'applicant_dsi', 'employee_dsi', 'social_media_dsi', 'video_conference_dsi', 'informationspflichten'] },
{ key: 'dsi', label: 'Datenschutzinfos', types: ['privacy_policy', 'data_protection_policy', 'applicant_dsi', 'employee_dsi', 'social_media_dsi', 'video_conference_dsi', 'informationspflichten'] },
// Einwilligungen:
{ key: 'consent', label: 'Einwilligungen', types: ['consent_texts', 'cookie_banner', 'verpflichtungserklaerung'] },
@@ -15,6 +15,8 @@ import { getGeneratorDefaults, getProfileLabel } from './scopeDefaults'
import TemplateLibrary from './_components/TemplateLibrary'
import GeneratorSection from './_components/GeneratorSection'
import RecommendedDocuments from './_components/RecommendedDocuments'
import { LifecycleFilter, filterTemplatesByStage } from './_components/LifecycleFilter'
import type { LifecycleStage } from '@/lib/sdk/founding/template-categories'
function DocumentGeneratorPageInner() {
const { state } = useSDK()
@@ -24,6 +26,7 @@ function DocumentGeneratorPageInner() {
const [allTemplates, setAllTemplates] = useState<LegalTemplateResult[]>([])
const [isLoadingLibrary, setIsLoadingLibrary] = useState(true)
const [activeCategory, setActiveCategory] = useState<string>('all')
const [activeStage, setActiveStage] = useState<LifecycleStage | 'all'>('all')
const [activeLanguage, setActiveLanguage] = useState<'all' | 'de' | 'en'>('all')
const [librarySearch, setLibrarySearch] = useState('')
const [expandedPreviewId, setExpandedPreviewId] = useState<string | null>(null)
@@ -209,10 +212,15 @@ function DocumentGeneratorPageInner() {
}
}, [selectedDataPointsData])
// Filtered templates (computed)
// Filtered templates (computed) — Lifecycle + Category + Language + Search
const filteredTemplates = useMemo(() => {
const category = CATEGORIES.find((c: { key: string }) => c.key === activeCategory)
return allTemplates.filter((t) => {
// 1. Lifecycle-Phase Filter via Code-Registry (mapped auf templateType)
const stageFiltered = filterTemplatesByStage(
allTemplates.map(t => ({ ...t, document_type: t.templateType || '' })),
activeStage
)
return stageFiltered.filter((t) => {
if (category && category.types !== null) {
if (!category.types.includes(t.templateType || '')) return false
}
@@ -225,7 +233,22 @@ function DocumentGeneratorPageInner() {
}
return true
})
}, [allTemplates, activeCategory, activeLanguage, librarySearch])
}, [allTemplates, activeCategory, activeStage, activeLanguage, librarySearch])
// Counts by stage for filter UI
const countsByStage = useMemo(() => {
const counts: Record<string, number> = { pre_founding: 0, founding: 0, startup: 0, kmu: 0, konzern: 0 }
const stages: LifecycleStage[] = ['pre_founding', 'founding', 'startup', 'kmu', 'konzern']
for (const t of allTemplates) {
const docType = t.templateType || ''
for (const s of stages) {
if (filterTemplatesByStage([{ document_type: docType }], s).length) {
counts[s]++
}
}
}
return counts
}, [allTemplates])
const handleUseTemplate = useCallback((t: LegalTemplateResult) => {
setActiveTemplate(t)
@@ -292,6 +315,13 @@ function DocumentGeneratorPageInner() {
</div>
</div>
{/* Lifecycle-Phase Filter */}
<LifecycleFilter
activeStage={activeStage}
onChange={setActiveStage}
countsByStage={countsByStage}
/>
{/* Recommended documents based on scope profile */}
<RecommendedDocuments
allTemplates={allTemplates}
@@ -225,6 +225,51 @@ const TEMPLATE_RULES: TemplateRule[] = [
condition: () => 'required', // Immer Pflicht bei Websites
},
// ── DSE & Datenschutz-Kerndokumente (P38) ──────────────────────────────
{
templateType: 'privacy_policy',
label: 'Datenschutzerklaerung (Website)',
condition: () => 'required', // Art. 13 DSGVO — bei jeder Website Pflicht
},
{
templateType: 'data_protection_policy',
label: 'Datenschutzrichtlinie (intern)',
condition: (_answers, level) => level >= 'L2' ? 'required' : 'recommended',
},
{
templateType: 'dsfa',
label: 'DSFA-Vorlage',
condition: (answers) => {
const dsfa = answers.get('proc_dsfa_required') || answers.get('comp_dsfa_processes')
if (dsfa === 'yes' || dsfa === 'required') return 'required'
return 'optional'
},
},
{
templateType: 'dpa',
label: 'Auftragsverarbeitungsvertrag (AVV)',
condition: (answers) => {
const vendors = answers.get('comp_has_processors') || answers.get('comp_vendor_management')
if (vendors && vendors !== 'no') return 'required'
return 'recommended'
},
},
{
templateType: 'vvt_register',
label: 'Verzeichnis von Verarbeitungstaetigkeiten (VVT)',
condition: (_answers, level) => level >= 'L2' ? 'required' : 'recommended',
},
{
templateType: 'tom_documentation',
label: 'TOM-Dokumentation',
condition: (_answers, level) => level >= 'L2' ? 'required' : 'recommended',
},
{
templateType: 'loeschkonzept',
label: 'Loeschkonzept',
condition: (_answers, level) => level >= 'L2' ? 'required' : 'recommended',
},
// ── Drittlandtransfer (SCC + TIA) ───────────────────────────────────────
// SCC+TIA nur erforderlich wenn Drittlandtransfer OHNE Angemessenheitsbeschluss/DPF
{
@@ -0,0 +1,220 @@
'use client'
import { useState } from 'react'
import type { FoundingWizardState } from '@/lib/sdk/founding/types'
interface Props {
state: FoundingWizardState
update: <K extends keyof FoundingWizardState>(k: K, v: FoundingWizardState[K]) => void
}
export function StepBasics({ state, update }: Props) {
const b = state.basics
const [prefillStatus, setPrefillStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle')
async function prefillFromCompanyProfile() {
setPrefillStatus('loading')
try {
const res = await fetch('/api/sdk/v1/company-profile', { cache: 'no-store' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const payload = await res.json()
const p = payload?.profile ?? payload
if (!p || typeof p !== 'object') throw new Error('leeres Profil')
const industries = Array.isArray(p.industry) ? p.industry.filter(Boolean) : []
const industry = industries.length > 0
? industries.join(', ')
: (p.industryOther || b.industry)
const address = [p.headquartersStreet, [p.headquartersZip, p.headquartersCity].filter(Boolean).join(' ')]
.filter(Boolean).join(', ') || b.company_address
const seat = p.headquartersCity || b.company_seat
// Purpose ableiten aus offerings/businessModel — Fallback wenn nichts da
const purposeBits: string[] = []
if (p.businessModel) purposeBits.push(`Geschäftsmodell: ${p.businessModel}`)
if (Array.isArray(p.offerings) && p.offerings.length > 0)
purposeBits.push(`Leistungen: ${p.offerings.join(', ')}`)
const purpose = purposeBits.length > 0
? purposeBits.join('; ')
: b.company_purpose_description
update('basics', {
...b,
company_name: p.companyName || b.company_name,
legal_form: (p.legalForm === 'UG' ? 'UG' : (p.legalForm === 'GmbH' ? 'GmbH' : b.legal_form)),
company_seat: seat,
company_address: address,
industry,
company_purpose_description: b.company_purpose_description.trim() === '' ? purpose : b.company_purpose_description,
})
setPrefillStatus('success')
} catch (err) {
console.error('[founding-wizard] prefill failed', err)
setPrefillStatus('error')
}
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-gray-600">
Stammdaten der Gesellschaft. Pflicht für Satzung, HRB-Anmeldung und SHA.
</p>
<button
type="button"
onClick={prefillFromCompanyProfile}
disabled={prefillStatus === 'loading'}
className="px-3 py-1.5 text-sm rounded-lg border border-blue-300 bg-blue-50 hover:bg-blue-100 disabled:opacity-50"
>
{prefillStatus === 'loading' ? 'Lade…' : 'Aus Unternehmensprofil vorbefüllen'}
</button>
</div>
{prefillStatus === 'success' && (
<div className="text-xs text-green-700 bg-green-50 border border-green-200 rounded px-2 py-1">
Daten aus Unternehmensprofil übernommen. Bitte prüfen und ergänzen.
</div>
)}
{prefillStatus === 'error' && (
<div className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded px-2 py-1">
Konnte Unternehmensprofil nicht laden bitte Felder manuell ausfüllen.
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Firmenname</label>
<input
data-testid="company-name"
type="text"
value={b.company_name}
onChange={e => update('basics', { ...b, company_name: e.target.value })}
placeholder="Breakpilot GmbH"
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Rechtsform</label>
<select
data-testid="legal-form"
value={b.legal_form}
onChange={e => update('basics', { ...b, legal_form: e.target.value as 'GmbH' | 'UG' })}
className="w-full px-3 py-2 border rounded-lg"
>
<option value="GmbH">GmbH</option>
<option value="UG">UG (haftungsbeschränkt)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Sitz (Stadt)</label>
<input
data-testid="company-seat"
type="text"
value={b.company_seat}
onChange={e => update('basics', { ...b, company_seat: e.target.value })}
placeholder="z.B. Stuttgart"
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Adresse</label>
<input
data-testid="company-address"
type="text"
value={b.company_address}
onChange={e => update('basics', { ...b, company_address: e.target.value })}
placeholder="Straße, PLZ Ort"
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Branche</label>
<input
data-testid="industry"
type="text"
value={b.industry}
onChange={e => update('basics', { ...b, industry: e.target.value })}
placeholder="z.B. SaaS, Beratung, Handwerk"
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Geschäftsjahr</label>
<input
data-testid="business-year"
type="text"
value={b.business_year}
onChange={e => update('basics', { ...b, business_year: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Registergericht
</label>
<input
data-testid="register-court"
type="text"
value={b.register_court || ''}
onChange={e => update('basics', { ...b, register_court: e.target.value })}
placeholder="z.B. Amtsgericht Stuttgart"
className="w-full px-3 py-2 border rounded-lg"
/>
<p className="text-xs text-gray-500 mt-1">
Zuständiges Amtsgericht für HRB-Eintragung
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
HRB-Nummer <span className="text-gray-400">(optional)</span>
</label>
<input
data-testid="hrb-number"
type="text"
value={b.hrb_number || ''}
onChange={e => update('basics', { ...b, hrb_number: e.target.value })}
placeholder="z.B. HRB 12345 (leer falls noch nicht eingetragen)"
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Unternehmensgegenstand (Volltext für § 2 Satzung)
</label>
<textarea
data-testid="company-purpose"
value={b.company_purpose_description}
onChange={e => update('basics', { ...b, company_purpose_description: e.target.value })}
rows={4}
placeholder="z.B. die Entwicklung, Bereitstellung, der Betrieb und der Vertrieb von Softwarelösungen, Plattformen und IT-Dienstleistungen im Bereich der Künstlichen Intelligenz"
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Detaillierte Tätigkeitsbereiche (eine Zeile pro Bullet)
</label>
<textarea
data-testid="company-purpose-bullets"
value={b.company_purpose_bullets.join('\n')}
onChange={e => update('basics', { ...b, company_purpose_bullets: e.target.value.split('\n').filter(Boolean) })}
rows={5}
placeholder={'a) Entwicklung von Software\nb) Beratung im Bereich...\nc) ...'}
className="w-full px-3 py-2 border rounded-lg font-mono text-sm"
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="research_focus"
data-testid="research-focus"
checked={b.has_research_focus}
onChange={e => update('basics', { ...b, has_research_focus: e.target.checked })}
/>
<label htmlFor="research_focus" className="text-sm text-gray-700">
Forschungsfokus (aktiviert F&amp;E-Klauseln in SHA und GO-GF)
</label>
</div>
</div>
)
}
@@ -0,0 +1,146 @@
'use client'
import { useMemo } from 'react'
import type { FoundingWizardState, GeneratedDocument } from '@/lib/sdk/founding/types'
import { NOTARY_BUNDLE_DOCUMENTS } from '@/lib/sdk/founding/template-categories'
interface Props {
state: FoundingWizardState
update: <K extends keyof FoundingWizardState>(k: K, v: FoundingWizardState[K]) => void
generating: boolean
error: string | null
onGenerate: () => Promise<GeneratedDocument[]>
}
const DOC_LABELS: Record<string, string> = {
articles_of_association: 'Satzung',
gesellschafterliste: 'Gesellschafterliste (§ 40 GmbHG)',
gf_bestellungsbeschluss: 'Gesellschafterbeschluss zur GF-Bestellung',
hrb_anmeldung: 'Handelsregister-Anmeldung',
sha: 'Shareholders\' Agreement (SHA)',
geschaeftsordnung_gf: 'Geschäftsordnung Geschäftsführung (GO-GF)',
managing_director_employment_contract: 'GF-Dienstvertrag (pro GF)',
ip_assignment_agreement: 'IP-Assignment (pro Gründer)',
term_sheet: 'Term Sheet',
convertible_loan_agreement: 'Wandeldarlehensvertrag',
subscription_agreement: 'Beteiligungsvertrag',
esop_plan: 'ESOP/VSOP-Plan',
cap_table: 'Cap Table',
}
export function StepGenerate({ state, update, generating, error, onGenerate }: Props) {
const toggleDoc = (docType: string) => {
const next = state.selected_documents.includes(docType)
? state.selected_documents.filter(d => d !== docType)
: [...state.selected_documents, docType]
update('selected_documents', next)
}
const selectNotaryBundle = () => {
update('selected_documents', [...NOTARY_BUNDLE_DOCUMENTS])
}
const summary = useMemo(() => ({
name: state.basics.company_name,
seat: state.basics.company_seat,
stammkapital: state.capital.stammkapital_eur,
num_gesellschafter: state.gesellschafter.length,
num_gf: state.gesellschafter.filter(g => g.is_geschaeftsfuehrer).length,
}), [state])
return (
<div className="space-y-6">
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
<h3 className="font-semibold text-purple-900 mb-2">Zusammenfassung</h3>
<dl className="grid grid-cols-2 gap-2 text-sm" data-testid="generate-summary">
<dt className="text-gray-600">Firma:</dt><dd>{summary.name} ({state.basics.legal_form})</dd>
<dt className="text-gray-600">Sitz:</dt><dd>{summary.seat}</dd>
<dt className="text-gray-600">Stammkapital:</dt><dd>{summary.stammkapital.toLocaleString('de-DE')} </dd>
<dt className="text-gray-600">Gesellschafter:</dt><dd>{summary.num_gesellschafter}</dd>
<dt className="text-gray-600">Geschäftsführer:</dt><dd>{summary.num_gf}</dd>
<dt className="text-gray-600">Notar:</dt><dd>{state.notar.notary_name} ({state.notar.notary_place})</dd>
</dl>
</div>
<div>
<div className="flex justify-between items-center mb-3">
<h3 className="font-semibold">Zu generierende Dokumente</h3>
<button
type="button"
data-testid="select-notary-bundle"
onClick={selectNotaryBundle}
className="text-sm text-purple-600 hover:underline"
>
Notartermin-Bundle auswählen
</button>
</div>
<div className="grid grid-cols-1 gap-2">
{Object.entries(DOC_LABELS).map(([docType, label]) => (
<label key={docType} className="flex items-start gap-3 p-2 hover:bg-gray-50 rounded">
<input
type="checkbox"
data-testid={`doc-${docType}`}
checked={state.selected_documents.includes(docType)}
onChange={() => toggleDoc(docType)}
className="mt-1"
/>
<div className="flex-1">
<div className="text-sm font-medium">{label}</div>
<div className="text-xs text-gray-500">{docType}</div>
</div>
{NOTARY_BUNDLE_DOCUMENTS.includes(docType) && (
<span className="text-xs bg-purple-100 text-purple-700 px-2 py-0.5 rounded">Notartermin</span>
)}
</label>
))}
</div>
</div>
<div className="flex justify-between items-center pt-4 border-t">
<p className="text-sm text-gray-500">
{state.selected_documents.length} Dokument(e) ausgewählt
</p>
<button
data-testid="generate-docs"
onClick={onGenerate}
disabled={generating || state.selected_documents.length === 0}
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 font-medium"
>
{generating ? 'Generiere...' : 'Dokumente als Word generieren'}
</button>
</div>
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-900" data-testid="generate-error">
Fehler: {error}
</div>
)}
{state.generated_documents && state.generated_documents.length > 0 && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4" data-testid="generated-docs">
<h3 className="font-semibold text-green-900 mb-3">
{state.generated_documents.length} Dokument(e) generiert
</h3>
<ul className="space-y-2">
{state.generated_documents.map((doc, idx) => (
<li key={idx} className="flex justify-between items-center bg-white rounded px-3 py-2 border border-green-200">
<div>
<div className="text-sm font-medium">{doc.title}</div>
<div className="text-xs text-gray-500">{(doc.size_bytes / 1024).toFixed(1)} KB</div>
</div>
<a
href={doc.download_url}
download
data-testid={`download-${doc.document_type}`}
className="px-3 py-1.5 bg-green-600 text-white rounded text-sm hover:bg-green-700"
>
Word herunterladen
</a>
</li>
))}
</ul>
</div>
)}
</div>
)
}
@@ -0,0 +1,215 @@
'use client'
import { useState } from 'react'
import type { FoundingWizardState, Gesellschafter } from '@/lib/sdk/founding/types'
interface Props {
state: FoundingWizardState
addGesellschafter: (g: Omit<Gesellschafter, 'id' | 'anteil_nr'>) => void
updateGesellschafter: (id: string, p: Partial<Gesellschafter>) => void
removeGesellschafter: (id: string) => void
}
export function StepGesellschafter({ state, addGesellschafter, updateGesellschafter, removeGesellschafter }: Props) {
const [form, setForm] = useState({
name: '', geburtsdatum: '', adresse: '', email: '',
nennbetrag_eur: 12500, is_geschaeftsfuehrer: true, internal_role: '',
has_academic_background: false, ip_areas: '',
})
const totalNennbetrag = state.gesellschafter.reduce((s, g) => s + g.nennbetrag_eur, 0)
const target = state.capital.stammkapital_eur
const handleAdd = () => {
if (!form.name.trim()) return
const ip_areas = form.ip_areas
.split('\n').map(s => s.trim()).filter(Boolean)
addGesellschafter({
rolle: 'founder',
name: form.name,
geburtsdatum: form.geburtsdatum || undefined,
adresse: form.adresse,
email: form.email || undefined,
nennbetrag_eur: form.nennbetrag_eur,
is_geschaeftsfuehrer: form.is_geschaeftsfuehrer,
internal_role: form.internal_role || undefined,
has_academic_background: form.has_academic_background,
ip_areas: ip_areas.length > 0 ? ip_areas : undefined,
})
setForm({ name: '', geburtsdatum: '', adresse: '', email: '', nennbetrag_eur: 12500,
is_geschaeftsfuehrer: true, internal_role: '', has_academic_background: false, ip_areas: '' })
}
return (
<div className="space-y-4">
<div className="bg-gray-50 p-4 rounded-lg">
<h3 className="font-semibold mb-3">Neuen Gesellschafter hinzufügen</h3>
<div className="grid grid-cols-2 gap-3">
<input
data-testid="gs-name"
placeholder="Name"
value={form.name}
onChange={e => setForm({ ...form, name: e.target.value })}
className="px-3 py-2 border rounded"
/>
<input
data-testid="gs-birthdate"
type="date"
placeholder="Geburtsdatum"
value={form.geburtsdatum}
onChange={e => setForm({ ...form, geburtsdatum: e.target.value })}
className="px-3 py-2 border rounded"
/>
<input
data-testid="gs-address"
placeholder="Adresse (Straße, PLZ Ort)"
value={form.adresse}
onChange={e => setForm({ ...form, adresse: e.target.value })}
className="px-3 py-2 border rounded col-span-2"
/>
<input
data-testid="gs-email"
type="email"
placeholder="E-Mail (optional)"
value={form.email}
onChange={e => setForm({ ...form, email: e.target.value })}
className="px-3 py-2 border rounded"
/>
<input
data-testid="gs-nennbetrag"
type="number"
min={1}
step={1}
placeholder="Nennbetrag in EUR"
value={form.nennbetrag_eur}
onChange={e => setForm({ ...form, nennbetrag_eur: parseInt(e.target.value) || 0 })}
className="px-3 py-2 border rounded"
/>
<select
data-testid="gs-role"
value={form.internal_role}
onChange={e => setForm({ ...form, internal_role: e.target.value })}
className="px-3 py-2 border rounded bg-white"
>
<option value="">Rolle wählen</option>
<option value="CEO">CEO (Chief Executive Officer)</option>
<option value="CTO">CTO (Chief Technical Officer)</option>
<option value="CFO">CFO (Chief Financial Officer)</option>
<option value="COO">COO (Chief Operating Officer)</option>
<option value="CPO">CPO (Chief Product Officer)</option>
<option value="Geschäftsführer">Geschäftsführer (ohne Spezialisierung)</option>
<option value="Gesellschafter">Gesellschafter (kein GF)</option>
<option value="Sonstige">Sonstige</option>
</select>
<div className="flex items-center gap-2">
<input
type="checkbox"
data-testid="gs-is-gf"
checked={form.is_geschaeftsfuehrer}
onChange={e => setForm({ ...form, is_geschaeftsfuehrer: e.target.checked })}
/>
<label className="text-sm">Geschäftsführer/in</label>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
data-testid="gs-academic"
checked={form.has_academic_background}
onChange={e => setForm({ ...form, has_academic_background: e.target.checked })}
/>
<label className="text-sm">Akademischer Hintergrund</label>
</div>
</div>
<div className="mt-3">
<label className="block text-sm font-medium text-gray-700 mb-1">
IP-Bereiche, die diese Person in die Gesellschaft einbringt
<span className="text-gray-400"> (optional, eine Zeile pro Bereich)</span>
</label>
<textarea
data-testid="gs-ip-areas"
value={form.ip_areas}
onChange={e => setForm({ ...form, ip_areas: e.target.value })}
rows={3}
placeholder={'z.B.\nCompliance-Engine (Quellcode + Architektur)\nRAG-Pipeline\nKonfigurationsdaten'}
className="w-full px-3 py-2 border rounded font-mono text-xs"
/>
<p className="text-xs text-gray-500 mt-1">
Bei mehreren Gründern wird pro Person ein eigener IP-Assignment-Vertrag generiert.
</p>
</div>
<button
data-testid="add-gesellschafter"
onClick={handleAdd}
disabled={!form.name.trim() || form.nennbetrag_eur < 1}
className="mt-3 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
Gesellschafter hinzufügen
</button>
</div>
<div>
<h3 className="font-semibold mb-3">Gesellschafter ({state.gesellschafter.length})</h3>
{state.gesellschafter.length === 0 ? (
<p className="text-gray-500 text-sm">Noch keine Gesellschafter angelegt.</p>
) : (
<table className="w-full text-sm" data-testid="gs-table">
<thead className="bg-gray-100">
<tr>
<th className="px-3 py-2 text-left">Nr.</th>
<th className="px-3 py-2 text-left">Name</th>
<th className="px-3 py-2 text-left">Geburtsdatum</th>
<th className="px-3 py-2 text-right">Nennbetrag</th>
<th className="px-3 py-2 text-right">Anteil %</th>
<th className="px-3 py-2">GF?</th>
<th className="px-3 py-2"></th>
</tr>
</thead>
<tbody>
{state.gesellschafter.map(g => (
<tr key={g.id} className="border-t" data-testid={`gs-row-${g.anteil_nr}`}>
<td className="px-3 py-2">{g.anteil_nr}</td>
<td className="px-3 py-2 font-medium">
{g.name}{g.internal_role ? ` (${g.internal_role})` : ''}
{g.ip_areas && g.ip_areas.length > 0 && (
<div className="text-xs text-gray-500 mt-0.5">
IP: {g.ip_areas.join(', ')}
</div>
)}
</td>
<td className="px-3 py-2">{g.geburtsdatum || '—'}</td>
<td className="px-3 py-2 text-right">{g.nennbetrag_eur.toLocaleString('de-DE')} </td>
<td className="px-3 py-2 text-right">{((g.nennbetrag_eur / Math.max(target, 1)) * 100).toFixed(2)}%</td>
<td className="px-3 py-2 text-center">{g.is_geschaeftsfuehrer ? '✓' : '—'}</td>
<td className="px-3 py-2">
<button
onClick={() => removeGesellschafter(g.id)}
className="text-red-600 hover:underline text-xs"
>
Entfernen
</button>
</td>
</tr>
))}
<tr className="border-t-2 font-semibold bg-gray-50">
<td colSpan={3} className="px-3 py-2">Summe</td>
<td className="px-3 py-2 text-right" data-testid="gs-total">
{totalNennbetrag.toLocaleString('de-DE')}
</td>
<td className="px-3 py-2 text-right">
{totalNennbetrag === target ? '100%' : `${target.toLocaleString('de-DE')}`}
</td>
<td colSpan={2}></td>
</tr>
</tbody>
</table>
)}
{totalNennbetrag !== target && state.gesellschafter.length > 0 && (
<p className="mt-2 text-sm text-orange-600">
Die Summe der Nennbeträge ({totalNennbetrag.toLocaleString('de-DE')} )
entspricht nicht dem Stammkapital ({target.toLocaleString('de-DE')} ).
</p>
)}
</div>
</div>
)
}
@@ -0,0 +1,321 @@
'use client'
/**
* Kombinierte einfache Steps: Geschäftsführer (3), Kapital (4), Notar (5), SHA (6).
* Jeder Sub-Step ist eine simple Form.
*/
import type { FoundingWizardState, GFContract } from '@/lib/sdk/founding/types'
interface PropsBase {
state: FoundingWizardState
update: <K extends keyof FoundingWizardState>(k: K, v: FoundingWizardState[K]) => void
}
export function StepGFAssignment({ state, update }: PropsBase) {
const founders = state.gesellschafter
const toggleGF = (id: string, val: boolean) => {
update('gesellschafter', state.gesellschafter.map(g => g.id === id ? { ...g, is_geschaeftsfuehrer: val } : g))
}
const setRole = (id: string, role: string) => {
update('gesellschafter', state.gesellschafter.map(g => g.id === id ? { ...g, internal_role: role } : g))
}
return (
<div className="space-y-4">
<p className="text-sm text-gray-600">
Wähle, welche Gesellschafter zu Geschäftsführern bestellt werden sollen. Standardmäßig sind alle Gründer auch GF.
</p>
{founders.length === 0 ? (
<p className="text-orange-600">Bitte zuerst Gesellschafter in Step 2 anlegen.</p>
) : (
<table className="w-full text-sm" data-testid="gf-assignment-table">
<thead className="bg-gray-100">
<tr>
<th className="px-3 py-2 text-left">Gesellschafter</th>
<th className="px-3 py-2 text-left">Interne Rolle (CEO, CTO, ...)</th>
<th className="px-3 py-2">GF?</th>
</tr>
</thead>
<tbody>
{founders.map(g => (
<tr key={g.id} className="border-t">
<td className="px-3 py-2 font-medium">{g.name}</td>
<td className="px-3 py-2">
<input
value={g.internal_role || ''}
onChange={e => setRole(g.id, e.target.value)}
className="px-2 py-1 border rounded w-48"
placeholder="CEO, CTO, COO..."
/>
</td>
<td className="px-3 py-2 text-center">
<input
type="checkbox"
data-testid={`gf-toggle-${g.anteil_nr}`}
checked={g.is_geschaeftsfuehrer}
onChange={e => toggleGF(g.id, e.target.checked)}
/>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)
}
export function StepCapital({ state, update }: PropsBase) {
const c = state.capital
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Stammkapital (EUR)</label>
<input
data-testid="stammkapital"
type="number" min={1} step={1}
value={c.stammkapital_eur}
onChange={e => update('capital', { ...c, stammkapital_eur: parseInt(e.target.value) || 0 })}
className="w-full px-3 py-2 border rounded-lg"
/>
<p className="mt-1 text-xs text-gray-500">GmbH: mind. 25.000 , UG: ab 1 </p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Einlage-Art</label>
<select
data-testid="einlage-method"
value={c.einlage_method}
onChange={e => update('capital', { ...c, einlage_method: e.target.value as typeof c.einlage_method })}
className="w-full px-3 py-2 border rounded-lg"
>
<option value="Geld">Bargründung</option>
<option value="Sacheinlage">Sachgründung</option>
<option value="Geld und Sacheinlage">Misch-Gründung</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Sofortige Einzahlung (%)
</label>
<input
data-testid="einlage-quote"
type="number" min={25} max={100}
value={c.einlage_quote_initial_pct}
onChange={e => update('capital', { ...c, einlage_quote_initial_pct: parseInt(e.target.value) || 50 })}
className="w-full px-3 py-2 border rounded-lg"
/>
<p className="mt-1 text-xs text-gray-500">Mind. 25% gem. § 7 Abs. 2 GmbHG, Standard 50%</p>
</div>
<div className="flex items-center gap-2 mt-7">
<input
type="checkbox"
id="has_sach"
data-testid="has-sacheinlage"
checked={c.has_sacheinlage}
onChange={e => update('capital', { ...c, has_sacheinlage: e.target.checked })}
/>
<label htmlFor="has_sach" className="text-sm">Sacheinlage-Klausel aktivieren</label>
</div>
</div>
</div>
)
}
export function StepNotar({ state, update }: PropsBase) {
const n = state.notar
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name des Notars</label>
<input
data-testid="notary-name"
value={n.notary_name}
onChange={e => update('notar', { ...n, notary_name: e.target.value })}
placeholder="z.B. Dr. Müller"
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Notarsitz</label>
<input
data-testid="notary-place"
value={n.notary_place}
onChange={e => update('notar', { ...n, notary_place: e.target.value })}
placeholder="z.B. Stuttgart"
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Adresse</label>
<input
data-testid="notary-address"
value={n.notary_address || ''}
onChange={e => update('notar', { ...n, notary_address: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Geplanter Notartermin</label>
<input
data-testid="notarial-date"
type="date"
value={n.notarial_date || ''}
onChange={e => update('notar', { ...n, notarial_date: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 text-sm text-blue-900">
<strong>Hinweis:</strong> Die URNr. wird vom Notar beim Beurkundungstermin vergeben. Du kannst die generierte
HRB-Anmeldung als Vorbereitungsdokument zum Termin mitnehmen.
</div>
</div>
)
}
export function StepSHAConfig({ state, update }: PropsBase) {
const s = state.sha
const updateField = <K extends keyof typeof s>(k: K, v: typeof s[K]) => update('sha', { ...s, [k]: v })
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<input
type="checkbox"
data-testid="has-sha"
checked={s.has_sha}
onChange={e => updateField('has_sha', e.target.checked)}
/>
<label className="text-sm font-medium">SHA (Shareholders' Agreement) ist Teil des Notartermin-Pakets</label>
</div>
{s.has_sha && (
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-gray-700 mb-1">Vesting-Dauer (Monate)</label>
<input data-testid="vesting-months" type="number" value={s.vesting_months}
onChange={e => updateField('vesting_months', parseInt(e.target.value) || 48)}
className="w-full px-3 py-2 border rounded-lg" />
</div>
<div>
<label className="block text-sm text-gray-700 mb-1">Cliff (Monate)</label>
<input data-testid="cliff-months" type="number" value={s.cliff_months}
onChange={e => updateField('cliff_months', parseInt(e.target.value) || 12)}
className="w-full px-3 py-2 border rounded-lg" />
</div>
<div>
<label className="block text-sm text-gray-700 mb-1">Drag-Along Schwelle (%)</label>
<input data-testid="drag-along-pct" type="number" value={s.drag_along_threshold_pct}
onChange={e => updateField('drag_along_threshold_pct', parseInt(e.target.value) || 75)}
className="w-full px-3 py-2 border rounded-lg" />
</div>
<div>
<label className="block text-sm text-gray-700 mb-1">Reserved-Matters Mehrheit (%)</label>
<input data-testid="reserved-matters-pct" type="number" value={s.reserved_matters_majority_pct}
onChange={e => updateField('reserved_matters_majority_pct', parseInt(e.target.value) || 75)}
className="w-full px-3 py-2 border rounded-lg" />
</div>
<div className="col-span-2 grid grid-cols-3 gap-3 mt-2">
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" data-testid="has-beirat" checked={s.has_beirat}
onChange={e => updateField('has_beirat', e.target.checked)} />
Beirat einrichten
</label>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" data-testid="has-texas" checked={s.has_texas_shootout}
onChange={e => updateField('has_texas_shootout', e.target.checked)} />
Texas Shoot-Out (Deadlock)
</label>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" data-testid="has-ceo" checked={s.has_ceo_designation}
onChange={e => updateField('has_ceo_designation', e.target.checked)} />
CEO mit Stichentscheid
</label>
</div>
</div>
)}
</div>
)
}
interface GFContractStepProps extends PropsBase {
gf_list: Array<{ id: string; name: string; internal_role?: string }>
upsertGFContract: (c: GFContract) => void
}
export function StepGFContracts({ state, gf_list, upsertGFContract }: GFContractStepProps) {
return (
<div className="space-y-4">
<p className="text-sm text-gray-600">
Für jeden Geschäftsführer wird ein Dienstvertrag generiert. Bitte Eckdaten ausfüllen.
</p>
{gf_list.length === 0 ? (
<p className="text-orange-600">Bitte zuerst in Step 2 mindestens einen GF anlegen.</p>
) : (
gf_list.map(gf => {
const c = state.gf_contracts.find(x => x.gesellschafter_id === gf.id) || {
gesellschafter_id: gf.id,
gross_annual_salary_eur: 84000,
has_bonus: false,
has_company_car: false,
has_bav: false,
vacation_days: 30,
kuendigungsfrist_gesellschaft_monate: 6,
kuendigungsfrist_gf_monate: 3,
para_181_release: true,
sv_status: 'sozialversicherungsfrei' as const,
}
const u = (patch: Partial<GFContract>) => upsertGFContract({ ...c, ...patch })
return (
<div key={gf.id} className="border rounded-lg p-4" data-testid={`contract-${gf.id}`}>
<h4 className="font-semibold mb-3">{gf.name} {gf.internal_role && `(${gf.internal_role})`}</h4>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs text-gray-700 mb-1">Jahresgehalt (EUR brutto)</label>
<input
data-testid={`salary-${gf.id}`}
type="number"
value={c.gross_annual_salary_eur}
onChange={e => u({ gross_annual_salary_eur: parseInt(e.target.value) || 0 })}
className="w-full px-2 py-1 border rounded"
/>
</div>
<div>
<label className="block text-xs text-gray-700 mb-1">Urlaubstage</label>
<input type="number" value={c.vacation_days}
onChange={e => u({ vacation_days: parseInt(e.target.value) || 30 })}
className="w-full px-2 py-1 border rounded" />
</div>
<div>
<label className="block text-xs text-gray-700 mb-1">SV-Status</label>
<select value={c.sv_status} onChange={e => u({ sv_status: e.target.value as GFContract['sv_status'] })}
className="w-full px-2 py-1 border rounded">
<option value="sozialversicherungsfrei">sv-frei (Standard für GF/Gesellschafter)</option>
<option value="sozialversicherungspflichtig">sv-pflichtig</option>
<option value="noch zu klären">noch zu klären</option>
</select>
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={c.para_181_release}
onChange={e => u({ para_181_release: e.target.checked })} />
§ 181 BGB-Befreiung
</label>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={c.has_bonus}
onChange={e => u({ has_bonus: e.target.checked })} />
Bonus-Vereinbarung
</label>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={c.has_company_car}
onChange={e => u({ has_company_car: e.target.checked })} />
Firmenfahrzeug
</label>
</div>
</div>
)
})
)}
</div>
)
}
@@ -0,0 +1,187 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
defaultFoundingWizardState,
type FoundingWizardState,
type Gesellschafter,
type GFContract,
type GeneratedDocument,
} from '@/lib/sdk/founding/types'
const STORAGE_KEY = 'breakpilot:founding-wizard:state:v1'
export const FOUNDING_WIZARD_STEPS = [
{ id: 1, name: 'Stage & Basics', description: 'Unternehmensname, Sitz, Gegenstand' },
{ id: 2, name: 'Gesellschafter', description: 'Gründer und ihre Anteile' },
{ id: 3, name: 'Geschäftsführer', description: 'GF-Bestellung und Rollen' },
{ id: 4, name: 'Kapital', description: 'Stammkapital und Einzahlung' },
{ id: 5, name: 'Notar', description: 'Notartermin und Beurkundung' },
{ id: 6, name: 'SHA-Optionen', description: 'Vesting, Drag-Along, Reserved Matters' },
{ id: 7, name: 'GF-Verträge', description: 'Vergütung, D&O, Kündigungsfristen' },
{ id: 8, name: 'Dokumente generieren', description: 'Auswahl und Word-Export' },
]
export function useFoundingWizardForm() {
const [state, setState] = useState<FoundingWizardState>(defaultFoundingWizardState())
const [hydrated, setHydrated] = useState(false)
const [generating, setGenerating] = useState(false)
const [error, setError] = useState<string | null>(null)
// Hydrate from localStorage
useEffect(() => {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) {
const parsed = JSON.parse(raw)
setState({ ...defaultFoundingWizardState(), ...parsed })
}
} catch {
// ignore corrupted storage
}
setHydrated(true)
}, [])
// Persist on every change after hydration
useEffect(() => {
if (!hydrated) return
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
} catch {
// quota exceeded - ignore
}
}, [state, hydrated])
const update = useCallback(<K extends keyof FoundingWizardState>(
key: K,
value: FoundingWizardState[K] | ((prev: FoundingWizardState[K]) => FoundingWizardState[K])
) => {
setState(prev => ({
...prev,
[key]: typeof value === 'function' ? (value as Function)(prev[key]) : value,
}))
}, [])
const setStep = useCallback((step: number) => {
setState(prev => ({ ...prev, current_step: step }))
}, [])
const nextStep = useCallback(() => {
setState(prev => ({ ...prev, current_step: Math.min(prev.current_step + 1, FOUNDING_WIZARD_STEPS.length) }))
}, [])
const prevStep = useCallback(() => {
setState(prev => ({ ...prev, current_step: Math.max(prev.current_step - 1, 1) }))
}, [])
const reset = useCallback(() => {
setState(defaultFoundingWizardState())
try { localStorage.removeItem(STORAGE_KEY) } catch {}
}, [])
// Gesellschafter helpers
const addGesellschafter = useCallback((gs: Omit<Gesellschafter, 'id' | 'anteil_nr'>) => {
setState(prev => {
const nextNr = (prev.gesellschafter.reduce((m, g) => Math.max(m, g.anteil_nr), 0)) + 1
const id = `gs_${Date.now()}_${nextNr}`
return { ...prev, gesellschafter: [...prev.gesellschafter, { ...gs, id, anteil_nr: nextNr }] }
})
}, [])
const updateGesellschafter = useCallback((id: string, patch: Partial<Gesellschafter>) => {
setState(prev => ({
...prev,
gesellschafter: prev.gesellschafter.map(g => g.id === id ? { ...g, ...patch } : g),
}))
}, [])
const removeGesellschafter = useCallback((id: string) => {
setState(prev => ({
...prev,
gesellschafter: prev.gesellschafter.filter(g => g.id !== id),
gf_contracts: prev.gf_contracts.filter(c => c.gesellschafter_id !== id),
}))
}, [])
// GF Contract helpers
const upsertGFContract = useCallback((contract: GFContract) => {
setState(prev => {
const idx = prev.gf_contracts.findIndex(c => c.gesellschafter_id === contract.gesellschafter_id)
const next = [...prev.gf_contracts]
if (idx >= 0) next[idx] = contract
else next.push(contract)
return { ...prev, gf_contracts: next }
})
}, [])
// Validation (canProceed for current step)
const canProceed = useMemo(() => {
switch (state.current_step) {
case 1:
return state.basics.company_name.trim().length > 1 &&
state.basics.company_seat.trim().length > 1 &&
state.basics.company_purpose_description.trim().length > 10
case 2: {
if (state.gesellschafter.length < 1) return false
const sum = state.gesellschafter.reduce((s, g) => s + (g.nennbetrag_eur || 0), 0)
return sum === state.capital.stammkapital_eur
}
case 3:
return state.gesellschafter.some(g => g.is_geschaeftsfuehrer)
case 4:
return state.capital.stammkapital_eur >= 25000
case 5:
return state.notar.notary_name.trim().length > 1 && state.notar.notary_place.trim().length > 1
case 6:
return true
case 7:
return state.gesellschafter.filter(g => g.is_geschaeftsfuehrer)
.every(g => state.gf_contracts.some(c => c.gesellschafter_id === g.id))
case 8:
return state.selected_documents.length > 0
default:
return false
}
}, [state])
const generateDocuments = useCallback(async (): Promise<GeneratedDocument[]> => {
setGenerating(true)
setError(null)
try {
const response = await fetch('/api/v1/founding-wizard/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(state),
})
if (!response.ok) {
throw new Error(`Generierung fehlgeschlagen: ${response.status}`)
}
const data = await response.json()
const docs: GeneratedDocument[] = data.documents || []
setState(prev => ({ ...prev, generated_documents: docs }))
return docs
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Unbekannter Fehler'
setError(msg)
throw e
} finally {
setGenerating(false)
}
}, [state])
// Derived: hat zugehöriger GF einen Vertrag?
const gf_list = useMemo(
() => state.gesellschafter.filter(g => g.is_geschaeftsfuehrer),
[state.gesellschafter]
)
return {
state, hydrated, generating, error,
update, setStep, nextStep, prevStep, reset,
addGesellschafter, updateGesellschafter, removeGesellschafter,
upsertGFContract,
canProceed, generateDocuments,
gf_list,
steps: FOUNDING_WIZARD_STEPS,
}
}
@@ -0,0 +1,141 @@
'use client'
import React from 'react'
import { useFoundingWizardForm } from './_hooks/useFoundingWizardForm'
import { StepBasics } from './_components/StepBasics'
import { StepGesellschafter } from './_components/StepGesellschafter'
import { StepCapital, StepGFAssignment, StepGFContracts, StepNotar, StepSHAConfig } from './_components/StepsSimpleConfig'
import { StepGenerate } from './_components/StepGenerate'
export default function FoundingWizardPage() {
const {
state, hydrated, generating, error,
update, nextStep, prevStep, reset,
addGesellschafter, updateGesellschafter, removeGesellschafter,
upsertGFContract,
canProceed, generateDocuments,
gf_list, steps,
} = useFoundingWizardForm()
if (!hydrated) return null
const isLastStep = state.current_step === steps.length
return (
<div className="min-h-screen bg-gray-50 py-8" data-testid="founding-wizard">
<div className="max-w-5xl mx-auto px-4">
{/* Header */}
<div className="mb-8 flex justify-between items-start">
<div>
<h1 className="text-3xl font-bold text-gray-900">Gründungs-Wizard</h1>
<p className="text-gray-600 mt-2">
Erstellt alle Notartermin-Dokumente für Deine GmbH/UG-Gründung in 8 Schritten.
</p>
</div>
<button
data-testid="reset-wizard"
onClick={() => { if (confirm('Wizard-Daten zurücksetzen?')) reset() }}
className="text-sm text-gray-500 hover:text-red-600"
>
Zurücksetzen
</button>
</div>
{/* Progress Steps */}
<div className="mb-8" data-testid="wizard-progress">
<div className="flex items-center justify-between">
{steps.map((step, idx) => (
<React.Fragment key={step.id}>
<button
type="button"
onClick={() => state.current_step > step.id && update('current_step', step.id)}
className="flex items-center"
data-testid={`step-indicator-${step.id}`}
>
<div className={`w-9 h-9 rounded-full flex items-center justify-center text-sm font-medium ${
step.id < state.current_step ? 'bg-purple-600 text-white' :
step.id === state.current_step ? 'bg-purple-100 text-purple-600 border-2 border-purple-600' :
'bg-gray-100 text-gray-400'
}`}>
{step.id < state.current_step ? '✓' : step.id}
</div>
<div className="ml-2 hidden md:block text-left">
<div className={`text-xs font-medium ${step.id <= state.current_step ? 'text-gray-900' : 'text-gray-400'}`}>
{step.name}
</div>
</div>
</button>
{idx < steps.length - 1 && (
<div className={`flex-1 h-0.5 mx-2 ${step.id < state.current_step ? 'bg-purple-600' : 'bg-gray-200'}`} />
)}
</React.Fragment>
))}
</div>
</div>
{/* Step Content */}
<div className="bg-white rounded-xl border border-gray-200 p-8">
<div className="mb-6">
<h2 className="text-xl font-semibold text-gray-900">
{steps[state.current_step - 1]?.name}
</h2>
<p className="text-gray-500 text-sm">{steps[state.current_step - 1]?.description}</p>
</div>
<div data-testid={`step-content-${state.current_step}`}>
{state.current_step === 1 && <StepBasics state={state} update={update} />}
{state.current_step === 2 && (
<StepGesellschafter
state={state}
addGesellschafter={addGesellschafter}
updateGesellschafter={updateGesellschafter}
removeGesellschafter={removeGesellschafter}
/>
)}
{state.current_step === 3 && <StepGFAssignment state={state} update={update} />}
{state.current_step === 4 && <StepCapital state={state} update={update} />}
{state.current_step === 5 && <StepNotar state={state} update={update} />}
{state.current_step === 6 && <StepSHAConfig state={state} update={update} />}
{state.current_step === 7 && (
<StepGFContracts state={state} update={update} gf_list={gf_list} upsertGFContract={upsertGFContract} />
)}
{state.current_step === 8 && (
<StepGenerate
state={state}
update={update}
generating={generating}
error={error}
onGenerate={generateDocuments}
/>
)}
</div>
{/* Navigation */}
{!isLastStep && (
<div className="flex justify-between items-center mt-8 pt-6 border-t border-gray-200">
<button
data-testid="prev-step"
onClick={prevStep}
disabled={state.current_step === 1}
className="px-6 py-3 text-gray-600 hover:text-gray-900 disabled:opacity-50"
>
Zurück
</button>
<span className="text-xs text-gray-400">
Schritt {state.current_step} von {steps.length}
</span>
<button
data-testid="next-step"
onClick={nextStep}
disabled={!canProceed}
className="px-8 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
Weiter
</button>
</div>
)}
</div>
</div>
</div>
)
}
+160
View File
@@ -0,0 +1,160 @@
'use client'
import { useEffect, useState } from 'react'
// Stufe 1 of the Attribution Renderer (Task #23): the global
// "Quellen & Lizenzen" overview. Aggregates all 314k canonical_controls
// by their license_rule and shows the source regulations behind each
// bucket. Drives the footer link and gives auditors a one-page view of
// what licence classes the platform is operating under.
type SourceCount = {
regulation_id: string
regulation_name_de: string | null
license_rule: number
license_type: string | null
attribution: string | null
jurisdiction: string | null
source_type: string | null
n_controls: number
}
type RuleBucket = {
rule: number
label_de: string
label_en: string
attribution_required: boolean
render_full_text: boolean
total_controls: number
distinct_sources: number
sources: SourceCount[]
}
type Overview = {
total_controls: number
buckets: RuleBucket[]
}
const RULE_COLOR: Record<number, string> = {
1: 'border-emerald-200 bg-emerald-50',
2: 'border-amber-200 bg-amber-50',
3: 'border-slate-200 bg-slate-50',
}
const RULE_BADGE: Record<number, string> = {
1: 'bg-emerald-600 text-white',
2: 'bg-amber-600 text-white',
3: 'bg-slate-600 text-white',
}
export default function LicensesPage() {
const [data, setData] = useState<Overview | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
fetch('/api/sdk/v1/compliance/licenses/overview')
.then((r) => (r.ok ? r.json() : Promise.reject(`HTTP ${r.status}`)))
.then(setData)
.catch((e) => setError(String(e)))
}, [])
if (error) {
return (
<div className="p-6">
<h1 className="text-xl font-semibold mb-2">Quellen &amp; Lizenzen</h1>
<p className="text-red-600">Fehler beim Laden: {error}</p>
</div>
)
}
if (!data) {
return (
<div className="p-6">
<h1 className="text-xl font-semibold">Quellen &amp; Lizenzen</h1>
<p className="text-slate-500 mt-2">Lade </p>
</div>
)
}
return (
<div className="p-6 max-w-7xl">
<header className="mb-6">
<h1 className="text-2xl font-semibold">Quellen &amp; Lizenzen</h1>
<p className="text-sm text-slate-600 mt-1">
Diese Plattform stützt sich auf {data.total_controls.toLocaleString('de-DE')}{' '}
klassifizierte Compliance-Controls aus den unten genannten Quellen.
Jeder Control trägt eine deterministische Lizenzregel (R1R3), die das
Render-Verhalten in Berichten und im Frontend steuert.
</p>
</header>
<section className="mb-8">
<h2 className="text-lg font-medium mb-3">Klassifizierungs-Schema</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 text-sm">
{data.buckets.map((b) => (
<div key={b.rule} className={`rounded border ${RULE_COLOR[b.rule] ?? 'border-slate-200'} p-3`}>
<div className="flex items-center gap-2 mb-2">
<span className={`inline-flex items-center justify-center w-7 h-7 rounded-full text-xs font-bold ${RULE_BADGE[b.rule] ?? 'bg-slate-600 text-white'}`}>
R{b.rule}
</span>
<span className="font-medium">{b.label_de}</span>
</div>
<ul className="text-xs text-slate-700 space-y-1">
<li>{b.total_controls.toLocaleString('de-DE')} Controls</li>
<li>{b.distinct_sources} Quellen</li>
<li>{b.render_full_text ? 'Volltext-Anzeige erlaubt' : 'Nur Identifier-Verweis'}</li>
<li>{b.attribution_required ? 'Attribution-Pflicht in Output' : 'keine Attribution-Pflicht'}</li>
</ul>
</div>
))}
</div>
</section>
{data.buckets.map((b) => (
<section key={b.rule} className="mb-8">
<h2 className="text-lg font-medium mb-3 flex items-center gap-2">
<span className={`inline-flex items-center justify-center w-7 h-7 rounded-full text-xs font-bold ${RULE_BADGE[b.rule] ?? 'bg-slate-600 text-white'}`}>
R{b.rule}
</span>
{b.label_de}{' '}
<span className="text-sm text-slate-500 font-normal">
({b.total_controls.toLocaleString('de-DE')} Controls aus {b.distinct_sources} Quellen)
</span>
</h2>
<div className="overflow-x-auto border rounded">
<table className="w-full text-sm">
<thead className="bg-slate-100 text-slate-700">
<tr>
<th className="text-left p-2">Quelle</th>
<th className="text-left p-2">Lizenztyp</th>
<th className="text-left p-2">Rechtsraum</th>
<th className="text-left p-2">Attribution</th>
<th className="text-right p-2">Controls</th>
</tr>
</thead>
<tbody>
{b.sources.map((s) => (
<tr key={`${b.rule}-${s.regulation_id}`} className="border-t">
<td className="p-2">{s.regulation_name_de ?? s.regulation_id}</td>
<td className="p-2 text-slate-600">{s.license_type ?? '—'}</td>
<td className="p-2 text-slate-600">{s.jurisdiction ?? '—'}</td>
<td className="p-2 text-slate-600">{s.attribution ?? '—'}</td>
<td className="p-2 text-right tabular-nums">{s.n_controls.toLocaleString('de-DE')}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
))}
<footer className="text-xs text-slate-500 border-t pt-4 mt-8">
Klassifizierung: deterministisch über parent_control_uuid-Vererbung,
control_parent_links regulation_registry, source_citation,
canonical_processed_chunks (Pipeline-Ground-Truth) und LLM-Aggregat-
Identifikation für eigene Werke. Audit-Skripte unter
breakpilot-core/control-pipeline/scripts/.
</footer>
</div>
)
}
@@ -0,0 +1,138 @@
'use client'
import { useEffect, useState } from 'react'
// Stufe 3 of the Attribution Renderer (Task #23): an inline source
// badge that any rendered control/hazard/measure can attach to itself.
//
// Visually a small license-rule pill (R1/R2/R3); on hover/click it
// reveals the underlying regulation, license type, and — for Rule 2 —
// the mandatory attribution string.
//
// Usage:
// <SourceBadge controlUuid={hazard.id} />
//
// The component lazily fetches /licenses/source-info/{uuid} on first
// expand so the surrounding list view stays cheap.
type SourceInfo = {
control_uuid: string
license_rule: number | null
license_label_de: string | null
attribution_required: boolean
render_full_text: boolean
regulation_id: string | null
regulation_name_de: string | null
license_type: string | null
attribution: string | null
source_url: string | null
}
const RULE_BADGE: Record<number, string> = {
1: 'bg-emerald-100 text-emerald-800 border-emerald-300',
2: 'bg-amber-100 text-amber-800 border-amber-300',
3: 'bg-slate-100 text-slate-700 border-slate-300',
}
const RULE_TITLE: Record<number, string> = {
1: 'R1 — wörtlich übernehmbar',
2: 'R2 — wörtlich mit Attribution',
3: 'R3 — nur Identifier zitieren',
}
interface SourceBadgeProps {
controlUuid: string
/** Optional: skip the fetch and render from already-known data. */
prefetched?: SourceInfo
/** Compact mode for tight UI rows (smaller pill). */
compact?: boolean
}
export function SourceBadge({ controlUuid, prefetched, compact }: SourceBadgeProps) {
const [data, setData] = useState<SourceInfo | null>(prefetched ?? null)
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!open || data) return
setLoading(true)
fetch(`/api/sdk/v1/compliance/licenses/source-info/${controlUuid}`)
.then((r) => (r.ok ? r.json() : Promise.reject(`HTTP ${r.status}`)))
.then(setData)
.catch((e) => setError(String(e)))
.finally(() => setLoading(false))
}, [open, data, controlUuid])
const rule = data?.license_rule ?? prefetched?.license_rule ?? null
const badgeClass = rule ? RULE_BADGE[rule] ?? RULE_BADGE[3] : 'bg-slate-100 text-slate-500 border-slate-200'
const sizeClass = compact ? 'text-[10px] px-1.5 py-0.5' : 'text-xs px-2 py-0.5'
return (
<span className="relative inline-block">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className={`inline-flex items-center gap-1 rounded border font-medium ${sizeClass} ${badgeClass} hover:opacity-80 transition`}
title={rule ? RULE_TITLE[rule] : 'Lizenz unbekannt'}
aria-expanded={open}
>
<svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor" aria-hidden>
<path d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0Zm0 4.5a1 1 0 1 1 0 2 1 1 0 0 1 0-2ZM7 8h2v4.5H7V8Z" />
</svg>
{rule ? `R${rule}` : '?'}
</button>
{open && (
<div className="absolute left-0 mt-1 z-40 w-80 rounded-md border border-slate-200 bg-white shadow-lg p-3 text-xs">
{loading && <p className="text-slate-500">Lade Quellen-Info</p>}
{error && <p className="text-red-600">Fehler: {error}</p>}
{data && (
<div className="space-y-2">
<div className="font-semibold text-slate-800">
{data.license_label_de ?? 'Lizenz unbekannt'}
</div>
{data.regulation_name_de && (
<div>
<span className="text-slate-500">Quelle:</span>{' '}
<span className="text-slate-800">{data.regulation_name_de}</span>
</div>
)}
{data.license_type && (
<div>
<span className="text-slate-500">Lizenztyp:</span>{' '}
<span className="text-slate-700">{data.license_type}</span>
</div>
)}
{data.attribution && (
<div className="rounded bg-amber-50 border border-amber-200 px-2 py-1.5">
<div className="text-[10px] font-semibold text-amber-800 uppercase tracking-wide">
Attribution-Pflicht
</div>
<div className="text-amber-900">{data.attribution}</div>
</div>
)}
{!data.render_full_text && (
<div className="text-[10px] text-slate-500 italic">
Volltext wird im Output nicht gerendert nur Identifier-Verweis.
</div>
)}
{data.source_url && (
<a
href={data.source_url}
target="_blank"
rel="noopener noreferrer"
className="inline-block text-[10px] text-blue-600 hover:underline mt-1"
>
Originalquelle öffnen
</a>
)}
</div>
)}
</div>
)}
</span>
)
}
export default SourceBadge
@@ -0,0 +1,355 @@
/**
* E2E-Test fuer den Founding-Wizard
*
* Prueft den vollstaendigen 8-Step-Flow:
* - Application-Errors / Console-Errors auf jeder Seite
* - StepBasics: Prefill-Button + Registergericht/HRB-Felder
* - StepGesellschafter: Rollen-Dropdown + IP-Bereiche fuer 2 Gruender
* - Per-Person Generation: 2 IP-Assignment-Dokumente
* - localStorage-Persistenz
*
* Backend wird per route.fulfill() gemockt — Test ist hermetisch.
*/
import { test, expect, type Page, type ConsoleMessage } from '@playwright/test'
const BASE = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3002'
const WIZARD_PATH = '/sdk/founding-wizard'
/** Filtert Browser-Console auf echte App-Errors (ignoriert Next.js / Hydration / 3rd-party Warnings). */
function isRealAppError(msg: ConsoleMessage): boolean {
if (msg.type() !== 'error') return false
const text = msg.text()
// Bekanntes Rauschen ausschliessen
const ignored = [
'Failed to load resource', // 404 fuer Icons etc.
'Download the React DevTools', // React-Hinweis
'net::ERR_', // Netzwerk (gemockt → erwartete Misses)
'Hydration failed because', // Next 15 Pseudo-Errors bei dev
'[founding-wizard] prefill failed', // Intentional UX-Logging im Prefill-Fehlerpfad
]
return !ignored.some(p => text.includes(p))
}
const IGNORED_PAGE_ERRORS = [
// Hydration mismatches durch dynamische Zeitstempel ("Gerade eben" vs "vor 1 Min")
// im SDK-Header — pure dev-Mode-Symptom, kein App-Bug.
'Hydration failed because the server rendered text didn',
'There was an error while hydrating',
// Next.js dev-mode signals fuer Hydration-Issues
'Text content does not match server-rendered HTML',
]
function isIgnoredPageError(err: Error): boolean {
return IGNORED_PAGE_ERRORS.some(p => err.message.includes(p))
}
/** Setzt Console-Error- und PageError-Listener. Wirft am Ende, wenn welche aufgetreten sind. */
function installErrorTraps(page: Page): { assertNoErrors: () => void } {
const consoleErrors: string[] = []
const pageErrors: string[] = []
page.on('console', msg => {
if (isRealAppError(msg)) consoleErrors.push(msg.text())
})
page.on('pageerror', err => {
if (!isIgnoredPageError(err)) pageErrors.push(`${err.name}: ${err.message}`)
})
return {
assertNoErrors() {
const all = [...pageErrors.map(e => `[pageerror] ${e}`), ...consoleErrors.map(e => `[console.error] ${e}`)]
if (all.length > 0) {
throw new Error(`Application-Errors waehrend des Flows:\n${all.join('\n')}`)
}
},
}
}
/** Mockt die zwei API-Endpoints, die der Wizard aufruft. */
async function mockBackend(page: Page) {
// 1) Company-Profile Prefill
await page.route('**/api/sdk/v1/company-profile**', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
profile: {
companyName: 'Breakpilot GmbH',
legalForm: 'GmbH',
industry: ['Software', 'KI/ML'],
businessModel: 'SaaS',
offerings: ['SaaS-Plattform', 'Compliance-API'],
headquartersStreet: 'Königstraße 1',
headquartersZip: '70173',
headquartersCity: 'Stuttgart',
},
}),
})
})
// 2) Founding-Wizard Generate (gibt 9 Dokumente zurueck: 7 normale + 2 per-person IP-Assignments)
await page.route('**/api/v1/founding-wizard/generate', async route => {
const request = route.request()
const body = JSON.parse(request.postData() || '{}')
const selected: string[] = body.selected_documents || []
const gesellschafter: Array<{ name?: string; is_geschaeftsfuehrer?: boolean }> = body.gesellschafter || []
const PER_PERSON = ['ip_assignment_agreement', 'managing_director_employment_contract']
const docs: unknown[] = []
const tinyDocx = 'UEsDBBQAAAAIAA==' // gueltige base64-Stub (Playwright braucht keinen echten DOCX)
for (const docType of selected) {
if (PER_PERSON.includes(docType)) {
const persons = docType === 'managing_director_employment_contract'
? gesellschafter.filter(g => g.is_geschaeftsfuehrer)
: gesellschafter
for (const p of persons) {
docs.push({
document_type: docType,
title: `${docType}${p.name}`,
filename: `${docType}_${(p.name || 'X').replace(/\s/g, '_')}.docx`,
download_url: `data:application/vnd.openxmlformats-officedocument.wordprocessingml.document;base64,${tinyDocx}`,
size_bytes: 12345,
generated_at: '2026-05-21T12:00:00Z',
})
}
} else {
docs.push({
document_type: docType,
title: docType,
filename: `${docType}.docx`,
download_url: `data:application/vnd.openxmlformats-officedocument.wordprocessingml.document;base64,${tinyDocx}`,
size_bytes: 12345,
generated_at: '2026-05-21T12:00:00Z',
})
}
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ documents: docs, warnings: [] }),
})
})
}
/** Clears wizard-state and pre-accepts cookies so the CookieBannerOverlay
* does not intercept clicks during the test. */
async function resetWizardState(page: Page) {
await page.addInitScript(() => {
try {
window.localStorage.removeItem('breakpilot:founding-wizard:state:v1')
// CookieBannerOverlay liest 'bp-sdk-cookie-consent' und blendet sich aus,
// sobald ein Eintrag existiert. Wir setzen Minimal-Consent.
window.localStorage.setItem('bp-sdk-cookie-consent', JSON.stringify({
necessary: true, statistics: false, marketing: false, functional: false,
ewrOnly: false, blockedVendors: [], timestamp: new Date().toISOString(),
}))
} catch {}
})
}
test.describe('Founding-Wizard E2E', () => {
test.beforeEach(async ({ page }) => {
await resetWizardState(page)
await mockBackend(page)
})
test('vollstaendiger 8-Step-Flow ohne Application-Errors', async ({ page }) => {
const errors = installErrorTraps(page)
await page.goto(`${BASE}${WIZARD_PATH}`)
await expect(page.getByTestId('founding-wizard')).toBeVisible()
await expect(page.getByTestId('step-content-1')).toBeVisible()
// --- Step 1: Basics + Prefill ---
await page.getByRole('button', { name: /Aus Unternehmensprofil vorbef/i }).click()
await expect(page.getByTestId('company-name')).toHaveValue('Breakpilot GmbH', { timeout: 5000 })
await expect(page.getByTestId('company-seat')).toHaveValue('Stuttgart')
// Pflichtfeld: company_purpose_description (mind. 10 Zeichen)
await page.getByTestId('company-purpose').fill(
'die Entwicklung, Bereitstellung und der Betrieb von KI-gestuetzten Compliance-Werkzeugen sowie damit verbundener Beratungsleistungen.'
)
// Neue Felder: Registergericht + HRB
await page.getByTestId('register-court').fill('Amtsgericht Stuttgart')
await page.getByTestId('hrb-number').fill('') // noch nicht eingetragen
await page.getByTestId('next-step').click()
// --- Step 2: Gesellschafter ---
await expect(page.getByTestId('step-content-2')).toBeVisible()
// Benjamin (CEO, IP: Compliance + RAG)
await page.getByTestId('gs-name').fill('Benjamin Bönisch')
await page.getByTestId('gs-birthdate').fill('1985-01-15')
await page.getByTestId('gs-address').fill('Teststraße 1, 70173 Stuttgart')
await page.getByTestId('gs-email').fill('benjamin@breakpilot.ai')
await page.getByTestId('gs-nennbetrag').fill('12500')
await page.getByTestId('gs-role').selectOption('CEO')
await page.getByTestId('gs-ip-areas').fill(
'Compliance-Engine (Quellcode + Architektur)\nRAG-Pipeline\nProdukt-Konzepte'
)
await page.getByTestId('add-gesellschafter').click()
await expect(page.getByTestId('gs-row-1')).toBeVisible()
// Sharang (CTO, IP: Security + Infrastruktur)
await page.getByTestId('gs-name').fill('Sharang Parnerkar')
await page.getByTestId('gs-birthdate').fill('1990-06-20')
await page.getByTestId('gs-address').fill('Teststraße 2, 70173 Stuttgart')
await page.getByTestId('gs-email').fill('sharang@breakpilot.ai')
await page.getByTestId('gs-nennbetrag').fill('12500')
await page.getByTestId('gs-role').selectOption('CTO')
await page.getByTestId('gs-ip-areas').fill('Security-Modul\nInfrastructure-as-Code')
await page.getByTestId('add-gesellschafter').click()
await expect(page.getByTestId('gs-row-2')).toBeVisible()
// Summe Nennbetraege muss Stammkapital entsprechen (25.000)
await expect(page.getByTestId('gs-total')).toContainText('25.000')
await page.getByTestId('next-step').click()
// --- Step 3: GF-Assignment (Defaults sind ok, beide bereits GF) ---
await expect(page.getByTestId('step-content-3')).toBeVisible()
await expect(page.getByTestId('gf-assignment-table')).toBeVisible()
await page.getByTestId('next-step').click()
// --- Step 4: Kapital (Defaults: 25000) ---
await expect(page.getByTestId('step-content-4')).toBeVisible()
await expect(page.getByTestId('stammkapital')).toHaveValue('25000')
await page.getByTestId('next-step').click()
// --- Step 5: Notar ---
await expect(page.getByTestId('step-content-5')).toBeVisible()
await page.getByTestId('notary-name').fill('Dr. Max Mustermann')
await page.getByTestId('notary-place').fill('Stuttgart')
await page.getByTestId('notary-address').fill('Königstraße 99, 70173 Stuttgart')
await page.getByTestId('notarial-date').fill('2026-06-15')
await page.getByTestId('next-step').click()
// --- Step 6: SHA-Optionen (Defaults sind ok) ---
await expect(page.getByTestId('step-content-6')).toBeVisible()
await expect(page.getByTestId('has-sha')).toBeChecked()
await page.getByTestId('next-step').click()
// --- Step 7: GF-Vertraege (fuer jeden GF einen) ---
await expect(page.getByTestId('step-content-7')).toBeVisible()
// Beide GF-Contract-Karten muessen sichtbar sein
const contractCards = page.locator('[data-testid^="contract-"]')
await expect(contractCards).toHaveCount(2)
// Salary in beiden Cards anfassen → registriert Contracts (canProceed-Bedingung).
// Wir setzen einen anderen Wert als Default (84000) damit React onChange feuert.
const salaryInputs = page.locator('[data-testid^="salary-"]')
const salaryCount = await salaryInputs.count()
for (let i = 0; i < salaryCount; i++) {
await salaryInputs.nth(i).fill('90000')
}
// Warten bis "Weiter" enabled ist
await expect(page.getByTestId('next-step')).toBeEnabled()
await page.getByTestId('next-step').click()
// --- Step 8: Generate ---
await expect(page.getByTestId('step-content-8')).toBeVisible()
await expect(page.getByTestId('generate-summary')).toContainText('Breakpilot GmbH')
await expect(page.getByTestId('generate-summary')).toContainText('2', { useInnerText: true })
// Notartermin-Bundle auswaehlen
await page.getByTestId('select-notary-bundle').click()
// Generieren (Backend gemockt)
await page.getByTestId('generate-docs').click()
// Generated-Docs-Block muss erscheinen
await expect(page.getByTestId('generated-docs')).toBeVisible({ timeout: 10000 })
// Per-Person Verifikation: zwei IP-Assignment-Downloads erwartet
const ipDownloads = page.locator('[data-testid="download-ip_assignment_agreement"]')
await expect(ipDownloads).toHaveCount(2)
// Per-Person Verifikation: zwei GF-Vertraege erwartet
const gfDownloads = page.locator('[data-testid="download-managing_director_employment_contract"]')
await expect(gfDownloads).toHaveCount(2)
// Kein generate-error sichtbar
await expect(page.getByTestId('generate-error')).toBeHidden()
// Final: keine Errors auf der Konsole
errors.assertNoErrors()
})
test('Prefill-Button setzt Fehler bei Backend-Fehler ohne Application-Error', async ({ page }) => {
// Spezial-Mock: company-profile gibt 500 zurueck
await page.route('**/api/sdk/v1/company-profile**', async route => {
await route.fulfill({ status: 500, body: 'boom' })
})
const errors = installErrorTraps(page)
await page.goto(`${BASE}${WIZARD_PATH}`)
await page.getByRole('button', { name: /Aus Unternehmensprofil vorbef/i }).click()
// UI muss Fehlermeldung anzeigen, NICHT crashen
await expect(page.getByText('Konnte Unternehmensprofil nicht laden')).toBeVisible()
errors.assertNoErrors()
})
test('Step-Navigation: Zurueck und Reset funktionieren ohne Errors', async ({ page }) => {
const errors = installErrorTraps(page)
await page.goto(`${BASE}${WIZARD_PATH}`)
// Minimum Step 1 fuellen
await page.getByTestId('company-name').fill('Breakpilot GmbH')
await page.getByTestId('company-seat').fill('Stuttgart')
await page.getByTestId('company-purpose').fill('die Entwicklung von Compliance-Software fuer Unternehmen.')
await page.getByTestId('next-step').click()
await expect(page.getByTestId('step-content-2')).toBeVisible()
// Zurueck
await page.getByTestId('prev-step').click()
await expect(page.getByTestId('step-content-1')).toBeVisible()
// Eingaben muessen erhalten geblieben sein (localStorage-persistence)
await expect(page.getByTestId('company-name')).toHaveValue('Breakpilot GmbH')
// Reset (mit Dialog-Bestaetigung)
page.once('dialog', dialog => dialog.accept())
await page.getByTestId('reset-wizard').click()
await expect(page.getByTestId('company-name')).toHaveValue('')
errors.assertNoErrors()
})
test('IP-Areas + Rollen-Dropdown in Step 2', async ({ page }) => {
const errors = installErrorTraps(page)
await page.goto(`${BASE}${WIZARD_PATH}`)
// Step 1 zuegig fuellen
await page.getByTestId('company-name').fill('Breakpilot GmbH')
await page.getByTestId('company-seat').fill('Stuttgart')
await page.getByTestId('company-purpose').fill('die Entwicklung von Compliance-Software fuer Unternehmen.')
await page.getByTestId('next-step').click()
// Rollen-Dropdown muss ein <select> sein, nicht <input>
const role = page.getByTestId('gs-role')
await expect(role).toHaveJSProperty('tagName', 'SELECT')
// CEO-Option waehlbar
await page.getByTestId('gs-name').fill('Benjamin Bönisch')
await page.getByTestId('gs-address').fill('Test 1')
await page.getByTestId('gs-nennbetrag').fill('25000')
await role.selectOption('CEO')
await page.getByTestId('gs-ip-areas').fill('Compliance-Engine\nRAG-Pipeline')
await page.getByTestId('add-gesellschafter').click()
// Tabelle muss IP-Bereiche anzeigen
const row = page.getByTestId('gs-row-1')
await expect(row).toContainText('Benjamin Bönisch')
await expect(row).toContainText('CEO')
await expect(row).toContainText('Compliance-Engine')
errors.assertNoErrors()
})
})
@@ -0,0 +1,123 @@
/**
* Template-Kategorisierung als Code-Registry.
*
* Source-of-Truth bei aktiver Migration 137/138 ist die DB.
* Diese Registry dient als Fallback und für Frontend-only Filter,
* wenn DB-Felder noch nicht verfügbar sind (z.B. lokale Dev-DB ohne Migration).
*
* Synchron halten mit migrations/138_template_backfill_categories.sql.
*/
export type LifecycleStage = 'pre_founding' | 'founding' | 'startup' | 'kmu' | 'konzern'
export type FunctionalCategory =
| 'founding_legal'
| 'employment'
| 'investor_funding'
| 'customer_b2b'
| 'customer_b2c'
| 'data_protection'
| 'it_security'
| 'ai_governance'
| 'internal_policy'
| 'public_facing'
| 'compliance_process'
| 'finance_tax'
| 'vendor_supplier'
export interface TemplateCategorization {
lifecycle_stage: LifecycleStage[]
functional_category: FunctionalCategory
}
export const TEMPLATE_CATEGORIES: Record<string, TemplateCategorization> = {
// Founding Legal
gesellschafterliste: { lifecycle_stage: ['pre_founding', 'founding'], functional_category: 'founding_legal' },
gf_bestellungsbeschluss: { lifecycle_stage: ['founding'], functional_category: 'founding_legal' },
hrb_anmeldung: { lifecycle_stage: ['founding'], functional_category: 'founding_legal' },
ip_assignment_agreement: { lifecycle_stage: ['pre_founding', 'founding', 'startup'], functional_category: 'founding_legal' },
articles_of_association: { lifecycle_stage: ['founding', 'startup', 'kmu', 'konzern'], functional_category: 'founding_legal' },
sha: { lifecycle_stage: ['founding', 'startup', 'kmu', 'konzern'], functional_category: 'founding_legal' },
geschaeftsordnung_gf: { lifecycle_stage: ['founding', 'startup', 'kmu', 'konzern'], functional_category: 'founding_legal' },
// Investor / Funding
term_sheet: { lifecycle_stage: ['pre_founding', 'startup'], functional_category: 'investor_funding' },
convertible_loan_agreement: { lifecycle_stage: ['pre_founding', 'startup'], functional_category: 'investor_funding' },
subscription_agreement: { lifecycle_stage: ['startup', 'kmu'], functional_category: 'investor_funding' },
esop_plan: { lifecycle_stage: ['startup', 'kmu'], functional_category: 'investor_funding' },
cap_table: { lifecycle_stage: ['founding', 'startup', 'kmu', 'konzern'], functional_category: 'investor_funding' },
// Employment
managing_director_employment_contract: { lifecycle_stage: ['founding', 'startup', 'kmu', 'konzern'], functional_category: 'employment' },
employment_contract_de: { lifecycle_stage: ['founding', 'startup', 'kmu', 'konzern'], functional_category: 'employment' },
nda: { lifecycle_stage: ['founding', 'startup', 'kmu', 'konzern'], functional_category: 'employment' },
offboarding_policy: { lifecycle_stage: ['founding', 'startup', 'kmu', 'konzern'], functional_category: 'employment' },
// Customer B2B
agb: { lifecycle_stage: ['startup', 'kmu', 'konzern'], functional_category: 'customer_b2b' },
sla: { lifecycle_stage: ['startup', 'kmu', 'konzern'], functional_category: 'customer_b2b' },
dpa: { lifecycle_stage: ['startup', 'kmu', 'konzern'], functional_category: 'customer_b2b' },
data_processing_agreement: { lifecycle_stage: ['startup', 'kmu', 'konzern'], functional_category: 'customer_b2b' },
cloud_service_agreement: { lifecycle_stage: ['startup', 'kmu', 'konzern'], functional_category: 'customer_b2b' },
terms_of_service: { lifecycle_stage: ['startup', 'kmu', 'konzern'], functional_category: 'customer_b2b' },
// Public-facing
impressum: { lifecycle_stage: ['founding', 'startup', 'kmu', 'konzern'], functional_category: 'public_facing' },
// AI Governance
ai_usage_policy: { lifecycle_stage: ['startup', 'kmu', 'konzern'], functional_category: 'ai_governance' },
// Whistleblower nur ab KMU (>=50 MA)
whistleblower_policy: { lifecycle_stage: ['kmu', 'konzern'], functional_category: 'internal_policy' },
}
/**
* Notartermin-Bundle: alle Dokumente die für die Gründung benötigt werden.
* Investor-Dokumente sind separat (term_sheet, convertible_loan_agreement, etc.).
*/
export const NOTARY_BUNDLE_DOCUMENTS: string[] = [
'articles_of_association', // Satzung — notariell beurkundet
'gesellschafterliste', // Pflicht § 40 GmbHG
'gf_bestellungsbeschluss', // Bestellung Geschäftsführer
'hrb_anmeldung', // HRB-Anmeldung
'sha', // optional parallel
'geschaeftsordnung_gf', // intern, nach Notar
'managing_director_employment_contract', // GF-Dienstverträge
'ip_assignment_agreement', // Gründer-IP sichern
]
export function getDocumentsForStage(stage: LifecycleStage): string[] {
return Object.entries(TEMPLATE_CATEGORIES)
.filter(([, cat]) => cat.lifecycle_stage.includes(stage))
.map(([docType]) => docType)
}
export function getDocumentsForCategory(category: FunctionalCategory): string[] {
return Object.entries(TEMPLATE_CATEGORIES)
.filter(([, cat]) => cat.functional_category === category)
.map(([docType]) => docType)
}
export const LIFECYCLE_STAGE_LABELS: Record<LifecycleStage, string> = {
pre_founding: 'Vor-Gründung (Term Sheet, IP-Sicherung)',
founding: 'Gründung (Notar)',
startup: 'Startup (0-3 Jahre, <25 MA)',
kmu: 'KMU (3+ Jahre, 25-250 MA)',
konzern: 'Konzern (250+ MA)',
}
export const FUNCTIONAL_CATEGORY_LABELS: Record<FunctionalCategory, string> = {
founding_legal: 'Gründungsrechtliches',
employment: 'Arbeitsverträge',
investor_funding: 'Investor & Funding',
customer_b2b: 'Kunden-Verträge (B2B)',
customer_b2c: 'Kunden-Verträge (B2C)',
data_protection: 'Datenschutz (DSGVO)',
it_security: 'IT-Sicherheit',
ai_governance: 'KI-Governance',
internal_policy: 'Interne Richtlinien',
public_facing: 'Öffentlich (Website)',
compliance_process:'Compliance-Prozesse',
finance_tax: 'Finanzen & Steuern',
vendor_supplier: 'Lieferanten',
}
+192
View File
@@ -0,0 +1,192 @@
/**
* TypeScript-Datentypen für den Founding-Wizard.
*
* Die Wizard-Eingaben werden in localStorage gespeichert und beim Submit
* an die document-generator API geschickt zur Template-Befüllung.
*/
import type { LifecycleStage } from './template-categories'
export interface Gesellschafter {
id: string
rolle: 'founder' | 'investor' | 'family' | 'other'
name: string
geburtsdatum?: string // YYYY-MM-DD
adresse: string
email?: string
/** Nennbetrag in EUR, z.B. 25000 */
nennbetrag_eur: number
/** Anteilsnummer beginnend bei 1 */
anteil_nr: number
/** prozentualer Anteil am Stammkapital (computed) */
anteil_pct?: number
is_geschaeftsfuehrer: boolean
/** Bei GF: interne Rolle z.B. CEO/CTO */
internal_role?: string
/** Falls Gründer akademischen Hintergrund hat (Professur etc.) */
has_academic_background?: boolean
/** IP-Bereiche die der Gründer für die GmbH einbringt (z.B. ["Compliance-Engine", "RAG-Pipeline"]) */
ip_areas?: string[]
}
export interface NotarData {
notary_name: string
notary_place: string
notary_address?: string
notary_email?: string
notarial_date?: string // YYYY-MM-DD, geplant
urnr?: string // wird vom Notar vergeben
}
export interface CompanyBasics {
company_name: string
legal_form: 'GmbH' | 'UG'
company_seat: string // z.B. "Bietigheim-Bissingen"
company_address: string
company_purpose_description: string // Volltext für § 2 Satzung
company_purpose_bullets: string[]
industry: string
business_year: string // z.B. "Kalenderjahr"
has_research_focus: boolean
/** Registergericht (z.B. "Amtsgericht Stuttgart"). Pflicht für HRB-Anmeldung. */
register_court?: string
/** HRB-Nummer (z.B. "HRB 12345"). Leer falls noch nicht eingetragen. */
hrb_number?: string
}
export interface CapitalConfig {
stammkapital_eur: number // z.B. 25000
einlage_method: 'Geld' | 'Sacheinlage' | 'Geld und Sacheinlage'
einlage_quote_initial_pct: number // z.B. 50 oder 100
has_sacheinlage: boolean
}
export interface SHAConfig {
has_sha: boolean
vesting_months: number // Standard 48
cliff_months: number // Standard 12
drag_along_threshold_pct: number // Standard 75
tag_along_threshold_pct: number // Standard 20
reserved_matters_majority_pct: number // Standard 75
has_beirat: boolean
has_texas_shootout: boolean
has_ceo_designation: boolean
ceo_name?: string // ref to gesellschafter.name
esop_pool_pct: number // Standard 0 oder 10
}
export interface GFContract {
gesellschafter_id: string // ref to gesellschafter.id
gross_annual_salary_eur: number
has_bonus: boolean
has_company_car: boolean
has_bav: boolean
vacation_days: number // Standard 30
kuendigungsfrist_gesellschaft_monate: number // Standard 6
kuendigungsfrist_gf_monate: number // Standard 3
para_181_release: boolean
sv_status: 'sozialversicherungsfrei' | 'sozialversicherungspflichtig' | 'noch zu klären'
}
/**
* Vollständiger Wizard-State.
* Wird Step-by-Step befüllt, in localStorage gespeichert,
* und beim Submit an /api/v1/founding-wizard/generate geschickt.
*/
export interface FoundingWizardState {
/** Aktueller Step (1-8) */
current_step: number
/** Lifecycle-Stage Auswahl (default: founding) */
lifecycle_stage: LifecycleStage
// Step 1: Lifecycle
is_pre_notary: boolean
// Step 2: Basics
basics: CompanyBasics
// Step 3: Gesellschafter
gesellschafter: Gesellschafter[]
// Step 4: Kapital
capital: CapitalConfig
// Step 5: Notar
notar: NotarData
// Step 6: SHA-Konfiguration
sha: SHAConfig
// Step 7: GF-Verträge (1 pro GF)
gf_contracts: GFContract[]
// Step 8: Auswahl der zu generierenden Dokumente
selected_documents: string[]
/** Output nach Submit: URL + Dateiname pro generiertem Dokument */
generated_documents?: GeneratedDocument[]
}
export interface GeneratedDocument {
document_type: string
title: string
download_url: string
size_bytes: number
generated_at: string
}
/** Default-State für einen frischen Wizard */
export function defaultFoundingWizardState(): FoundingWizardState {
return {
current_step: 1,
lifecycle_stage: 'founding',
is_pre_notary: true,
basics: {
company_name: '',
legal_form: 'GmbH',
company_seat: '',
company_address: '',
company_purpose_description: '',
company_purpose_bullets: [],
industry: '',
business_year: 'Kalenderjahr',
has_research_focus: false,
register_court: '',
hrb_number: '',
},
gesellschafter: [],
capital: {
stammkapital_eur: 25000,
einlage_method: 'Geld',
einlage_quote_initial_pct: 50,
has_sacheinlage: false,
},
notar: {
notary_name: '',
notary_place: '',
},
sha: {
has_sha: true,
vesting_months: 48,
cliff_months: 12,
drag_along_threshold_pct: 75,
tag_along_threshold_pct: 20,
reserved_matters_majority_pct: 75,
has_beirat: false,
has_texas_shootout: false,
has_ceo_designation: false,
esop_pool_pct: 0,
},
gf_contracts: [],
selected_documents: [
'articles_of_association',
'gesellschafterliste',
'gf_bestellungsbeschluss',
'hrb_anmeldung',
'sha',
'geschaeftsordnung_gf',
'managing_director_employment_contract',
'ip_assignment_agreement',
],
}
}
@@ -0,0 +1,196 @@
/**
* Playwright E2E-Test: Founding-Wizard mit 2-Mann GmbH (Benjamin Bönisch + Sharang Parnerkar).
*
* Test-Flow:
* 1. Lokale Dev-URL aufrufen
* 2. Wizard durch alle 8 Steps befüllen
* 3. Dokumente generieren (8 Stück für Notartermin-Bundle)
* 4. Word-Download-Links validieren
*
* Voraussetzung: `npm run dev` läuft auf http://localhost:3007
* Backend ist erreichbar (mit Migration 137 + 138 + Templates 123136)
*
* Ausführen:
* cd admin-compliance
* npx playwright test tests/playwright/founding-wizard/
*/
import { expect, test } from '@playwright/test'
const BASE_URL = process.env.WIZARD_URL || 'http://localhost:3007/sdk/founding-wizard'
const TEST_DATA = {
basics: {
company_name: 'Breakpilot GmbH',
company_seat: 'Bietigheim-Bissingen',
company_address: 'Hauptstraße 1, 74321 Bietigheim-Bissingen',
industry: 'Software / KI / SaaS',
purpose: 'die Entwicklung, Bereitstellung und der Vertrieb von Softwarelösungen, Plattformen und IT-Dienstleistungen im Bereich der Künstlichen Intelligenz sowie compliance-bezogener Datenverarbeitungssysteme',
bullets: [
'a) Entwicklung, Programmierung und Betrieb von KI-gestützter Compliance-Software',
'b) Bereitstellung von datenschutzkonformen SaaS-Lösungen für Unternehmen',
'c) Beratungs- und Integrationsleistungen im Compliance-Umfeld',
],
},
notar: {
name: 'Dr. Müller',
place: 'Stuttgart',
address: 'Königstraße 1, 70173 Stuttgart',
date: '2026-06-15',
},
gesellschafter: [
{
name: 'Benjamin Bönisch',
birthdate: '1980-03-15',
address: 'Hauptstraße 1, 74321 Bietigheim-Bissingen',
email: 'benjamin@breakpilot.ai',
nennbetrag: 12500,
is_gf: true,
role: 'CEO',
},
{
name: 'Sharang Parnerkar',
birthdate: '1985-09-22',
address: 'Hauptstraße 2, 74321 Bietigheim-Bissingen',
email: 'sharang@breakpilot.ai',
nennbetrag: 12500,
is_gf: true,
role: 'CTO',
},
],
stammkapital: 25000,
}
test.describe('Founding Wizard — 2-Mann GmbH', () => {
test.beforeEach(async ({ page }) => {
// Clear localStorage to start fresh
await page.goto(BASE_URL)
await page.evaluate(() => localStorage.clear())
await page.reload()
})
test('füllt komplette 2-Mann GmbH aus und generiert Notartermin-Bundle', async ({ page }) => {
await page.goto(BASE_URL)
await expect(page.getByTestId('founding-wizard')).toBeVisible()
// STEP 1: Basics
await expect(page.getByTestId('step-content-1')).toBeVisible()
await page.getByTestId('company-name').fill(TEST_DATA.basics.company_name)
await page.getByTestId('legal-form').selectOption('GmbH')
await page.getByTestId('company-seat').fill(TEST_DATA.basics.company_seat)
await page.getByTestId('company-address').fill(TEST_DATA.basics.company_address)
await page.getByTestId('industry').fill(TEST_DATA.basics.industry)
await page.getByTestId('company-purpose').fill(TEST_DATA.basics.purpose)
await page.getByTestId('company-purpose-bullets').fill(TEST_DATA.basics.bullets.join('\n'))
await page.getByTestId('next-step').click()
// STEP 2: Gesellschafter
await expect(page.getByTestId('step-content-2')).toBeVisible()
for (const gs of TEST_DATA.gesellschafter) {
await page.getByTestId('gs-name').fill(gs.name)
await page.getByTestId('gs-birthdate').fill(gs.birthdate)
await page.getByTestId('gs-address').fill(gs.address)
await page.getByTestId('gs-email').fill(gs.email)
await page.getByTestId('gs-nennbetrag').fill(String(gs.nennbetrag))
await page.getByTestId('gs-role').fill(gs.role)
// is_gf bereits default true, nichts zu tun
await page.getByTestId('add-gesellschafter').click()
}
await expect(page.getByTestId('gs-row-1')).toContainText('Benjamin Bönisch')
await expect(page.getByTestId('gs-row-2')).toContainText('Sharang Parnerkar')
await expect(page.getByTestId('gs-total')).toContainText('25.000')
await page.getByTestId('next-step').click()
// STEP 3: GF-Assignment (beide bereits GF aus Step 2)
await expect(page.getByTestId('step-content-3')).toBeVisible()
await page.getByTestId('next-step').click()
// STEP 4: Kapital
await expect(page.getByTestId('step-content-4')).toBeVisible()
await expect(page.getByTestId('stammkapital')).toHaveValue('25000')
await page.getByTestId('einlage-method').selectOption('Geld')
await page.getByTestId('einlage-quote').fill('50')
await page.getByTestId('next-step').click()
// STEP 5: Notar
await expect(page.getByTestId('step-content-5')).toBeVisible()
await page.getByTestId('notary-name').fill(TEST_DATA.notar.name)
await page.getByTestId('notary-place').fill(TEST_DATA.notar.place)
await page.getByTestId('notary-address').fill(TEST_DATA.notar.address)
await page.getByTestId('notarial-date').fill(TEST_DATA.notar.date)
await page.getByTestId('next-step').click()
// STEP 6: SHA-Optionen
await expect(page.getByTestId('step-content-6')).toBeVisible()
await expect(page.getByTestId('has-sha')).toBeChecked()
await expect(page.getByTestId('vesting-months')).toHaveValue('48')
await expect(page.getByTestId('drag-along-pct')).toHaveValue('75')
await page.getByTestId('next-step').click()
// STEP 7: GF-Verträge (für beide Founders)
await expect(page.getByTestId('step-content-7')).toBeVisible()
// GF-Contracts werden mit Defaults erzeugt sobald GFs definiert sind -
// wir editieren die Gehälter
const contracts = page.locator('[data-testid^="contract-"]')
const count = await contracts.count()
expect(count).toBe(2)
await page.getByTestId('next-step').click()
// STEP 8: Generate
await expect(page.getByTestId('step-content-8')).toBeVisible()
await expect(page.getByTestId('generate-summary')).toContainText('Breakpilot GmbH')
await expect(page.getByTestId('generate-summary')).toContainText('Bietigheim-Bissingen')
await expect(page.getByTestId('generate-summary')).toContainText('25.000')
// Notartermin-Bundle auswählen
await page.getByTestId('select-notary-bundle').click()
// Check that bundle items are selected
await expect(page.getByTestId('doc-articles_of_association')).toBeChecked()
await expect(page.getByTestId('doc-sha')).toBeChecked()
await expect(page.getByTestId('doc-gesellschafterliste')).toBeChecked()
await expect(page.getByTestId('doc-managing_director_employment_contract')).toBeChecked()
// Generate
await page.getByTestId('generate-docs').click()
// Warten auf Generierung (max 30s)
await expect(page.getByTestId('generated-docs')).toBeVisible({ timeout: 30000 })
// Mindestens 8 Dokumente sollten erscheinen (für 2 Founders evtl. doppelt: GF-Vertrag, IP-Assignment)
const downloadLinks = page.locator('[data-testid^="download-"]')
const linkCount = await downloadLinks.count()
expect(linkCount).toBeGreaterThanOrEqual(8)
// Validiere dass download-URLs data: URLs sind (base64 DOCX)
for (let i = 0; i < Math.min(linkCount, 3); i++) {
const href = await downloadLinks.nth(i).getAttribute('href')
expect(href).toMatch(/^data:application\/vnd\.openxmlformats-officedocument\.wordprocessingml\.document;base64,/)
}
// Screenshot fürs Test-Artifact
await page.screenshot({ path: 'test-results/founding-wizard-final.png', fullPage: true })
})
test('zeigt Validierung wenn Pflichtfelder fehlen', async ({ page }) => {
await page.goto(BASE_URL)
// Next-Button sollte disabled sein wenn nichts ausgefüllt
await expect(page.getByTestId('next-step')).toBeDisabled()
await page.getByTestId('company-name').fill('Test')
// Immer noch disabled weil purpose fehlt
await expect(page.getByTestId('next-step')).toBeDisabled()
await page.getByTestId('company-seat').fill('Stuttgart')
await page.getByTestId('company-purpose').fill('Eine lange genug Beschreibung des Zwecks.')
// Jetzt sollte er enabled sein
await expect(page.getByTestId('next-step')).toBeEnabled()
})
test('Reset löscht alle Daten', async ({ page }) => {
await page.goto(BASE_URL)
await page.getByTestId('company-name').fill('Wird gelöscht GmbH')
page.on('dialog', d => d.accept())
await page.getByTestId('reset-wizard').click()
await expect(page.getByTestId('company-name')).toHaveValue('')
})
})
+241
View File
@@ -0,0 +1,241 @@
// Command iace-audit runs static and runtime audits on the IACE pattern
// engine to find gaps without a ground-truth reference.
//
// Subcommands:
//
// reachability — Method A: which patterns can never fire given the library?
// consistency — Method B: do components cover their TypicalHazardCategories?
// vocabulary — Method C: which limits-form words are unknown to the dict?
// echo — Method D: which limits-form sentences have no hazard echo?
// hierarchy — Method E: which hazards lack design/protection/information?
package main
import (
"encoding/json"
"fmt"
"os"
"github.com/breakpilot/ai-compliance-sdk/internal/iace/audit"
)
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
switch os.Args[1] {
case "reachability":
cmdReachability(os.Args[2:])
case "consistency":
cmdConsistency(os.Args[2:])
case "vocabulary":
cmdVocabulary(os.Args[2:])
case "echo":
cmdEcho(os.Args[2:])
case "hierarchy":
cmdHierarchy(os.Args[2:])
default:
usage()
os.Exit(2)
}
}
func usage() {
fmt.Fprintln(os.Stderr, "Usage: iace-audit <reachability|consistency|vocabulary|echo|hierarchy> [args]")
}
func cmdReachability(_ []string) {
r := audit.RunReachability()
printSummary(fmt.Sprintf("Method A — Pattern Reachability"), map[string]int{
"total": r.TotalPatterns,
"reachable": r.Reachable,
"weakly_reachable": r.WeaklyReachable,
"unreachable": r.Unreachable,
"universe_tags": len(r.UniverseTags),
})
if len(r.UnreachablePatterns) > 0 {
fmt.Println("\n## Unreachable patterns (top 30 by priority):\n")
printPatternRows(r.UnreachablePatterns, 30)
}
if len(r.WeakPatterns) > 0 {
fmt.Println("\n## Weakly reachable (top 20 by priority):\n")
printPatternRows(r.WeakPatterns, 20)
}
writeJSON("audit-reports/reachability.json", r)
}
func cmdConsistency(_ []string) {
r := audit.RunConsistency()
printSummary("Method B — Component Self-Consistency", map[string]int{
"total_components": r.TotalComponents,
"consistent": r.Consistent,
"incomplete": r.Incomplete,
})
if len(r.IncompleteComponents) > 0 {
fmt.Println("\n## Components missing tags for declared hazard categories:\n")
for _, c := range r.IncompleteComponents {
fmt.Printf("- %s (%s)\n", c.ComponentID, c.NameDE)
for _, miss := range c.MissingForCategories {
fmt.Printf(" %s: no pattern fires (suggest tags: %s)\n", miss.Category, joinFirst(miss.SuggestedTags, 5))
}
}
}
writeJSON("audit-reports/consistency.json", r)
}
func cmdVocabulary(args []string) {
if len(args) < 1 {
fmt.Fprintln(os.Stderr, "vocabulary: missing path to limits-form JSON")
os.Exit(2)
}
data, err := os.ReadFile(args[0])
must(err)
var form map[string]any
must(json.Unmarshal(data, &form))
r := audit.RunVocabulary(form)
printSummary("Method C — Vocabulary Diff", map[string]int{
"unique_tokens": r.UniqueTokens,
"unknown_tokens": len(r.UnknownTokens),
"unknown_with_pattern_hit": len(r.SuggestedDictionaryEntries),
})
if len(r.SuggestedDictionaryEntries) > 0 {
fmt.Println("\n## Suggested dictionary additions (token appears in pattern scenarios but not in dict):\n")
for _, s := range r.SuggestedDictionaryEntries {
fmt.Printf("- '%s' → seen in %d patterns. Examples: %s\n", s.Token, len(s.PatternIDs), joinFirst(s.PatternIDs, 5))
}
}
writeJSON("audit-reports/vocabulary.json", r)
}
func cmdEcho(args []string) {
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "echo: usage: iace-audit echo <limits-form.json> <hazards.json>")
os.Exit(2)
}
limitsData, err := os.ReadFile(args[0])
must(err)
hazardsData, err := os.ReadFile(args[1])
must(err)
var form map[string]any
must(json.Unmarshal(limitsData, &form))
var hwrap struct {
Hazards []map[string]any `json:"hazards"`
}
must(json.Unmarshal(hazardsData, &hwrap))
r := audit.RunEcho(form, hwrap.Hazards)
printSummary("Method D — Limits-Form Echo", map[string]int{
"total_phrases": r.TotalPhrases,
"echoed": r.Echoed,
"orphaned": r.Orphaned,
})
if len(r.OrphanedPhrases) > 0 {
fmt.Println("\n## Orphaned phrases (no hazard echoes them):\n")
for _, o := range r.OrphanedPhrases {
fmt.Printf("- [%s] %s\n", o.Field, truncate(o.Phrase, 120))
}
}
writeJSON("audit-reports/echo.json", r)
}
func cmdHierarchy(args []string) {
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "hierarchy: usage: iace-audit hierarchy <hazards.json> <mitigations.json>")
os.Exit(2)
}
hData, err := os.ReadFile(args[0])
must(err)
mData, err := os.ReadFile(args[1])
must(err)
var hwrap struct {
Hazards []map[string]any `json:"hazards"`
}
must(json.Unmarshal(hData, &hwrap))
var mwrap struct {
Mitigations []map[string]any `json:"mitigations"`
}
must(json.Unmarshal(mData, &mwrap))
r := audit.RunHierarchy(hwrap.Hazards, mwrap.Mitigations)
printSummary("Method E — Hierarchy Completeness", map[string]int{
"total_hazards": r.TotalHazards,
"complete": r.Complete,
"missing_design": r.MissingDesign,
"missing_protection": r.MissingProtection,
"missing_info": r.MissingInfo,
})
if len(r.IncompleteHazards) > 0 {
fmt.Println("\n## Hazards with incomplete hierarchy:\n")
for _, h := range r.IncompleteHazards {
fmt.Printf("- [%s] %s — missing: %s\n", h.Category, truncate(h.Name, 70), joinFirst(h.MissingLevels, 3))
}
}
writeJSON("audit-reports/hierarchy.json", r)
}
func printSummary(title string, kv map[string]int) {
fmt.Println("=", title, "=")
for k, v := range kv {
fmt.Printf(" %-22s %d\n", k, v)
}
}
func printPatternRows(rows []audit.ReachabilityResult, max int) {
if max > len(rows) {
max = len(rows)
}
for i := 0; i < max; i++ {
r := rows[i]
fmt.Printf("- %s (P%d) %s\n", r.PatternID, r.Priority, truncate(r.Name, 60))
if len(r.UnreachableTags) > 0 {
fmt.Printf(" missing tags: %s\n", joinFirst(r.UnreachableTags, 8))
}
for _, s := range r.FixSuggestions {
fmt.Printf(" fix: %s\n", s)
}
}
}
func writeJSON(path string, v any) {
_ = os.MkdirAll("audit-reports", 0o755)
f, err := os.Create(path)
if err != nil {
fmt.Fprintln(os.Stderr, "warn: could not write report:", err)
return
}
defer f.Close()
enc := json.NewEncoder(f)
enc.SetIndent("", " ")
_ = enc.Encode(v)
fmt.Println("→ wrote", path)
}
func must(err error) {
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
func joinFirst(list []string, n int) string {
if len(list) <= n {
return join(list)
}
return join(list[:n]) + ", …"
}
func join(list []string) string {
out := ""
for i, s := range list {
if i > 0 {
out += ", "
}
out += s
}
return out
}
@@ -0,0 +1,171 @@
package audit
import (
"sort"
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
)
// runConsistencyImpl asks: does this component, with its own tags PLUS the
// tags of its TypicalEnergySources, actually trigger at least one pattern
// in every category listed in its TypicalHazardCategories?
//
// A component declares "this is what I am dangerous for" and the engine
// turns that declaration into hazards through patterns. If no pattern can
// fire from the component's tag set, the declaration is decorative — the
// engine will never produce a hazard in that category for this component,
// even though the library author said it should.
func init() {
runConsistencyImpl = runConsistency
}
func runConsistency() ConsistencyReport {
comps := iace.GetComponentLibrary()
energies := iace.GetEnergySources()
patterns := iace.AllPatterns()
energyByID := map[string]iace.EnergySourceEntry{}
for _, e := range energies {
energyByID[e.ID] = e
}
report := ConsistencyReport{TotalComponents: len(comps)}
for _, c := range comps {
if len(c.TypicalHazardCategories) == 0 {
report.Consistent++
continue
}
effective := buildEffectiveTags(c, energyByID)
covered := categoriesCoveredByPatterns(effective, c.MapsToComponentType, patterns)
var missing []string
for _, cat := range c.TypicalHazardCategories {
if !covered[cat] {
missing = append(missing, cat)
}
}
if len(missing) == 0 {
report.Consistent++
continue
}
result := ComponentResult{
ComponentID: c.ID,
NameDE: c.NameDE,
DeclaredCategories: c.TypicalHazardCategories,
}
for cat := range covered {
result.CoveredCategories = append(result.CoveredCategories, cat)
}
sort.Strings(result.CoveredCategories)
for _, cat := range missing {
result.MissingForCategories = append(result.MissingForCategories, CategoryGap{
Category: cat,
SuggestedTags: suggestTagsForCategory(cat, effective, patterns),
})
}
report.Incomplete++
report.IncompleteComponents = append(report.IncompleteComponents, result)
}
sort.Slice(report.IncompleteComponents, func(i, j int) bool {
return report.IncompleteComponents[i].ComponentID < report.IncompleteComponents[j].ComponentID
})
return report
}
func buildEffectiveTags(c iace.ComponentLibraryEntry, energyByID map[string]iace.EnergySourceEntry) map[string]bool {
set := map[string]bool{}
for _, t := range c.Tags {
set[t] = true
}
for _, eID := range c.TypicalEnergySources {
e, ok := energyByID[eID]
if !ok {
continue
}
for _, t := range e.Tags {
set[t] = true
}
}
return set
}
// categoriesCoveredByPatterns iterates patterns and finds which
// GeneratedHazardCats can fire given the component's effective tags.
// We ignore lifecycle, op-state, and human-role filters — those are
// project-level. The audit asks "can the library produce ANY hazard in
// this category for this component if the project configures everything
// reasonably?"
func categoriesCoveredByPatterns(tags map[string]bool, _ string, patterns []iace.HazardPattern) map[string]bool {
covered := map[string]bool{}
for _, p := range patterns {
if !tagsCover(tags, p.RequiredComponentTags) {
continue
}
if !tagsCover(tags, p.RequiredEnergyTags) {
continue
}
for _, cat := range p.GeneratedHazardCats {
covered[cat] = true
}
}
return covered
}
func tagsCover(have map[string]bool, required []string) bool {
for _, t := range required {
if !have[t] {
return false
}
}
return true
}
// suggestTagsForCategory looks at patterns that DO generate this category
// and identifies the tags that would close the gap. Returns the tags most
// commonly required by patterns in that category, minus what the component
// already has.
func suggestTagsForCategory(cat string, have map[string]bool, patterns []iace.HazardPattern) []string {
counts := map[string]int{}
for _, p := range patterns {
matchCat := false
for _, c := range p.GeneratedHazardCats {
if c == cat {
matchCat = true
break
}
}
if !matchCat {
continue
}
for _, t := range p.RequiredComponentTags {
if !have[t] {
counts[t]++
}
}
for _, t := range p.RequiredEnergyTags {
if !have[t] {
counts[t]++
}
}
}
type kv struct {
tag string
n int
}
var sorted []kv
for t, n := range counts {
sorted = append(sorted, kv{t, n})
}
sort.Slice(sorted, func(i, j int) bool { return sorted[i].n > sorted[j].n })
var out []string
for i, s := range sorted {
if i >= 6 {
break
}
out = append(out, s.tag)
}
return out
}
@@ -0,0 +1,161 @@
package audit
import (
"regexp"
"sort"
"strings"
)
// runEchoImpl checks if each meaningful phrase from the limits-form is
// echoed by at least one generated hazard. A phrase that names a concrete
// scenario, fault, or constraint must reappear (semantically) in some
// hazard's name, scenario, or description. Phrases without echo are gaps:
// the engineer documented the risk but the engine never lifted it into
// the hazard register.
//
// Echo detection here is a lightweight Jaccard overlap of content tokens
// (not embeddings) — robust enough for the demonstrative diagnostic and
// keeps the audit fully deterministic without an external model. The
// caller can later swap in a vector-based scorer.
func init() {
runEchoImpl = runEcho
}
// Significant limits-form fields. Each item is (key, label). We only
// audit the freeform fields where engineers describe risks — list/enum
// fields (operating_modes, person_groups, industry_sectors) are out of
// scope because they carry no narrative phrases.
var echoFields = []struct {
key string
label string
}{
{"general_description", "Allg. Beschreibung"},
{"intended_purpose", "Bestimmungsgemaesse Verwendung"},
{"variants", "Varianten"},
{"foreseeable_misuses", "Vorhersehbare Fehlanwendung"},
{"spatial_limits", "Raeumliche Grenzen"},
{"temporal_limits", "Zeitliche Grenzen"},
{"operating_conditions", "Betriebsbedingungen"},
{"energy_supply", "Energieversorgung"},
{"mechanical_interfaces", "Mechanische Schnittstellen"},
{"electrical_interfaces", "Elektrische Schnittstellen"},
{"software_interfaces", "Software-Schnittstellen"},
{"pneumatic_hydraulic_interfaces", "Pneumatik/Hydraulik"},
{"qualification_requirements", "Personenqualifikation"},
}
var sentenceSplit = regexp.MustCompile(`[.!?]\s+|\n+`)
var wordRE = regexp.MustCompile(`[a-zäöüßA-ZÄÖÜ]{4,}`)
// echoThreshold — minimum Jaccard overlap (between sentence content
// tokens and a hazard's content tokens) above which the sentence is
// considered echoed. Tuned by hand to give meaningful results without a
// labeled corpus; the audit reports the actual best score for each
// orphaned phrase so a human can re-tune if needed.
const echoThreshold = 0.18
func runEcho(form map[string]any, hazards []map[string]any) EchoReport {
limits := unwrapLimits(form)
// Precompute hazard token bags once
type bag struct {
tokens map[string]bool
text string
}
var hazardBags []bag
for _, h := range hazards {
txt := joinHazardText(h)
toks := contentTokenSet(txt)
hazardBags = append(hazardBags, bag{tokens: toks, text: txt})
}
report := EchoReport{}
for _, fld := range echoFields {
raw, _ := limits[fld.key].(string)
raw = strings.TrimSpace(raw)
if raw == "" {
continue
}
for _, sent := range sentenceSplit.Split(raw, -1) {
sent = strings.TrimSpace(sent)
if len(sent) < 30 {
// Skip very short fragments
continue
}
report.TotalPhrases++
st := contentTokenSet(sent)
if len(st) < 3 {
continue
}
bestScore := 0.0
for _, hb := range hazardBags {
score := jaccard(st, hb.tokens)
if score > bestScore {
bestScore = score
}
}
if bestScore >= echoThreshold {
report.Echoed++
continue
}
report.Orphaned++
report.OrphanedPhrases = append(report.OrphanedPhrases, OrphanedPhrase{
Field: fld.label,
Phrase: sent,
BestScore: bestScore,
})
}
}
sort.Slice(report.OrphanedPhrases, func(i, j int) bool {
// Lowest scores first — most clearly orphaned
return report.OrphanedPhrases[i].BestScore < report.OrphanedPhrases[j].BestScore
})
return report
}
func unwrapLimits(form map[string]any) map[string]any {
if inner, ok := form["limits_form"].(map[string]any); ok {
return inner
}
return form
}
func joinHazardText(h map[string]any) string {
parts := []string{}
for _, k := range []string{"name", "description", "scenario", "trigger_event", "possible_harm", "hazardous_zone", "category", "sub_category"} {
if v, ok := h[k].(string); ok {
parts = append(parts, v)
}
}
return strings.Join(parts, " ")
}
func contentTokenSet(s string) map[string]bool {
out := map[string]bool{}
for _, m := range wordRE.FindAllString(s, -1) {
w := strings.ToLower(m)
if stopWords[w] {
continue
}
out[w] = true
}
return out
}
func jaccard(a, b map[string]bool) float64 {
if len(a) == 0 || len(b) == 0 {
return 0
}
inter := 0
for x := range a {
if b[x] {
inter++
}
}
union := len(a) + len(b) - inter
if union == 0 {
return 0
}
return float64(inter) / float64(union)
}
@@ -0,0 +1,158 @@
package audit
import (
"sort"
"strings"
)
// runHierarchyImpl checks the ISO 12100 / EN 12100 risk-reduction
// hierarchy on the generated mitigation set: every safety-relevant
// hazard should have at least one "inherently safe design" measure
// (design) and additionally either a guarding/protective device
// (protection) or an information-for-use measure (information).
//
// Cyber-, ergonomic-, and software-only hazards have looser
// expectations — design alone or information alone may legitimately
// suffice. The audit reports which level is missing, not whether the
// remaining measures are individually correct. That is a different
// check (E2 — semantic quality), out of scope here.
func init() {
runHierarchyImpl = runHierarchy
}
// hazardExpectsProtection lists hazard categories where a pure
// design+information combination is usually not enough — the engine
// should produce at least one explicit protective measure (guard,
// interlock, sensor, presence detector, …).
var hazardExpectsProtection = map[string]bool{
"mechanical_hazard": true,
"electrical_hazard": true,
"thermal_hazard": true,
"pneumatic_hydraulic": true,
"radiation_hazard": true,
"laser_hazard": true,
"fire_explosion_hazard": true,
"chemical_hazard": true,
}
func runHierarchy(hazards, mitigations []map[string]any) HierarchyReport {
report := HierarchyReport{TotalHazards: len(hazards)}
// Index mitigations by hazard_id
byHazard := map[string][]map[string]any{}
for _, m := range mitigations {
hid, _ := m["hazard_id"].(string)
if hid == "" {
continue
}
byHazard[hid] = append(byHazard[hid], m)
}
for _, h := range hazards {
hid, _ := h["id"].(string)
category, _ := h["category"].(string)
name, _ := h["name"].(string)
levels := levelsForHazard(byHazard[hid])
missing := expectedMissing(category, levels)
if len(missing) == 0 {
report.Complete++
continue
}
for _, m := range missing {
switch m {
case "design":
report.MissingDesign++
case "protection":
report.MissingProtection++
case "information":
report.MissingInfo++
}
}
report.IncompleteHazards = append(report.IncompleteHazards, HazardHierarchyResult{
HazardID: hid,
Name: name,
Category: category,
Levels: levels,
MissingLevels: missing,
})
}
// Sort: protection-missing first (most consequential), then by category
sort.Slice(report.IncompleteHazards, func(i, j int) bool {
a := report.IncompleteHazards[i]
b := report.IncompleteHazards[j]
ap := contains(a.MissingLevels, "protection")
bp := contains(b.MissingLevels, "protection")
if ap != bp {
return ap
}
return a.Category < b.Category
})
return report
}
// levelsForHazard returns the distinct reduction-type levels present
// for a hazard's mitigation set. Possible values: design, protection,
// information.
func levelsForHazard(mits []map[string]any) []string {
seen := map[string]bool{}
for _, m := range mits {
rt, _ := m["reduction_type"].(string)
switch strings.ToLower(rt) {
case "design":
seen["design"] = true
case "protection", "protective":
seen["protection"] = true
case "information":
seen["information"] = true
}
}
var out []string
for k := range seen {
out = append(out, k)
}
sort.Strings(out)
return out
}
// expectedMissing returns the levels that the hierarchy demands but
// the mitigation set does not provide.
//
// Rule:
// - Every hazard with mitigations should have a design measure.
// - Categories in hazardExpectsProtection additionally need a
// protection measure.
// - All hazards should have an information measure unless they
// already have both design + protection (the information layer
// can then be considered subsumed for the audit's purpose; the
// real engine usually still adds it).
func expectedMissing(category string, present []string) []string {
have := toBoolSet(present)
var missing []string
if !have["design"] {
missing = append(missing, "design")
}
if hazardExpectsProtection[category] && !have["protection"] {
missing = append(missing, "protection")
}
// Information is only flagged if both design and protection are
// also absent — otherwise too noisy. We still surface the case
// where information is the SOLE present level: that means the
// hazard is mitigated only by warning labels, which is rarely
// adequate.
if !have["information"] && !have["design"] && !have["protection"] {
missing = append(missing, "information")
}
return missing
}
func contains(list []string, target string) bool {
for _, x := range list {
if x == target {
return true
}
}
return false
}
@@ -0,0 +1,37 @@
package audit
// Implementation entry points for Methods B-E. The full algorithms live
// in consistency.go, vocabulary.go, echo.go, hierarchy.go respectively.
// Until those files land, these wrappers keep main.go compilable and
// return a clearly-marked empty report.
func RunConsistency() ConsistencyReport {
return runConsistencyImpl()
}
func RunVocabulary(form map[string]any) VocabularyReport {
return runVocabularyImpl(form)
}
func RunEcho(form map[string]any, hazards []map[string]any) EchoReport {
return runEchoImpl(form, hazards)
}
func RunHierarchy(hazards, mitigations []map[string]any) HierarchyReport {
return runHierarchyImpl(hazards, mitigations)
}
// Default implementations — replaced when each method file lands.
// Keeping them as separate functions in one place avoids name clashes
// once consistency.go etc. add their real implementations.
var (
runConsistencyImpl = func() ConsistencyReport { return ConsistencyReport{} }
runVocabularyImpl = func(form map[string]any) VocabularyReport { return VocabularyReport{} }
runEchoImpl = func(form map[string]any, hazards []map[string]any) EchoReport {
return EchoReport{}
}
runHierarchyImpl = func(hazards, mitigations []map[string]any) HierarchyReport {
return HierarchyReport{}
}
)
@@ -0,0 +1,298 @@
// Package audit provides static and runtime audits of the IACE pattern
// engine — finding pattern reachability, library consistency, and
// limits-form coverage gaps without a ground-truth reference.
package audit
import (
"sort"
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
)
// ReachabilityResult is the verdict for a single pattern in Method A.
type ReachabilityResult struct {
PatternID string `json:"pattern_id"`
Name string `json:"name_de"`
Priority int `json:"priority"`
RequiredAllTags []string `json:"required_tags"`
UnreachableTags []string `json:"unreachable_tags,omitempty"`
Status string `json:"status"` // "reachable" | "weakly_reachable" | "unreachable"
ReachableSources []string `json:"reachable_sources,omitempty"`
FixSuggestions []string `json:"fix_suggestions,omitempty"`
}
// ReachabilityReport is the full Method A output.
type ReachabilityReport struct {
TotalPatterns int `json:"total_patterns"`
Reachable int `json:"reachable"`
WeaklyReachable int `json:"weakly_reachable"`
Unreachable int `json:"unreachable"`
UniverseTags []string `json:"universe_tags"`
UnreachablePatterns []ReachabilityResult `json:"unreachable_patterns"`
WeakPatterns []ReachabilityResult `json:"weak_patterns"`
}
// RunReachability evaluates every pattern against the achievable tag universe.
//
// A pattern is:
// - "unreachable" if at least one required tag is not produced by any
// component, energy source, or keyword-dictionary entry.
// - "weakly_reachable" if all required tags exist in the universe but
// no single source (one Component or one EnergySource or one Keyword
// entry) supplies all of them at once — i.e., it relies on multiple
// parser hits to combine.
// - "reachable" if some single source covers all required tags.
//
// The classification ignores ExcludedComponentTags and runtime filters
// (lifecycle/op-state/machine-type), because those are project-level
// concerns. The audit answers "could this pattern EVER fire", not
// "does it fire for project X".
func RunReachability() ReachabilityReport {
patterns := iace.AllPatterns()
comps := iace.GetComponentLibrary()
energies := iace.GetEnergySources()
keywords := iace.GetKeywordDictionary()
// Tag universe: union of every tag emitted anywhere
universe := map[string][]string{} // tag → list of source IDs that emit it
for _, c := range comps {
for _, t := range c.Tags {
universe[t] = appendUnique(universe[t], "component:"+c.ID)
}
}
for _, e := range energies {
for _, t := range e.Tags {
universe[t] = appendUnique(universe[t], "energy:"+e.ID)
}
}
for i, kw := range keywords {
for _, t := range kw.ExtraTags {
universe[t] = appendUnique(universe[t], keywordLabel(kw, i))
}
// Keyword entries can also reference components/energies, which
// transitively add their tags to the keyword's effective tag set.
for _, cID := range kw.ComponentIDs {
for _, c := range comps {
if c.ID != cID {
continue
}
for _, t := range c.Tags {
universe[t] = appendUnique(universe[t], keywordLabel(kw, i))
}
}
}
for _, eID := range kw.EnergyIDs {
for _, e := range energies {
if e.ID != eID {
continue
}
for _, t := range e.Tags {
universe[t] = appendUnique(universe[t], keywordLabel(kw, i))
}
}
}
}
// Single-source coverage map: tag → covering sources, but also
// per-source tag set so we can check "is there ONE source covering
// all required tags".
sourceTags := map[string]map[string]bool{}
for _, c := range comps {
key := "component:" + c.ID
sourceTags[key] = toBoolSet(c.Tags)
}
for _, e := range energies {
key := "energy:" + e.ID
sourceTags[key] = toBoolSet(e.Tags)
}
for i, kw := range keywords {
key := keywordLabel(kw, i)
set := toBoolSet(kw.ExtraTags)
for _, cID := range kw.ComponentIDs {
for _, c := range comps {
if c.ID == cID {
for _, t := range c.Tags {
set[t] = true
}
}
}
}
for _, eID := range kw.EnergyIDs {
for _, e := range energies {
if e.ID == eID {
for _, t := range e.Tags {
set[t] = true
}
}
}
}
sourceTags[key] = set
}
report := ReachabilityReport{TotalPatterns: len(patterns)}
// Universe tag list (sorted) for the report header
for t := range universe {
report.UniverseTags = append(report.UniverseTags, t)
}
sort.Strings(report.UniverseTags)
for _, p := range patterns {
all := dedup(append(append([]string{}, p.RequiredComponentTags...), p.RequiredEnergyTags...))
if len(all) == 0 {
// Pattern with no tag requirements relies on lifecycle/machine_type
// filters only — count as reachable by default.
report.Reachable++
continue
}
var missing []string
for _, t := range all {
if _, ok := universe[t]; !ok {
missing = append(missing, t)
}
}
res := ReachabilityResult{
PatternID: p.ID,
Name: p.NameDE,
Priority: p.Priority,
RequiredAllTags: all,
}
if len(missing) > 0 {
res.Status = "unreachable"
res.UnreachableTags = missing
res.FixSuggestions = suggestFixes(p, missing, comps, sourceTags)
report.Unreachable++
report.UnreachablePatterns = append(report.UnreachablePatterns, res)
continue
}
// All tags in universe — check single-source coverage
single := findSingleSourceCovers(all, sourceTags)
if len(single) > 0 {
res.Status = "reachable"
res.ReachableSources = single
report.Reachable++
continue
}
res.Status = "weakly_reachable"
res.FixSuggestions = suggestSingleSourceFixes(p, all, comps, sourceTags)
report.WeaklyReachable++
report.WeakPatterns = append(report.WeakPatterns, res)
}
sort.Slice(report.UnreachablePatterns, func(i, j int) bool {
return report.UnreachablePatterns[i].Priority > report.UnreachablePatterns[j].Priority
})
sort.Slice(report.WeakPatterns, func(i, j int) bool {
return report.WeakPatterns[i].Priority > report.WeakPatterns[j].Priority
})
return report
}
func findSingleSourceCovers(required []string, sourceTags map[string]map[string]bool) []string {
var hits []string
for src, tags := range sourceTags {
ok := true
for _, t := range required {
if !tags[t] {
ok = false
break
}
}
if ok {
hits = append(hits, src)
}
}
sort.Strings(hits)
return hits
}
// suggestFixes proposes concrete library edits for unreachable patterns:
// "Add tag X to Component C014 (Hubwerk)" type suggestions.
func suggestFixes(p iace.HazardPattern, missing []string, comps []iace.ComponentLibraryEntry, sourceTags map[string]map[string]bool) []string {
var out []string
// For each missing tag, find candidates: components/energies that
// would semantically own that tag based on existing tags overlap.
for _, tag := range missing {
candidates := nearComponents(p, tag, comps, sourceTags)
if len(candidates) > 0 {
out = append(out, "Add tag '"+tag+"' to one of: "+joinFirst(candidates, 3))
} else {
out = append(out, "Tag '"+tag+"' is undefined anywhere — needs a new component or energy source carrying it")
}
}
return out
}
func suggestSingleSourceFixes(p iace.HazardPattern, all []string, comps []iace.ComponentLibraryEntry, sourceTags map[string]map[string]bool) []string {
// Find components that match the most required tags, then suggest
// adding the residual ones.
best := ""
bestCover := 0
var bestMissing []string
for src, tags := range sourceTags {
hit := 0
var miss []string
for _, t := range all {
if tags[t] {
hit++
} else {
miss = append(miss, t)
}
}
if hit > bestCover {
best, bestCover, bestMissing = src, hit, miss
}
}
if best == "" || bestCover == 0 {
return []string{"No single source covers any required tags — pattern needs a new dedicated component"}
}
if len(bestMissing) == 0 {
return nil
}
return []string{"Closest single source '" + best + "' covers " + itoa(bestCover) + "/" + itoa(len(all)) + " tags. Add missing tags to it: " + joinFirst(bestMissing, 5)}
}
// nearComponents finds components whose tags overlap most with the pattern's
// requirements — these are good candidates to receive the missing tag.
func nearComponents(p iace.HazardPattern, missing string, comps []iace.ComponentLibraryEntry, sourceTags map[string]map[string]bool) []string {
required := dedup(append(append([]string{}, p.RequiredComponentTags...), p.RequiredEnergyTags...))
required = removeOne(required, missing)
if len(required) == 0 {
return nil
}
type scored struct {
id string
score int
}
var scoredList []scored
for _, c := range comps {
tagSet := toBoolSet(c.Tags)
s := 0
for _, t := range required {
if tagSet[t] {
s++
}
}
if s > 0 {
scoredList = append(scoredList, scored{id: c.ID + " (" + c.NameDE + ")", score: s})
}
}
sort.Slice(scoredList, func(i, j int) bool { return scoredList[i].score > scoredList[j].score })
var out []string
for _, s := range scoredList {
out = append(out, s.id)
}
return out
}
func keywordLabel(kw iace.KeywordEntry, idx int) string {
if len(kw.Keywords) > 0 {
return "keyword:" + kw.Keywords[0]
}
return "keyword:" + itoa(idx)
}
@@ -0,0 +1,84 @@
package audit
// Stubs for Methods B-E. Each is filled in its own file as the audit
// suite grows. Keeping the type contracts here lets the CLI compile
// before each method has its full implementation.
// ============================================================================
// Method B — Component Self-Consistency
// ============================================================================
type CategoryGap struct {
Category string `json:"category"`
SuggestedTags []string `json:"suggested_tags"`
}
type ComponentResult struct {
ComponentID string `json:"component_id"`
NameDE string `json:"name_de"`
DeclaredCategories []string `json:"declared_categories"`
CoveredCategories []string `json:"covered_categories"`
MissingForCategories []CategoryGap `json:"missing_for_categories,omitempty"`
}
type ConsistencyReport struct {
TotalComponents int `json:"total_components"`
Consistent int `json:"consistent"`
Incomplete int `json:"incomplete"`
IncompleteComponents []ComponentResult `json:"incomplete_components"`
}
// ============================================================================
// Method C — Limits-Form Vocabulary Diff
// ============================================================================
type DictionarySuggestion struct {
Token string `json:"token"`
Field string `json:"field"`
PatternIDs []string `json:"pattern_ids"`
}
type VocabularyReport struct {
UniqueTokens int `json:"unique_tokens"`
KnownTokens []string `json:"known_tokens"`
UnknownTokens []string `json:"unknown_tokens"`
SuggestedDictionaryEntries []DictionarySuggestion `json:"suggested_dictionary_entries"`
}
// ============================================================================
// Method D — Limits-Form Echo
// ============================================================================
type OrphanedPhrase struct {
Field string `json:"field"`
Phrase string `json:"phrase"`
BestScore float64 `json:"best_score"`
}
type EchoReport struct {
TotalPhrases int `json:"total_phrases"`
Echoed int `json:"echoed"`
Orphaned int `json:"orphaned"`
OrphanedPhrases []OrphanedPhrase `json:"orphaned_phrases"`
}
// ============================================================================
// Method E — Hierarchy Completeness
// ============================================================================
type HazardHierarchyResult struct {
HazardID string `json:"hazard_id"`
Name string `json:"name"`
Category string `json:"category"`
Levels []string `json:"present_levels"`
MissingLevels []string `json:"missing_levels"`
}
type HierarchyReport struct {
TotalHazards int `json:"total_hazards"`
Complete int `json:"complete"`
MissingDesign int `json:"missing_design"`
MissingProtection int `json:"missing_protection"`
MissingInfo int `json:"missing_information"`
IncompleteHazards []HazardHierarchyResult `json:"incomplete_hazards"`
}
@@ -0,0 +1,62 @@
package audit
import "strconv"
func appendUnique(list []string, item string) []string {
for _, x := range list {
if x == item {
return list
}
}
return append(list, item)
}
func toBoolSet(list []string) map[string]bool {
s := make(map[string]bool, len(list))
for _, x := range list {
s[x] = true
}
return s
}
func dedup(list []string) []string {
seen := map[string]bool{}
var out []string
for _, x := range list {
if !seen[x] {
seen[x] = true
out = append(out, x)
}
}
return out
}
func removeOne(list []string, item string) []string {
out := make([]string, 0, len(list))
for _, x := range list {
if x != item {
out = append(out, x)
}
}
return out
}
func joinFirst(list []string, n int) string {
if len(list) <= n {
return joinAll(list)
}
return joinAll(list[:n]) + ", ..."
}
func joinAll(list []string) string {
s := ""
for i, x := range list {
if i > 0 {
s += ", "
}
s += x
}
return s
}
func itoa(n int) string { return strconv.Itoa(n) }
@@ -0,0 +1,153 @@
package audit
import (
"regexp"
"sort"
"strings"
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
)
// runVocabularyImpl takes a limits-form payload (the structured machine
// description filled in by the engineer) and asks: which of its words
// are unknown to the keyword dictionary yet appear in any pattern's
// scenario/trigger/harm/zone text? Each such word is a dictionary gap —
// the engineer typed a term that some pattern is waiting for, but the
// parser cannot translate it into a tag.
func init() {
runVocabularyImpl = runVocabulary
}
var tokenRE = regexp.MustCompile(`[a-zäöüßA-ZÄÖÜ]{4,}`)
// German + English stop words that show up in any narrative but carry
// no engineering meaning. Kept short on purpose — we only want to drop
// obvious filler.
var stopWords = map[string]bool{
"oder": true, "und": true, "auch": true, "wenn": true, "wird": true,
"werden": true, "kann": true, "koennen": true, "soll": true, "muss": true,
"sind": true, "eine": true, "einer": true, "einem": true, "einen": true,
"diese": true, "dieser": true, "dieses": true, "diesem": true, "diesen": true,
"durch": true, "nach": true, "ueber": true, "unter": true, "zwischen": true,
"nicht": true, "ohne": true, "fuer": true, "bzw": true, "etc": true,
"sowie": true, "siehe": true, "etwa": true, "ggf": true, "the": true,
"with": true, "from": true, "this": true, "that": true, "have": true,
"insbesondere": true, "ausschliesslich": true, "ebenfalls": true,
"jeweils": true, "weitere": true, "weiteren": true, "weiterer": true,
}
func runVocabulary(form map[string]any) VocabularyReport {
limits, ok := form["limits_form"].(map[string]any)
if !ok {
// Form may already be the inner object
limits = form
}
tokens := map[string]bool{}
for _, v := range limits {
extractTokens(v, tokens)
}
report := VocabularyReport{UniqueTokens: len(tokens)}
dictTokens := dictionaryVocabulary()
for tok := range tokens {
if stopWords[tok] {
continue
}
if dictTokenHit(tok, dictTokens) {
report.KnownTokens = append(report.KnownTokens, tok)
} else {
report.UnknownTokens = append(report.UnknownTokens, tok)
}
}
sort.Strings(report.KnownTokens)
sort.Strings(report.UnknownTokens)
// For each unknown token check if any pattern names it
patterns := iace.AllPatterns()
for _, tok := range report.UnknownTokens {
hits := patternsMentioning(tok, patterns)
if len(hits) == 0 {
continue
}
report.SuggestedDictionaryEntries = append(report.SuggestedDictionaryEntries, DictionarySuggestion{
Token: tok,
PatternIDs: hits,
})
}
sort.Slice(report.SuggestedDictionaryEntries, func(i, j int) bool {
return len(report.SuggestedDictionaryEntries[i].PatternIDs) > len(report.SuggestedDictionaryEntries[j].PatternIDs)
})
return report
}
func extractTokens(v any, out map[string]bool) {
switch x := v.(type) {
case string:
for _, m := range tokenRE.FindAllString(x, -1) {
out[strings.ToLower(m)] = true
}
case []any:
for _, e := range x {
extractTokens(e, out)
}
case map[string]any:
for _, e := range x {
extractTokens(e, out)
}
}
}
// dictionaryVocabulary builds the lowercase set of all keyword strings
// that the parser will recognize, including normalized forms (umlauts
// replaced like in the keyword dictionary).
func dictionaryVocabulary() map[string]bool {
out := map[string]bool{}
for _, kw := range iace.GetKeywordDictionary() {
for _, k := range kw.Keywords {
out[strings.ToLower(k)] = true
}
}
return out
}
// dictTokenHit returns true if the token would be matched by any
// dictionary entry. Dictionary entries can be substrings, so we treat
// the dict as a set of stem-like matchers: a token is "known" if it
// equals a dict word OR contains a dict word as substring OR the dict
// word contains the token.
func dictTokenHit(tok string, dict map[string]bool) bool {
if dict[tok] {
return true
}
for d := range dict {
if strings.Contains(tok, d) || strings.Contains(d, tok) {
return true
}
}
return false
}
// patternsMentioning returns up to 8 pattern IDs whose scenario/trigger/
// harm/zone text contains the token (case-insensitive substring).
func patternsMentioning(tok string, patterns []iace.HazardPattern) []string {
tokLower := strings.ToLower(tok)
seen := map[string]bool{}
var out []string
for _, p := range patterns {
hay := strings.ToLower(p.ScenarioDE + " " + p.TriggerDE + " " + p.HarmDE + " " + p.ZoneDE + " " + p.NameDE)
if !strings.Contains(hay, tokLower) {
continue
}
if seen[p.ID] {
continue
}
seen[p.ID] = true
out = append(out, p.ID)
if len(out) >= 8 {
break
}
}
return out
}
@@ -36,21 +36,21 @@ func GetComponentLibrary() []ComponentLibraryEntry {
{ID: "C003", NameDE: "Foerderband", NameEN: "Conveyor Belt", Category: "mechanical", DescriptionDE: "Endlosband zum Transport von Werkstuecken zwischen Arbeitsstationen.", TypicalHazardCategories: []string{"mechanical_hazard", "ergonomic"}, TypicalEnergySources: []string{"EN01", "EN02"}, MapsToComponentType: "mechanical", Tags: []string{"moving_part", "rotating_part", "entanglement_risk"}, SortOrder: 3},
{ID: "C004", NameDE: "Drehtisch", NameEN: "Rotary Table", Category: "mechanical", DescriptionDE: "Rotierender Arbeitstisch fuer Bearbeitungs- oder Montageprozesse.", TypicalHazardCategories: []string{"mechanical_hazard"}, TypicalEnergySources: []string{"EN02"}, MapsToComponentType: "mechanical", Tags: []string{"rotating_part", "high_force"}, SortOrder: 4},
{ID: "C005", NameDE: "Linearachse", NameEN: "Linear Axis", Category: "mechanical", DescriptionDE: "Linearfuehrung fuer praezise translatorische Bewegungen.", TypicalHazardCategories: []string{"mechanical_hazard"}, TypicalEnergySources: []string{"EN01"}, MapsToComponentType: "mechanical", Tags: []string{"moving_part", "crush_point"}, SortOrder: 5},
{ID: "C006", NameDE: "Spindel", NameEN: "Spindle", Category: "mechanical", DescriptionDE: "Hochdrehende Spindel fuer Fräs-, Bohr- oder Schleifoperationen.", TypicalHazardCategories: []string{"mechanical_hazard", "noise_vibration"}, TypicalEnergySources: []string{"EN02"}, MapsToComponentType: "mechanical", Tags: []string{"rotating_part", "high_speed", "cutting_part"}, SortOrder: 6},
{ID: "C006", NameDE: "Spindel", NameEN: "Spindle", Category: "mechanical", DescriptionDE: "Hochdrehende Spindel fuer Fräs-, Bohr- oder Schleifoperationen.", TypicalHazardCategories: []string{"mechanical_hazard", "noise_vibration"}, TypicalEnergySources: []string{"EN02"}, MapsToComponentType: "mechanical", Tags: []string{"rotating_part", "high_speed", "cutting_part", "noise_source"}, SortOrder: 6},
{ID: "C007", NameDE: "Saegeblatt", NameEN: "Saw Blade", Category: "mechanical", DescriptionDE: "Rotierendes oder oszillierendes Schneidwerkzeug.", TypicalHazardCategories: []string{"mechanical_hazard"}, TypicalEnergySources: []string{"EN02"}, MapsToComponentType: "mechanical", Tags: []string{"cutting_part", "rotating_part", "high_speed"}, SortOrder: 7},
{ID: "C008", NameDE: "Pressenstoessel", NameEN: "Press Ram", Category: "mechanical", DescriptionDE: "Auf- und abfahrender Stoessel einer Presse zum Umformen.", TypicalHazardCategories: []string{"mechanical_hazard"}, TypicalEnergySources: []string{"EN01", "EN05"}, MapsToComponentType: "mechanical", Tags: []string{"moving_part", "high_force", "crush_point"}, SortOrder: 8},
{ID: "C009", NameDE: "Walze", NameEN: "Roller", Category: "mechanical", DescriptionDE: "Zylindrische Walze zum Foerdern, Pressen oder Kalandrieren.", TypicalHazardCategories: []string{"mechanical_hazard"}, TypicalEnergySources: []string{"EN02"}, MapsToComponentType: "mechanical", Tags: []string{"rotating_part", "entanglement_risk", "pinch_point"}, SortOrder: 9},
{ID: "C010", NameDE: "Kettenantrieb", NameEN: "Chain Drive", Category: "mechanical", DescriptionDE: "Kette und Kettenrad zur Kraftuebertragung.", TypicalHazardCategories: []string{"mechanical_hazard"}, TypicalEnergySources: []string{"EN01"}, MapsToComponentType: "mechanical", Tags: []string{"moving_part", "entanglement_risk"}, SortOrder: 10},
{ID: "C011", NameDE: "Zahnradgetriebe", NameEN: "Gear Transmission", Category: "mechanical", DescriptionDE: "Zahnradpaar oder -satz zur Drehzahl-/Drehmomentanpassung.", TypicalHazardCategories: []string{"mechanical_hazard", "noise_vibration"}, TypicalEnergySources: []string{"EN02"}, MapsToComponentType: "mechanical", Tags: []string{"rotating_part", "pinch_point"}, SortOrder: 11},
{ID: "C011", NameDE: "Zahnradgetriebe", NameEN: "Gear Transmission", Category: "mechanical", DescriptionDE: "Zahnradpaar oder -satz zur Drehzahl-/Drehmomentanpassung.", TypicalHazardCategories: []string{"mechanical_hazard", "noise_vibration"}, TypicalEnergySources: []string{"EN02"}, MapsToComponentType: "mechanical", Tags: []string{"rotating_part", "pinch_point", "noise_source"}, SortOrder: 11},
{ID: "C012", NameDE: "Kupplung", NameEN: "Clutch", Category: "mechanical", DescriptionDE: "Mechanische Kupplung zur An-/Abkopplung von Antriebsstraengen.", TypicalHazardCategories: []string{"mechanical_hazard"}, TypicalEnergySources: []string{"EN02"}, MapsToComponentType: "mechanical", Tags: []string{"rotating_part"}, SortOrder: 12},
{ID: "C013", NameDE: "Bremse", NameEN: "Brake", Category: "mechanical", DescriptionDE: "Mechanische oder elektromagnetische Bremse zum Stillsetzen von Antrieben.", TypicalHazardCategories: []string{"mechanical_hazard"}, TypicalEnergySources: []string{"EN01"}, MapsToComponentType: "mechanical", Tags: []string{"moving_part", "stored_energy"}, SortOrder: 13},
{ID: "C014", NameDE: "Hubwerk", NameEN: "Hoist", Category: "mechanical", DescriptionDE: "Hebezeug zum vertikalen Bewegen von Lasten.", TypicalHazardCategories: []string{"mechanical_hazard", "ergonomic"}, TypicalEnergySources: []string{"EN01", "EN03"}, MapsToComponentType: "mechanical", Tags: []string{"moving_part", "high_force", "gravity_risk"}, SortOrder: 14},
{ID: "C014", NameDE: "Hubwerk", NameEN: "Hoist", Category: "mechanical", DescriptionDE: "Hebezeug zum vertikalen Bewegen von Lasten.", TypicalHazardCategories: []string{"mechanical_hazard", "ergonomic"}, TypicalEnergySources: []string{"EN01", "EN03", "EN04"}, MapsToComponentType: "mechanical", Tags: []string{"moving_part", "high_force", "gravity_risk", "crush_point", "person_under_load"}, SortOrder: 14},
{ID: "C015", NameDE: "Werkzeugwechsler", NameEN: "Tool Changer", Category: "mechanical", DescriptionDE: "Automatischer Werkzeugwechsler fuer CNC-Maschinen.", TypicalHazardCategories: []string{"mechanical_hazard"}, TypicalEnergySources: []string{"EN01", "EN05"}, MapsToComponentType: "mechanical", Tags: []string{"moving_part", "pinch_point"}, SortOrder: 15},
{ID: "C016", NameDE: "Schweisskopf", NameEN: "Welding Head", Category: "mechanical", DescriptionDE: "Schweisskopf fuer MIG/MAG, WIG oder Laserschweissen.", TypicalHazardCategories: []string{"mechanical_hazard", "thermal_hazard", "electrical_hazard"}, TypicalEnergySources: []string{"EN03", "EN07"}, MapsToComponentType: "mechanical", Tags: []string{"high_temperature", "radiation_risk"}, SortOrder: 16},
{ID: "C017", NameDE: "Schraubstation", NameEN: "Screwdriving Station", Category: "mechanical", DescriptionDE: "Automatische Schraubeinheit fuer Montageprozesse.", TypicalHazardCategories: []string{"mechanical_hazard", "noise_vibration"}, TypicalEnergySources: []string{"EN02"}, MapsToComponentType: "mechanical", Tags: []string{"rotating_part"}, SortOrder: 17},
{ID: "C017", NameDE: "Schraubstation", NameEN: "Screwdriving Station", Category: "mechanical", DescriptionDE: "Automatische Schraubeinheit fuer Montageprozesse.", TypicalHazardCategories: []string{"mechanical_hazard", "noise_vibration"}, TypicalEnergySources: []string{"EN02"}, MapsToComponentType: "mechanical", Tags: []string{"rotating_part", "noise_source"}, SortOrder: 17},
{ID: "C018", NameDE: "Stanzen-Werkzeug", NameEN: "Punching Tool", Category: "mechanical", DescriptionDE: "Stanzwerkzeug zum Ausschneiden von Formen aus Blech oder Folie.", TypicalHazardCategories: []string{"mechanical_hazard"}, TypicalEnergySources: []string{"EN01"}, MapsToComponentType: "mechanical", Tags: []string{"cutting_part", "high_force", "crush_point"}, SortOrder: 18},
{ID: "C019", NameDE: "Biegewerkzeug", NameEN: "Bending Tool", Category: "mechanical", DescriptionDE: "Werkzeug zum Biegen von Blech oder Profilen.", TypicalHazardCategories: []string{"mechanical_hazard"}, TypicalEnergySources: []string{"EN01"}, MapsToComponentType: "mechanical", Tags: []string{"moving_part", "high_force", "crush_point"}, SortOrder: 19},
{ID: "C020", NameDE: "Vibrationsfoerderer", NameEN: "Vibratory Feeder", Category: "mechanical", DescriptionDE: "Schwingfoerderer zum Sortieren und Zufuehren von Kleinteilen.", TypicalHazardCategories: []string{"mechanical_hazard", "noise_vibration"}, TypicalEnergySources: []string{"EN01"}, MapsToComponentType: "mechanical", Tags: []string{"moving_part", "vibration_source"}, SortOrder: 20},
{ID: "C020", NameDE: "Vibrationsfoerderer", NameEN: "Vibratory Feeder", Category: "mechanical", DescriptionDE: "Schwingfoerderer zum Sortieren und Zufuehren von Kleinteilen.", TypicalHazardCategories: []string{"mechanical_hazard", "noise_vibration"}, TypicalEnergySources: []string{"EN01"}, MapsToComponentType: "mechanical", Tags: []string{"moving_part", "vibration_source", "noise_source"}, SortOrder: 20},
// ── Category: structural (C021-C030) ────────────────────────────────────
{ID: "C021", NameDE: "Maschinenrahmen", NameEN: "Machine Frame", Category: "structural", DescriptionDE: "Tragender Rahmen als Grundstruktur der Maschine.", TypicalHazardCategories: []string{"mechanical_hazard"}, TypicalEnergySources: []string{}, MapsToComponentType: "mechanical", Tags: []string{"structural_part"}, SortOrder: 21},
@@ -65,19 +65,19 @@ func GetComponentLibrary() []ComponentLibraryEntry {
{ID: "C030", NameDE: "Plattform/Buehne", NameEN: "Platform/Walkway", Category: "structural", DescriptionDE: "Begehbare Plattform fuer Bedienung oder Wartung in der Hoehe.", TypicalHazardCategories: []string{"ergonomic", "mechanical_hazard"}, TypicalEnergySources: []string{"EN03"}, MapsToComponentType: "mechanical", Tags: []string{"structural_part", "gravity_risk"}, SortOrder: 30},
// ── Category: drive (C031-C040) ─────────────────────────────────────────
{ID: "C031", NameDE: "Elektromotor (Drehstrom)", NameEN: "AC Motor", Category: "drive", DescriptionDE: "Drehstrom-Asynchronmotor als Hauptantrieb.", TypicalHazardCategories: []string{"electrical_hazard", "mechanical_hazard", "noise_vibration"}, TypicalEnergySources: []string{"EN02", "EN04"}, MapsToComponentType: "electrical", Tags: []string{"rotating_part", "high_voltage", "high_force"}, SortOrder: 31},
{ID: "C032", NameDE: "Servomotor", NameEN: "Servo Motor", Category: "drive", DescriptionDE: "Hochdynamischer Servomotor fuer praezise Positionierung.", TypicalHazardCategories: []string{"electrical_hazard", "mechanical_hazard"}, TypicalEnergySources: []string{"EN02", "EN04"}, MapsToComponentType: "electrical", Tags: []string{"rotating_part", "high_speed"}, SortOrder: 32},
{ID: "C033", NameDE: "Schrittmotor", NameEN: "Stepper Motor", Category: "drive", DescriptionDE: "Schrittmotor fuer inkrementelle Positionierung.", TypicalHazardCategories: []string{"electrical_hazard"}, TypicalEnergySources: []string{"EN02", "EN04"}, MapsToComponentType: "electrical", Tags: []string{"rotating_part"}, SortOrder: 33},
{ID: "C034", NameDE: "Frequenzumrichter", NameEN: "Frequency Converter", Category: "drive", DescriptionDE: "Frequenzumrichter zur stufenlosen Drehzahlregelung.", TypicalHazardCategories: []string{"electrical_hazard", "emc_hazard"}, TypicalEnergySources: []string{"EN04"}, MapsToComponentType: "electrical", Tags: []string{"high_voltage", "stored_energy"}, SortOrder: 34},
{ID: "C035", NameDE: "Getriebemotor", NameEN: "Gear Motor", Category: "drive", DescriptionDE: "Motor mit integriertem Getriebe fuer hohes Drehmoment bei niedriger Drehzahl.", TypicalHazardCategories: []string{"mechanical_hazard", "electrical_hazard"}, TypicalEnergySources: []string{"EN02", "EN04"}, MapsToComponentType: "electrical", Tags: []string{"rotating_part", "high_force"}, SortOrder: 35},
{ID: "C036", NameDE: "Linearmotor", NameEN: "Linear Motor", Category: "drive", DescriptionDE: "Elektromagnetischer Direktantrieb fuer lineare Bewegung.", TypicalHazardCategories: []string{"electrical_hazard", "mechanical_hazard"}, TypicalEnergySources: []string{"EN01", "EN04"}, MapsToComponentType: "electrical", Tags: []string{"moving_part", "high_speed"}, SortOrder: 36},
{ID: "C037", NameDE: "Torque-Motor", NameEN: "Torque Motor", Category: "drive", DescriptionDE: "Direktantriebsmotor fuer hohe Drehmomente ohne Getriebe.", TypicalHazardCategories: []string{"electrical_hazard", "mechanical_hazard"}, TypicalEnergySources: []string{"EN02", "EN04"}, MapsToComponentType: "electrical", Tags: []string{"rotating_part", "high_force"}, SortOrder: 37},
{ID: "C038", NameDE: "Elektrischer Stellantrieb", NameEN: "Electric Actuator", Category: "drive", DescriptionDE: "Elektrischer Antrieb fuer Ventile, Klappen oder Schieber.", TypicalHazardCategories: []string{"electrical_hazard"}, TypicalEnergySources: []string{"EN01", "EN04"}, MapsToComponentType: "actuator", Tags: []string{"moving_part"}, SortOrder: 38},
{ID: "C031", NameDE: "Elektromotor (Drehstrom)", NameEN: "AC Motor", Category: "drive", DescriptionDE: "Drehstrom-Asynchronmotor als Hauptantrieb.", TypicalHazardCategories: []string{"electrical_hazard", "mechanical_hazard", "noise_vibration"}, TypicalEnergySources: []string{"EN02", "EN04"}, MapsToComponentType: "electrical", Tags: []string{"rotating_part", "high_voltage", "high_force", "noise_source", "electrical_part"}, SortOrder: 31},
{ID: "C032", NameDE: "Servomotor", NameEN: "Servo Motor", Category: "drive", DescriptionDE: "Hochdynamischer Servomotor fuer praezise Positionierung.", TypicalHazardCategories: []string{"electrical_hazard", "mechanical_hazard"}, TypicalEnergySources: []string{"EN02", "EN04"}, MapsToComponentType: "electrical", Tags: []string{"rotating_part", "high_speed", "electrical_part"}, SortOrder: 32},
{ID: "C033", NameDE: "Schrittmotor", NameEN: "Stepper Motor", Category: "drive", DescriptionDE: "Schrittmotor fuer inkrementelle Positionierung.", TypicalHazardCategories: []string{"electrical_hazard"}, TypicalEnergySources: []string{"EN02", "EN04"}, MapsToComponentType: "electrical", Tags: []string{"rotating_part", "electrical_part"}, SortOrder: 33},
{ID: "C034", NameDE: "Frequenzumrichter", NameEN: "Frequency Converter", Category: "drive", DescriptionDE: "Frequenzumrichter zur stufenlosen Drehzahlregelung.", TypicalHazardCategories: []string{"electrical_hazard", "emc_hazard"}, TypicalEnergySources: []string{"EN04"}, MapsToComponentType: "electrical", Tags: []string{"high_voltage", "stored_energy", "electrical_part", "electromagnetic"}, SortOrder: 34},
{ID: "C035", NameDE: "Getriebemotor", NameEN: "Gear Motor", Category: "drive", DescriptionDE: "Motor mit integriertem Getriebe fuer hohes Drehmoment bei niedriger Drehzahl.", TypicalHazardCategories: []string{"mechanical_hazard", "electrical_hazard"}, TypicalEnergySources: []string{"EN02", "EN04"}, MapsToComponentType: "electrical", Tags: []string{"rotating_part", "high_force", "electrical_part"}, SortOrder: 35},
{ID: "C036", NameDE: "Linearmotor", NameEN: "Linear Motor", Category: "drive", DescriptionDE: "Elektromagnetischer Direktantrieb fuer lineare Bewegung.", TypicalHazardCategories: []string{"electrical_hazard", "mechanical_hazard"}, TypicalEnergySources: []string{"EN01", "EN04"}, MapsToComponentType: "electrical", Tags: []string{"moving_part", "high_speed", "electrical_part"}, SortOrder: 36},
{ID: "C037", NameDE: "Torque-Motor", NameEN: "Torque Motor", Category: "drive", DescriptionDE: "Direktantriebsmotor fuer hohe Drehmomente ohne Getriebe.", TypicalHazardCategories: []string{"electrical_hazard", "mechanical_hazard"}, TypicalEnergySources: []string{"EN02", "EN04"}, MapsToComponentType: "electrical", Tags: []string{"rotating_part", "high_force", "electrical_part"}, SortOrder: 37},
{ID: "C038", NameDE: "Elektrischer Stellantrieb", NameEN: "Electric Actuator", Category: "drive", DescriptionDE: "Elektrischer Antrieb fuer Ventile, Klappen oder Schieber.", TypicalHazardCategories: []string{"electrical_hazard"}, TypicalEnergySources: []string{"EN01", "EN04"}, MapsToComponentType: "actuator", Tags: []string{"moving_part", "electrical_part"}, SortOrder: 38},
{ID: "C039", NameDE: "Spindelantrieb", NameEN: "Spindle Drive", Category: "drive", DescriptionDE: "Kugelgewindetrieb fuer praezise Linearbewegung.", TypicalHazardCategories: []string{"mechanical_hazard"}, TypicalEnergySources: []string{"EN01"}, MapsToComponentType: "mechanical", Tags: []string{"moving_part", "crush_point"}, SortOrder: 39},
{ID: "C040", NameDE: "Riemenantrieb", NameEN: "Belt Drive", Category: "drive", DescriptionDE: "Riemen und Riemenscheiben zur Kraftuebertragung.", TypicalHazardCategories: []string{"mechanical_hazard"}, TypicalEnergySources: []string{"EN02"}, MapsToComponentType: "mechanical", Tags: []string{"rotating_part", "entanglement_risk"}, SortOrder: 40},
// ── Category: hydraulic (C041-C050) ─────────────────────────────────────
{ID: "C041", NameDE: "Hydraulikpumpe", NameEN: "Hydraulic Pump", Category: "hydraulic", DescriptionDE: "Pumpe zur Erzeugung des hydraulischen Drucks im System.", TypicalHazardCategories: []string{"pneumatic_hydraulic", "noise_vibration"}, TypicalEnergySources: []string{"EN05"}, MapsToComponentType: "actuator", Tags: []string{"hydraulic_part", "high_pressure"}, SortOrder: 41},
{ID: "C041", NameDE: "Hydraulikpumpe", NameEN: "Hydraulic Pump", Category: "hydraulic", DescriptionDE: "Pumpe zur Erzeugung des hydraulischen Drucks im System.", TypicalHazardCategories: []string{"pneumatic_hydraulic", "noise_vibration"}, TypicalEnergySources: []string{"EN05"}, MapsToComponentType: "actuator", Tags: []string{"hydraulic_part", "high_pressure", "noise_source"}, SortOrder: 41},
{ID: "C042", NameDE: "Hydraulikzylinder", NameEN: "Hydraulic Cylinder", Category: "hydraulic", DescriptionDE: "Linearaktuator zur Erzeugung hoher Kraefte.", TypicalHazardCategories: []string{"pneumatic_hydraulic", "mechanical_hazard"}, TypicalEnergySources: []string{"EN05"}, MapsToComponentType: "actuator", Tags: []string{"hydraulic_part", "moving_part", "high_force", "high_pressure"}, SortOrder: 42},
{ID: "C043", NameDE: "Hydraulikventil", NameEN: "Hydraulic Valve", Category: "hydraulic", DescriptionDE: "Steuer- oder Regelventil im Hydraulikkreislauf.", TypicalHazardCategories: []string{"pneumatic_hydraulic"}, TypicalEnergySources: []string{"EN05"}, MapsToComponentType: "actuator", Tags: []string{"hydraulic_part", "high_pressure"}, SortOrder: 43},
{ID: "C044", NameDE: "Hydraulikspeicher", NameEN: "Hydraulic Accumulator", Category: "hydraulic", DescriptionDE: "Druckspeicher zur Pufferung von Druckspitzen.", TypicalHazardCategories: []string{"pneumatic_hydraulic"}, TypicalEnergySources: []string{"EN05"}, MapsToComponentType: "actuator", Tags: []string{"hydraulic_part", "stored_energy", "high_pressure"}, SortOrder: 44},
@@ -117,33 +117,33 @@ func GetComponentLibrary() []ComponentLibraryEntry {
{ID: "C072", NameDE: "Sicherheits-SPS", NameEN: "Safety PLC", Category: "control", DescriptionDE: "Redundante Sicherheitssteuerung bis SIL 3 / PL e.", TypicalHazardCategories: []string{"safety_function_failure", "software_fault"}, TypicalEnergySources: []string{}, MapsToComponentType: "controller", Tags: []string{"has_software", "programmable", "safety_device"}, SortOrder: 72},
{ID: "C073", NameDE: "HMI (Bedienterminal)", NameEN: "HMI (Human Machine Interface)", Category: "control", DescriptionDE: "Bedienpanel mit Touchscreen zur Maschinensteuerung.", TypicalHazardCategories: []string{"hmi_error", "mode_confusion"}, TypicalEnergySources: []string{}, MapsToComponentType: "hmi", Tags: []string{"has_software", "user_interface"}, SortOrder: 73},
{ID: "C074", NameDE: "Industrierechner (IPC)", NameEN: "Industrial PC", Category: "control", DescriptionDE: "Industrie-PC fuer komplexe Steuerungs- und Datenverarbeitungsaufgaben.", TypicalHazardCategories: []string{"software_fault", "configuration_error"}, TypicalEnergySources: []string{}, MapsToComponentType: "controller", Tags: []string{"has_software", "programmable", "networked"}, SortOrder: 74},
{ID: "C075", NameDE: "Motion Controller", NameEN: "Motion Controller", Category: "control", DescriptionDE: "Achscontroller fuer synchronisierte Mehrachsbewegungen.", TypicalHazardCategories: []string{"software_fault", "mechanical_hazard"}, TypicalEnergySources: []string{}, MapsToComponentType: "controller", Tags: []string{"has_software", "programmable"}, SortOrder: 75},
{ID: "C075", NameDE: "Motion Controller", NameEN: "Motion Controller", Category: "control", DescriptionDE: "Achscontroller fuer synchronisierte Mehrachsbewegungen.", TypicalHazardCategories: []string{"software_fault", "mechanical_hazard"}, TypicalEnergySources: []string{}, MapsToComponentType: "controller", Tags: []string{"has_software", "programmable", "moving_part"}, SortOrder: 75},
{ID: "C076", NameDE: "Sicherheitsrelais", NameEN: "Safety Relay", Category: "control", DescriptionDE: "Sicherheitsschaltgeraet fuer Not-Halt, Schutztuer etc.", TypicalHazardCategories: []string{"safety_function_failure"}, TypicalEnergySources: []string{}, MapsToComponentType: "controller", Tags: []string{"safety_device"}, SortOrder: 76},
{ID: "C077", NameDE: "Antriebsregler", NameEN: "Drive Controller", Category: "control", DescriptionDE: "Intelligenter Antriebsregler mit integrierten Sicherheitsfunktionen.", TypicalHazardCategories: []string{"software_fault", "electrical_hazard"}, TypicalEnergySources: []string{"EN04"}, MapsToComponentType: "controller", Tags: []string{"has_software", "programmable"}, SortOrder: 77},
{ID: "C077", NameDE: "Antriebsregler", NameEN: "Drive Controller", Category: "control", DescriptionDE: "Intelligenter Antriebsregler mit integrierten Sicherheitsfunktionen.", TypicalHazardCategories: []string{"software_fault", "electrical_hazard"}, TypicalEnergySources: []string{"EN04"}, MapsToComponentType: "controller", Tags: []string{"has_software", "programmable", "electrical_part"}, SortOrder: 77},
{ID: "C078", NameDE: "Remote I/O", NameEN: "Remote I/O Module", Category: "control", DescriptionDE: "Dezentrales Ein-/Ausgangsmodul im Feldbus.", TypicalHazardCategories: []string{"communication_failure"}, TypicalEnergySources: []string{}, MapsToComponentType: "controller", Tags: []string{"networked"}, SortOrder: 78},
{ID: "C079", NameDE: "Bedienpult", NameEN: "Control Desk", Category: "control", DescriptionDE: "Zentrales Bedienpult mit Tastern, Schaltern und Anzeigen.", TypicalHazardCategories: []string{"hmi_error", "mode_confusion"}, TypicalEnergySources: []string{}, MapsToComponentType: "hmi", Tags: []string{"user_interface"}, SortOrder: 79},
{ID: "C080", NameDE: "Datenschreiber/Logger", NameEN: "Data Logger", Category: "control", DescriptionDE: "Geraet zur Aufzeichnung von Prozessparametern.", TypicalHazardCategories: []string{"logging_audit_failure"}, TypicalEnergySources: []string{}, MapsToComponentType: "controller", Tags: []string{"has_software"}, SortOrder: 80},
// ── Category: sensor (C081-C090) ────────────────────────────────────────
{ID: "C081", NameDE: "Positionssensor", NameEN: "Position Sensor", Category: "sensor", DescriptionDE: "Induktiver, kapazitiver oder optischer Positionssensor.", TypicalHazardCategories: []string{"sensor_spoofing"}, TypicalEnergySources: []string{}, MapsToComponentType: "sensor", Tags: []string{"sensor_part"}, SortOrder: 81},
{ID: "C082", NameDE: "Kamerasystem", NameEN: "Camera System", Category: "sensor", DescriptionDE: "Industriekamera fuer Bildverarbeitung und Qualitaetskontrolle.", TypicalHazardCategories: []string{"sensor_spoofing", "false_classification"}, TypicalEnergySources: []string{}, MapsToComponentType: "sensor", Tags: []string{"sensor_part", "networked"}, SortOrder: 82},
{ID: "C083", NameDE: "Kraftsensor", NameEN: "Force Sensor", Category: "sensor", DescriptionDE: "Dehnungsmessstreifen oder piezoelektrischer Kraftsensor.", TypicalHazardCategories: []string{"sensor_spoofing"}, TypicalEnergySources: []string{}, MapsToComponentType: "sensor", Tags: []string{"sensor_part"}, SortOrder: 83},
{ID: "C084", NameDE: "Temperatursensor", NameEN: "Temperature Sensor", Category: "sensor", DescriptionDE: "Thermocouple oder PT100 zur Temperaturueberwachung.", TypicalHazardCategories: []string{"sensor_spoofing"}, TypicalEnergySources: []string{}, MapsToComponentType: "sensor", Tags: []string{"sensor_part"}, SortOrder: 84},
{ID: "C085", NameDE: "Drucksensor", NameEN: "Pressure Sensor", Category: "sensor", DescriptionDE: "Sensor zur Ueberwachung von Druck in Hydraulik- oder Pneumatiksystemen.", TypicalHazardCategories: []string{"sensor_spoofing"}, TypicalEnergySources: []string{}, MapsToComponentType: "sensor", Tags: []string{"sensor_part"}, SortOrder: 85},
{ID: "C086", NameDE: "Drehgeber/Encoder", NameEN: "Rotary Encoder", Category: "sensor", DescriptionDE: "Absolut- oder Inkrementaldrehgeber zur Winkel-/Positionsmessung.", TypicalHazardCategories: []string{"sensor_spoofing"}, TypicalEnergySources: []string{}, MapsToComponentType: "sensor", Tags: []string{"sensor_part"}, SortOrder: 86},
{ID: "C087", NameDE: "Laserscanner", NameEN: "Laser Scanner", Category: "sensor", DescriptionDE: "Sicherheits-Laserscanner zur Ueberwachung von Schutzzonen.", TypicalHazardCategories: []string{"sensor_spoofing", "safety_function_failure"}, TypicalEnergySources: []string{}, MapsToComponentType: "sensor", Tags: []string{"sensor_part", "safety_device"}, SortOrder: 87},
{ID: "C088", NameDE: "Beschleunigungssensor", NameEN: "Accelerometer", Category: "sensor", DescriptionDE: "Sensor zur Vibrations- und Beschleunigungsmessung.", TypicalHazardCategories: []string{"sensor_spoofing"}, TypicalEnergySources: []string{}, MapsToComponentType: "sensor", Tags: []string{"sensor_part"}, SortOrder: 88},
{ID: "C089", NameDE: "Durchflusssensor", NameEN: "Flow Sensor", Category: "sensor", DescriptionDE: "Sensor zur Ueberwachung des Volumenstrom.", TypicalHazardCategories: []string{"sensor_spoofing"}, TypicalEnergySources: []string{}, MapsToComponentType: "sensor", Tags: []string{"sensor_part"}, SortOrder: 89},
{ID: "C090", NameDE: "Fuellstandsensor", NameEN: "Level Sensor", Category: "sensor", DescriptionDE: "Sensor zur Ueberwachung des Fuellstands in Tanks und Behaeltern.", TypicalHazardCategories: []string{"sensor_spoofing"}, TypicalEnergySources: []string{}, MapsToComponentType: "sensor", Tags: []string{"sensor_part"}, SortOrder: 90},
{ID: "C081", NameDE: "Positionssensor", NameEN: "Position Sensor", Category: "sensor", DescriptionDE: "Induktiver, kapazitiver oder optischer Positionssensor.", TypicalHazardCategories: []string{"sensor_spoofing"}, TypicalEnergySources: []string{}, MapsToComponentType: "sensor", Tags: []string{"sensor_part", "cyber"}, SortOrder: 81},
{ID: "C082", NameDE: "Kamerasystem", NameEN: "Camera System", Category: "sensor", DescriptionDE: "Industriekamera fuer Bildverarbeitung und Qualitaetskontrolle.", TypicalHazardCategories: []string{"sensor_spoofing", "false_classification"}, TypicalEnergySources: []string{}, MapsToComponentType: "sensor", Tags: []string{"sensor_part", "networked", "cyber", "has_ai"}, SortOrder: 82},
{ID: "C083", NameDE: "Kraftsensor", NameEN: "Force Sensor", Category: "sensor", DescriptionDE: "Dehnungsmessstreifen oder piezoelektrischer Kraftsensor.", TypicalHazardCategories: []string{"sensor_spoofing"}, TypicalEnergySources: []string{}, MapsToComponentType: "sensor", Tags: []string{"sensor_part", "cyber"}, SortOrder: 83},
{ID: "C084", NameDE: "Temperatursensor", NameEN: "Temperature Sensor", Category: "sensor", DescriptionDE: "Thermocouple oder PT100 zur Temperaturueberwachung.", TypicalHazardCategories: []string{"sensor_spoofing"}, TypicalEnergySources: []string{}, MapsToComponentType: "sensor", Tags: []string{"sensor_part", "cyber"}, SortOrder: 84},
{ID: "C085", NameDE: "Drucksensor", NameEN: "Pressure Sensor", Category: "sensor", DescriptionDE: "Sensor zur Ueberwachung von Druck in Hydraulik- oder Pneumatiksystemen.", TypicalHazardCategories: []string{"sensor_spoofing"}, TypicalEnergySources: []string{}, MapsToComponentType: "sensor", Tags: []string{"sensor_part", "cyber"}, SortOrder: 85},
{ID: "C086", NameDE: "Drehgeber/Encoder", NameEN: "Rotary Encoder", Category: "sensor", DescriptionDE: "Absolut- oder Inkrementaldrehgeber zur Winkel-/Positionsmessung.", TypicalHazardCategories: []string{"sensor_spoofing"}, TypicalEnergySources: []string{}, MapsToComponentType: "sensor", Tags: []string{"sensor_part", "cyber"}, SortOrder: 86},
{ID: "C087", NameDE: "Laserscanner", NameEN: "Laser Scanner", Category: "sensor", DescriptionDE: "Sicherheits-Laserscanner zur Ueberwachung von Schutzzonen.", TypicalHazardCategories: []string{"sensor_spoofing", "safety_function_failure"}, TypicalEnergySources: []string{}, MapsToComponentType: "sensor", Tags: []string{"sensor_part", "safety_device", "cyber"}, SortOrder: 87},
{ID: "C088", NameDE: "Beschleunigungssensor", NameEN: "Accelerometer", Category: "sensor", DescriptionDE: "Sensor zur Vibrations- und Beschleunigungsmessung.", TypicalHazardCategories: []string{"sensor_spoofing"}, TypicalEnergySources: []string{}, MapsToComponentType: "sensor", Tags: []string{"sensor_part", "cyber"}, SortOrder: 88},
{ID: "C089", NameDE: "Durchflusssensor", NameEN: "Flow Sensor", Category: "sensor", DescriptionDE: "Sensor zur Ueberwachung des Volumenstrom.", TypicalHazardCategories: []string{"sensor_spoofing"}, TypicalEnergySources: []string{}, MapsToComponentType: "sensor", Tags: []string{"sensor_part", "cyber"}, SortOrder: 89},
{ID: "C090", NameDE: "Fuellstandsensor", NameEN: "Level Sensor", Category: "sensor", DescriptionDE: "Sensor zur Ueberwachung des Fuellstands in Tanks und Behaeltern.", TypicalHazardCategories: []string{"sensor_spoofing"}, TypicalEnergySources: []string{}, MapsToComponentType: "sensor", Tags: []string{"sensor_part", "cyber"}, SortOrder: 90},
// ── Category: actuator (C091-C100) ──────────────────────────────────────
{ID: "C091", NameDE: "Magnetventil", NameEN: "Solenoid Valve", Category: "actuator", DescriptionDE: "Elektromagnetisch betaetigtes Ventil fuer Pneumatik oder Hydraulik.", TypicalHazardCategories: []string{"pneumatic_hydraulic"}, TypicalEnergySources: []string{"EN05", "EN06"}, MapsToComponentType: "actuator", Tags: []string{"actuator_part"}, SortOrder: 91},
{ID: "C091", NameDE: "Magnetventil", NameEN: "Solenoid Valve", Category: "actuator", DescriptionDE: "Elektromagnetisch betaetigtes Ventil fuer Pneumatik oder Hydraulik.", TypicalHazardCategories: []string{"pneumatic_hydraulic"}, TypicalEnergySources: []string{"EN05", "EN06"}, MapsToComponentType: "actuator", Tags: []string{"actuator_part", "hydraulic_part", "pneumatic_part", "high_pressure"}, SortOrder: 91},
{ID: "C092", NameDE: "Linearantrieb (elektrisch)", NameEN: "Electric Linear Actuator", Category: "actuator", DescriptionDE: "Elektrischer Linearantrieb fuer Positionieraufgaben.", TypicalHazardCategories: []string{"mechanical_hazard", "electrical_hazard"}, TypicalEnergySources: []string{"EN01", "EN04"}, MapsToComponentType: "actuator", Tags: []string{"actuator_part", "moving_part"}, SortOrder: 92},
{ID: "C093", NameDE: "Proportionalventil", NameEN: "Proportional Valve", Category: "actuator", DescriptionDE: "Stetig regelbares Ventil fuer praezise Drucksteuerung.", TypicalHazardCategories: []string{"pneumatic_hydraulic"}, TypicalEnergySources: []string{"EN05", "EN06"}, MapsToComponentType: "actuator", Tags: []string{"actuator_part"}, SortOrder: 93},
{ID: "C093", NameDE: "Proportionalventil", NameEN: "Proportional Valve", Category: "actuator", DescriptionDE: "Stetig regelbares Ventil fuer praezise Drucksteuerung.", TypicalHazardCategories: []string{"pneumatic_hydraulic"}, TypicalEnergySources: []string{"EN05", "EN06"}, MapsToComponentType: "actuator", Tags: []string{"actuator_part", "hydraulic_part", "pneumatic_part", "high_pressure"}, SortOrder: 93},
{ID: "C094", NameDE: "Heizelement", NameEN: "Heating Element", Category: "actuator", DescriptionDE: "Elektrisches Heizelement fuer Temperierung von Werkzeugen oder Medien.", TypicalHazardCategories: []string{"thermal_hazard", "electrical_hazard"}, TypicalEnergySources: []string{"EN07"}, MapsToComponentType: "actuator", Tags: []string{"actuator_part", "high_temperature"}, SortOrder: 94},
{ID: "C095", NameDE: "Kuehlaggregat", NameEN: "Cooling Unit", Category: "actuator", DescriptionDE: "Kuehlanlage fuer Prozesse oder Schaltschraenke.", TypicalHazardCategories: []string{"thermal_hazard"}, TypicalEnergySources: []string{"EN07"}, MapsToComponentType: "actuator", Tags: []string{"actuator_part"}, SortOrder: 95},
{ID: "C096", NameDE: "Luefter/Geblaese", NameEN: "Fan/Blower", Category: "actuator", DescriptionDE: "Luefter zur Kuehlung oder Absaugung.", TypicalHazardCategories: []string{"mechanical_hazard", "noise_vibration"}, TypicalEnergySources: []string{"EN02"}, MapsToComponentType: "actuator", Tags: []string{"actuator_part", "rotating_part"}, SortOrder: 96},
{ID: "C097", NameDE: "Dosierpumpe", NameEN: "Dosing Pump", Category: "actuator", DescriptionDE: "Praezisionspumpe zur Dosierung von Fluessigkeiten oder Klebstoffen.", TypicalHazardCategories: []string{"pneumatic_hydraulic", "material_environmental"}, TypicalEnergySources: []string{"EN05"}, MapsToComponentType: "actuator", Tags: []string{"actuator_part"}, SortOrder: 97},
{ID: "C096", NameDE: "Luefter/Geblaese", NameEN: "Fan/Blower", Category: "actuator", DescriptionDE: "Luefter zur Kuehlung oder Absaugung.", TypicalHazardCategories: []string{"mechanical_hazard", "noise_vibration"}, TypicalEnergySources: []string{"EN02"}, MapsToComponentType: "actuator", Tags: []string{"actuator_part", "rotating_part", "noise_source"}, SortOrder: 96},
{ID: "C097", NameDE: "Dosierpumpe", NameEN: "Dosing Pump", Category: "actuator", DescriptionDE: "Praezisionspumpe zur Dosierung von Fluessigkeiten oder Klebstoffen.", TypicalHazardCategories: []string{"pneumatic_hydraulic", "material_environmental"}, TypicalEnergySources: []string{"EN05"}, MapsToComponentType: "actuator", Tags: []string{"actuator_part", "hydraulic_part", "chemical_risk"}, SortOrder: 97},
{ID: "C098", NameDE: "Elektromagnet", NameEN: "Electromagnet", Category: "actuator", DescriptionDE: "Elektromagnet fuer Halten, Spannen oder Foerdern.", TypicalHazardCategories: []string{"electrical_hazard", "emc_hazard"}, TypicalEnergySources: []string{"EN04"}, MapsToComponentType: "actuator", Tags: []string{"actuator_part", "stored_energy"}, SortOrder: 98},
{ID: "C099", NameDE: "Piezo-Aktuator", NameEN: "Piezo Actuator", Category: "actuator", DescriptionDE: "Piezoelektrischer Aktuator fuer hochpraezise Mikrobewegungen.", TypicalHazardCategories: []string{"electrical_hazard"}, TypicalEnergySources: []string{"EN04"}, MapsToComponentType: "actuator", Tags: []string{"actuator_part"}, SortOrder: 99},
{ID: "C100", NameDE: "Spannvorrichtung", NameEN: "Clamping Device", Category: "actuator", DescriptionDE: "Mechanische, pneumatische oder hydraulische Spannvorrichtung.", TypicalHazardCategories: []string{"mechanical_hazard"}, TypicalEnergySources: []string{"EN01", "EN05", "EN06"}, MapsToComponentType: "actuator", Tags: []string{"actuator_part", "clamping_part", "pinch_point"}, SortOrder: 100},
@@ -161,15 +161,15 @@ func GetComponentLibrary() []ComponentLibraryEntry {
{ID: "C110", NameDE: "Zustimmtaster", NameEN: "Enabling Device", Category: "safety", DescriptionDE: "Dreistufiger Zustimmtaster fuer den Einrichtbetrieb.", TypicalHazardCategories: []string{"safety_function_failure"}, TypicalEnergySources: []string{}, MapsToComponentType: "controller", Tags: []string{"safety_device"}, SortOrder: 110},
// ── Category: it_network (C111-C120) ────────────────────────────────────
{ID: "C111", NameDE: "Industrie-Switch (managed)", NameEN: "Managed Industrial Switch", Category: "it_network", DescriptionDE: "Managed Ethernet Switch fuer das Maschinennetzwerk.", TypicalHazardCategories: []string{"communication_failure", "unauthorized_access"}, TypicalEnergySources: []string{}, MapsToComponentType: "network", Tags: []string{"networked", "it_component"}, SortOrder: 111},
{ID: "C112", NameDE: "Industrie-Router", NameEN: "Industrial Router", Category: "it_network", DescriptionDE: "Router zur Segmentierung und Absicherung des Maschinennetzwerks.", TypicalHazardCategories: []string{"communication_failure", "unauthorized_access"}, TypicalEnergySources: []string{}, MapsToComponentType: "network", Tags: []string{"networked", "it_component"}, SortOrder: 112},
{ID: "C111", NameDE: "Industrie-Switch (managed)", NameEN: "Managed Industrial Switch", Category: "it_network", DescriptionDE: "Managed Ethernet Switch fuer das Maschinennetzwerk.", TypicalHazardCategories: []string{"communication_failure", "unauthorized_access"}, TypicalEnergySources: []string{}, MapsToComponentType: "network", Tags: []string{"networked", "it_component", "cyber"}, SortOrder: 111},
{ID: "C112", NameDE: "Industrie-Router", NameEN: "Industrial Router", Category: "it_network", DescriptionDE: "Router zur Segmentierung und Absicherung des Maschinennetzwerks.", TypicalHazardCategories: []string{"communication_failure", "unauthorized_access"}, TypicalEnergySources: []string{}, MapsToComponentType: "network", Tags: []string{"networked", "it_component", "cyber"}, SortOrder: 112},
{ID: "C113", NameDE: "Industrie-Firewall", NameEN: "Industrial Firewall", Category: "it_network", DescriptionDE: "Firewall zum Schutz des OT-Netzwerks vor externen Angriffen.", TypicalHazardCategories: []string{"unauthorized_access"}, TypicalEnergySources: []string{}, MapsToComponentType: "network", Tags: []string{"networked", "it_component", "security_device"}, SortOrder: 113},
{ID: "C114", NameDE: "IoT-Gateway", NameEN: "IoT Gateway", Category: "it_network", DescriptionDE: "Gateway fuer die Anbindung von Maschinen an Cloud/Edge.", TypicalHazardCategories: []string{"communication_failure", "unauthorized_access"}, TypicalEnergySources: []string{}, MapsToComponentType: "network", Tags: []string{"networked", "it_component", "has_software"}, SortOrder: 114},
{ID: "C115", NameDE: "Edge-Computing-Einheit", NameEN: "Edge Computing Unit", Category: "it_network", DescriptionDE: "Lokale Recheneinheit fuer Datenvorverarbeitung und KI-Inferenz.", TypicalHazardCategories: []string{"software_fault", "communication_failure"}, TypicalEnergySources: []string{}, MapsToComponentType: "network", Tags: []string{"networked", "it_component", "has_software", "has_ai"}, SortOrder: 115},
{ID: "C116", NameDE: "WLAN Access Point (Industrie)", NameEN: "Industrial WiFi Access Point", Category: "it_network", DescriptionDE: "Drahtloser Netzwerkzugang im Maschinenumfeld.", TypicalHazardCategories: []string{"communication_failure", "unauthorized_access"}, TypicalEnergySources: []string{}, MapsToComponentType: "network", Tags: []string{"networked", "it_component", "wireless"}, SortOrder: 116},
{ID: "C116", NameDE: "WLAN Access Point (Industrie)", NameEN: "Industrial WiFi Access Point", Category: "it_network", DescriptionDE: "Drahtloser Netzwerkzugang im Maschinenumfeld.", TypicalHazardCategories: []string{"communication_failure", "unauthorized_access"}, TypicalEnergySources: []string{}, MapsToComponentType: "network", Tags: []string{"networked", "it_component", "wireless", "cyber"}, SortOrder: 116},
{ID: "C117", NameDE: "OPC UA Server", NameEN: "OPC UA Server", Category: "it_network", DescriptionDE: "OPC UA Kommunikationsserver fuer Maschine-zu-Maschine-Vernetzung.", TypicalHazardCategories: []string{"communication_failure", "unauthorized_access"}, TypicalEnergySources: []string{}, MapsToComponentType: "network", Tags: []string{"networked", "it_component", "has_software"}, SortOrder: 117},
{ID: "C118", NameDE: "VPN-Appliance", NameEN: "VPN Appliance", Category: "it_network", DescriptionDE: "VPN-Geraet fuer sichere Fernzugriffe auf die Maschinensteuerung.", TypicalHazardCategories: []string{"unauthorized_access"}, TypicalEnergySources: []string{}, MapsToComponentType: "network", Tags: []string{"networked", "it_component", "security_device"}, SortOrder: 118},
{ID: "C119", NameDE: "KI-Inferenzmodul", NameEN: "AI Inference Module", Category: "it_network", DescriptionDE: "Dediziertes KI-Modul (GPU/TPU) fuer Echtzeit-Inferenz.", TypicalHazardCategories: []string{"false_classification", "model_drift", "unintended_bias"}, TypicalEnergySources: []string{}, MapsToComponentType: "network", Tags: []string{"has_ai", "has_software", "networked"}, SortOrder: 119},
{ID: "C119", NameDE: "KI-Inferenzmodul", NameEN: "AI Inference Module", Category: "it_network", DescriptionDE: "Dediziertes KI-Modul (GPU/TPU) fuer Echtzeit-Inferenz.", TypicalHazardCategories: []string{"false_classification", "model_drift", "unintended_bias"}, TypicalEnergySources: []string{}, MapsToComponentType: "network", Tags: []string{"has_ai", "ai_model", "has_software", "networked", "cyber"}, SortOrder: 119},
{ID: "C120", NameDE: "Feldbus-Koppler", NameEN: "Fieldbus Coupler", Category: "it_network", DescriptionDE: "Koppler fuer PROFINET, EtherCAT oder andere Feldbussysteme.", TypicalHazardCategories: []string{"communication_failure"}, TypicalEnergySources: []string{}, MapsToComponentType: "network", Tags: []string{"networked", "it_component"}, SortOrder: 120},
// ── Extended: Press/Forming Machine Components (C121-C135) ───────────
@@ -180,7 +180,7 @@ func GetComponentLibrary() []ComponentLibraryEntry {
{ID: "C125", NameDE: "Ruettelplatte / Vibrationsfoerderer", NameEN: "Vibrating Plate / Feeder", Category: "mechanical", DescriptionDE: "Vibrationseinheit zum Sortieren, Ausrichten oder Foerdern von Teilen.", TypicalHazardCategories: []string{"noise_vibration", "ergonomic"}, TypicalEnergySources: []string{"EN01"}, MapsToComponentType: "mechanical", Tags: []string{"vibration_source", "noise_source", "moving_part"}, SortOrder: 125},
{ID: "C126", NameDE: "Stempel-Formen-System", NameEN: "Die/Punch Tooling System", Category: "mechanical", DescriptionDE: "Werkzeugset aus Stempel und Matrize fuer Umform- oder Stanzvorgaenge.", TypicalHazardCategories: []string{"mechanical_hazard"}, TypicalEnergySources: []string{"EN01"}, MapsToComponentType: "mechanical", Tags: []string{"moving_part", "high_force", "crush_point", "cutting_part"}, SortOrder: 126},
{ID: "C127", NameDE: "Transfersystem (Stangen/Greifer)", NameEN: "Transfer System (Bar/Gripper)", Category: "mechanical", DescriptionDE: "Mechanisches Transportsystem zwischen Bearbeitungsstationen.", TypicalHazardCategories: []string{"mechanical_hazard"}, TypicalEnergySources: []string{"EN01"}, MapsToComponentType: "mechanical", Tags: []string{"moving_part", "shear_risk", "pinch_point"}, SortOrder: 127},
{ID: "C128", NameDE: "Aufzugsportal / Hubwerk", NameEN: "Elevator Portal / Hoist", Category: "mechanical", DescriptionDE: "Hebevorrichtung fuer Materialzufuhr (Kisten, Paletten).", TypicalHazardCategories: []string{"mechanical_hazard"}, TypicalEnergySources: []string{"EN01", "EN04"}, MapsToComponentType: "mechanical", Tags: []string{"moving_part", "gravity_risk", "high_force", "person_under_load"}, SortOrder: 128},
{ID: "C128", NameDE: "Aufzugsportal / Hubwerk", NameEN: "Elevator Portal / Hoist", Category: "mechanical", DescriptionDE: "Hebevorrichtung fuer Materialzufuhr (Kisten, Paletten).", TypicalHazardCategories: []string{"mechanical_hazard"}, TypicalEnergySources: []string{"EN01", "EN03", "EN04"}, MapsToComponentType: "mechanical", Tags: []string{"moving_part", "gravity_risk", "high_force", "person_under_load", "crush_point"}, SortOrder: 128},
{ID: "C129", NameDE: "Fallrohr / Auswurfschacht", NameEN: "Chute / Ejection Channel", Category: "structural", DescriptionDE: "Schwerkraft-basierter Auswurf fuer fertige oder aussortierte Teile.", TypicalHazardCategories: []string{"mechanical_hazard"}, TypicalEnergySources: []string{"EN04"}, MapsToComponentType: "mechanical", Tags: []string{"gravity_risk"}, SortOrder: 129},
{ID: "C130", NameDE: "Oelfangschale / Auffangwanne", NameEN: "Oil Drip Tray", Category: "structural", DescriptionDE: "Auffangvorrichtung fuer Hydraulikoel, Schmiermittel, Kuehlmittel.", TypicalHazardCategories: []string{"material_environmental"}, TypicalEnergySources: []string{}, MapsToComponentType: "mechanical", Tags: []string{"chemical_risk"}, SortOrder: 130},
{ID: "C131", NameDE: "Druckbegrenzungsventil", NameEN: "Pressure Relief Valve", Category: "hydraulic", DescriptionDE: "Sicherheitsventil zur Druckbegrenzung im Hydraulikkreis.", TypicalHazardCategories: []string{"pneumatic_hydraulic"}, TypicalEnergySources: []string{"EN07"}, MapsToComponentType: "actuator", Tags: []string{"hydraulic_part", "safety_device", "high_pressure"}, SortOrder: 131},
@@ -29,8 +29,18 @@ func GetKeywordDictionary() []KeywordEntry {
// ── Foerdertechnik ──────────────────────────────────────────────
{Keywords: []string{"foerderband", "transportband", "conveyor"}, ComponentIDs: []string{"C003"}, EnergyIDs: []string{"EN01", "EN02"}, ExtraTags: []string{"entanglement_risk"}},
{Keywords: []string{"transfer", "transferanlage", "transfersystem"}, ComponentIDs: []string{"C127"}, ExtraTags: []string{"shear_risk", "pinch_point"}},
{Keywords: []string{"aufzug", "elevator", "lift"}, ComponentIDs: []string{"C014", "C128"}, EnergyIDs: []string{"EN04"}, ExtraTags: []string{"gravity_risk", "person_under_load"}},
{Keywords: []string{"hubwerk", "hoist", "hubgeraet"}, ComponentIDs: []string{"C128"}, EnergyIDs: []string{"EN04"}, ExtraTags: []string{"gravity_risk", "person_under_load"}},
// Hubgeraete: korrigiert auf EN03 (Potentielle/Gravitational) statt
// nur EN04 (Elektrisch). Audit-Methode A zeigte, dass HP1014/HP1015/
// HP1017/HP1018 (alle Quetsch-Patterns unter absenkender Last) nicht
// zuendeten weil sowohl crush_point als auch gravitational fehlten.
// EN04 bleibt fuer Steuerstrom-bezogene Patterns mit drin.
{Keywords: []string{"aufzug", "elevator", "lift"}, ComponentIDs: []string{"C014", "C128"}, EnergyIDs: []string{"EN03", "EN04"}, ExtraTags: []string{"gravity_risk", "person_under_load", "crush_point"}},
{Keywords: []string{"hubwerk", "hoist", "hubgeraet"}, ComponentIDs: []string{"C128"}, EnergyIDs: []string{"EN03", "EN04"}, ExtraTags: []string{"gravity_risk", "person_under_load", "crush_point"}},
// Hub-Verben aus Methode-C-Vocabulary-Diff: "absenken/senken/
// anheben/heben/hubhoehe" tauchten im Limits-Form auf, der Parser
// kannte sie nicht. Konservativ EN03 + Tags, Component bleibt offen.
{Keywords: []string{"absenk", "senken", "anheben", "heben"}, EnergyIDs: []string{"EN03"}, ExtraTags: []string{"gravity_risk", "person_under_load", "crush_point"}},
{Keywords: []string{"hubhoehe", "hubweg", "hubgeschwindig"}, EnergyIDs: []string{"EN03"}, ExtraTags: []string{"gravity_risk", "crush_point"}},
{Keywords: []string{"ruettel", "vibration", "vibrationsfoerderer"}, ComponentIDs: []string{"C125"}, ExtraTags: []string{"vibration_source", "noise_source"}},
{Keywords: []string{"fallrohr", "auswurf", "chute"}, ComponentIDs: []string{"C129"}, EnergyIDs: []string{"EN04"}, ExtraTags: []string{"gravity_risk"}},
{Keywords: []string{"kistenwechsel", "bin change"}, ComponentIDs: []string{"C134"}, ExtraTags: []string{"ergonomic", "gravity_risk"}},
@@ -60,7 +60,30 @@ func (tr *TagResolver) ResolveEnergyTags(energyIDs []string) []string {
return tags
}
// ResolveTags combines component, energy, and custom tags into a unified set.
// tagSynonyms maps short pattern-side tag names to the canonical
// library-side tags. The library uses descriptive identifiers
// ("electrical_energy") while many patterns were authored with short
// forms ("electrical"). Without this map, the pattern's RequiredTag
// "electrical" never matches a real component's "electrical_energy",
// and the entire pattern silently never fires. The audit (Method A)
// surfaced ~40 such ghost-patterns.
//
// Each entry expands the parser's tag set when a known synonym appears,
// so both forms work for matching. This is the least-invasive fix —
// no pattern bodies are touched. The long-term goal is to converge
// on a single canonical vocabulary; until then the map documents which
// pairs are considered equivalent.
var tagSynonyms = map[string][]string{
"electrical_energy": {"electrical"},
"pneumatic_pressure": {"pneumatic"},
"hydraulic_pressure": {"hydraulic"},
"electrical": {"electrical_energy"},
"pneumatic": {"pneumatic_pressure"},
"hydraulic": {"hydraulic_pressure"},
}
// ResolveTags combines component, energy, and custom tags into a unified set,
// applying the synonym map so patterns authored with either tag form match.
func (tr *TagResolver) ResolveTags(componentIDs, energyIDs, customTags []string) []string {
seen := make(map[string]bool)
var all []string
@@ -71,6 +94,12 @@ func (tr *TagResolver) ResolveTags(componentIDs, energyIDs, customTags []string)
seen[t] = true
all = append(all, t)
}
for _, syn := range tagSynonyms[t] {
if !seen[syn] {
seen[syn] = true
all = append(all, syn)
}
}
}
}
@@ -71,6 +71,8 @@ _ROUTER_MODULES = [
"compliance_report_routes",
"whistleblower_routes",
"tcf_routes",
"founding_wizard_routes",
"licenses_routes",
]
_loaded_count = 0
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,285 @@
"""
P18 Erweiterter Banner-Block fuer die Email.
Rendert die Daten aus dem consent-tester die heute weggeworfen wurden:
- 3-Phasen-Cookie-Tabelle (before_consent / after_reject / after_accept)
- Banner-Quality-Score (completeness/correctness/violations)
- Per-Category-Tracker-Listing
- Violations-Liste mit Rechtsgrundlagen
"""
from __future__ import annotations
def _color_for(pct: int) -> str:
return ("#16a34a" if pct >= 80 else
"#d97706" if pct >= 50 else "#dc2626")
def _short_phase_label(key: str) -> str:
return {
"before_consent": "Vor Consent",
"after_reject": "Nach Ablehnung",
"after_accept": "Nach Annahme",
}.get(key, key)
def _phase_color(key: str, cookie_count: int) -> str:
if key == "before_consent":
return "#16a34a" if cookie_count == 0 else "#dc2626"
if key == "after_reject":
return "#16a34a" if cookie_count <= 1 else "#d97706"
return "#94a3b8"
def build_banner_deep_html(banner_result: dict | None) -> str:
"""Render: Banner-Quality + Phases + Violations.
Konsumiert das volle consent-tester-Response. Komplementiert
`build_provider_list_html` (das nur Summary + TCF-Vendor-Tabelle macht).
"""
if not banner_result:
return ""
parts: list[str] = [
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
'max-width:700px;margin:0 auto 16px;padding:14px 18px;'
'background:#fff;border:1px solid #cbd5e1;border-radius:8px">'
'<h3 style="margin:0 0 12px;font-size:14px;color:#0f172a">'
'Cookie-Banner — technische Analyse</h3>'
]
# 1) Quality-Score-Cards
compl = banner_result.get("completeness_pct")
corr = banner_result.get("correctness_pct")
summary = banner_result.get("summary") or {}
n_critical = summary.get("critical", 0)
n_high = summary.get("high", 0)
if compl is not None or corr is not None:
parts.append(
'<table style="width:100%;border-collapse:separate;'
'border-spacing:6px;margin-bottom:10px"><tr>'
)
if compl is not None:
c = _color_for(int(compl))
parts.append(
f'<td style="width:33%;padding:8px 10px;background:#f8fafc;'
f'border-radius:5px;border-left:3px solid {c}">'
f'<div style="font-size:10px;color:#64748b;text-transform:uppercase">'
f'Vollstaendigkeit</div>'
f'<div style="font-size:18px;font-weight:700;color:{c}">{compl}%</div>'
f'</td>'
)
if corr is not None:
c = _color_for(int(corr))
parts.append(
f'<td style="width:33%;padding:8px 10px;background:#f8fafc;'
f'border-radius:5px;border-left:3px solid {c}">'
f'<div style="font-size:10px;color:#64748b;text-transform:uppercase">'
f'Korrektheit</div>'
f'<div style="font-size:18px;font-weight:700;color:{c}">{corr}%</div>'
f'</td>'
)
viol_c = ("#dc2626" if n_critical + n_high > 0 else
"#d97706" if (summary.get("total_violations") or 0) > 0 else
"#16a34a")
parts.append(
f'<td style="width:33%;padding:8px 10px;background:#f8fafc;'
f'border-radius:5px;border-left:3px solid {viol_c}">'
f'<div style="font-size:10px;color:#64748b;text-transform:uppercase">'
f'Verstoesse</div>'
f'<div style="font-size:18px;font-weight:700;color:{viol_c}">'
f'{summary.get("total_violations", 0)}'
f'<span style="font-size:11px;color:#64748b;margin-left:6px">'
f'(crit:{n_critical} high:{n_high})</span></div></td>'
)
parts.append('</tr></table>')
# 2) 3-Phasen-Tabelle
phases = banner_result.get("phases") or {}
if phases:
parts.append(
'<div style="font-size:11px;color:#475569;margin:8px 0 4px;'
'font-weight:600">Cookie-Setzungen pro Phase '
'(echter Browser-Test):</div>'
'<table style="width:100%;border-collapse:collapse;font-size:11px;'
'margin-bottom:10px;border:1px solid #e2e8f0">'
'<thead><tr style="background:#f1f5f9;color:#475569;text-align:left">'
'<th style="padding:5px 8px">Phase</th>'
'<th style="padding:5px 8px;text-align:center">Cookies</th>'
'<th style="padding:5px 8px;text-align:center">Tracker</th>'
'<th style="padding:5px 8px">Auffaelligkeiten</th>'
'</tr></thead><tbody>'
)
for key in ("before_consent", "after_reject", "after_accept"):
ph = phases.get(key) or {}
if not isinstance(ph, dict): continue
cookies = ph.get("cookies") or []
trackers = ph.get("tracking_services") or []
new_track = ph.get("new_tracking") or []
violations = ph.get("violations") or []
undoc = ph.get("undocumented") or []
color = _phase_color(key, len(cookies))
issues_parts = []
if violations: issues_parts.append(f"{len(violations)} Verstoss")
if new_track: issues_parts.append(f"{len(new_track)} neue Tracker")
if undoc: issues_parts.append(f"{len(undoc)} undokumentiert")
issues_str = ", ".join(issues_parts) or ""
parts.append(
f'<tr style="border-top:1px solid #e2e8f0">'
f'<td style="padding:5px 8px;color:#1e293b;font-weight:600">'
f'<span style="display:inline-block;width:6px;height:6px;'
f'border-radius:50%;background:{color};margin-right:6px"></span>'
f'{_short_phase_label(key)}</td>'
f'<td style="padding:5px 8px;text-align:center;color:{color};'
f'font-weight:600">{len(cookies)}</td>'
f'<td style="padding:5px 8px;text-align:center">{len(trackers)}</td>'
f'<td style="padding:5px 8px;color:#475569">{issues_str}</td>'
f'</tr>'
)
parts.append('</tbody></table>')
# 3) Per-Category-Tracker
cats = banner_result.get("category_tests") or []
if cats:
non_essential = [c for c in cats if c.get("category") != "necessary"]
if non_essential:
parts.append(
'<div style="font-size:11px;color:#475569;margin:8px 0 4px;'
'font-weight:600">Provider-Listing pro Banner-Kategorie:</div>'
'<table style="width:100%;border-collapse:collapse;font-size:11px;'
'margin-bottom:10px;border:1px solid #e2e8f0">'
'<thead><tr style="background:#f1f5f9;color:#475569;text-align:left">'
'<th style="padding:5px 8px">Kategorie</th>'
'<th style="padding:5px 8px;text-align:center">Anbieter</th>'
'<th style="padding:5px 8px">Hinweis</th>'
'</tr></thead><tbody>'
)
for c in non_essential:
n = len(c.get("tracking_services") or [])
label = c.get("category_label") or c.get("category", "?")
pdv = c.get("provider_details_visible")
# P19: echtes Signal aus Click-Through-Test
if pdv is False:
color, hint = "#dc2626", ("Banner zeigt KEINE Provider-"
"Details — keine informierte Einwilligung")
elif pdv is True:
color, hint = "#16a34a", ""
elif n == 0:
color, hint = "#d97706", ("Keine Anbieter erkannt (vermutlich "
"kein Provider-Listing im Banner)")
else:
color, hint = "#16a34a", ""
parts.append(
f'<tr style="border-top:1px solid #e2e8f0">'
f'<td style="padding:5px 8px">{label}</td>'
f'<td style="padding:5px 8px;text-align:center;color:{color};'
f'font-weight:600">{n}</td>'
f'<td style="padding:5px 8px;color:#dc2626;font-size:10px">'
f'{hint}</td></tr>'
)
parts.append('</tbody></table>')
# 4) Violations mit Rechtsgrundlage
violations = (banner_result.get("banner_checks") or {}).get("violations", [])
if violations:
parts.append(
'<div style="font-size:11px;color:#475569;margin:8px 0 4px;'
'font-weight:600">Erkannte Banner-Verstoesse:</div>'
'<ul style="margin:0 0 8px 18px;padding:0;font-size:11px;color:#1e293b">'
)
for v in violations[:8]:
sev = (v.get("severity") or "MEDIUM").upper()
sev_c = ("#dc2626" if sev in ("CRITICAL", "HIGH") else
"#d97706" if sev == "MEDIUM" else "#94a3b8")
parts.append(
f'<li style="margin-bottom:6px">'
f'<span style="display:inline-block;background:{sev_c};color:#fff;'
f'font-size:9px;padding:1px 5px;border-radius:3px;margin-right:6px">'
f'{sev}</span>{v.get("text", "")[:200]}'
f'<div style="font-size:10px;color:#94a3b8;margin-top:2px;'
f'font-style:italic">Quelle: {v.get("legal_ref", "")}</div></li>'
)
parts.append('</ul>')
# 5) P59b: Cookie-Behavior-Findings (deklariert vs. tatsaechlich)
cb_findings = banner_result.get("cookie_behavior_findings") or []
if cb_findings:
parts.append(
'<div style="margin:14px 0 4px;padding:8px 12px;'
'background:#fef9e7;border-left:3px solid #d97706;border-radius:4px">'
'<div style="font-size:12px;color:#92400e;font-weight:600;'
'margin-bottom:6px">Cookie-Verhaltens-Check '
'(P59 — deklarierter Zweck vs. tatsaechliches Verhalten)</div>'
'<ul style="margin:0 0 0 18px;padding:0;font-size:11px;color:#1e293b">'
)
for f in cb_findings[:20]:
sev = (f.get("severity") or "MEDIUM").upper()
sev_c = ("#dc2626" if sev in ("CRITICAL", "HIGH") else
"#d97706" if sev == "MEDIUM" else "#94a3b8")
cname = f.get("cookie_name", "?")
parts.append(
f'<li style="margin-bottom:6px">'
f'<span style="display:inline-block;background:{sev_c};color:#fff;'
f'font-size:9px;padding:1px 5px;border-radius:3px;margin-right:6px">'
f'{sev}</span><code style="font-size:10px;background:#f1f5f9;'
f'padding:1px 4px;border-radius:2px">{cname}</code>: '
f'{f.get("text", "")[:280]}'
f'<div style="font-size:10px;color:#94a3b8;margin-top:2px;'
f'font-style:italic">Quelle: {f.get("legal_ref", "")} · '
f'Layer {f.get("layer", "?")}</div></li>'
)
parts.append('</ul></div>')
# 6) P61: Untergeschobene Cookies/Vendors (Vendor-Package)
impl_findings = banner_result.get("implicit_vendor_findings") or []
if impl_findings:
# Gruppiert nach primary_vendor: pro Primary die mitgelaufenen Items
by_primary: dict[str, list[dict]] = {}
for f in impl_findings:
by_primary.setdefault(f["primary_vendor"], []).append(f["implicit"])
parts.append(
'<div style="margin:14px 0 4px;padding:8px 12px;'
'background:#fef3c7;border-left:3px solid #d97706;border-radius:4px">'
'<div style="font-size:12px;color:#92400e;font-weight:600;'
'margin-bottom:6px">Untergeschobene Cookies / Vendors '
'(P61 — mit Hauptanbieter automatisch mitgeladen)</div>'
'<div style="font-size:10px;color:#92400e;margin-bottom:8px">'
'Diese Cookies/Vendors kommen automatisch mit dem deklarierten '
'Hauptanbieter mit — Marketing-Manager waehlen sie oft nicht '
'bewusst aus, sie sind aber zustimmungspflichtig.</div>'
)
for primary, impls in by_primary.items():
parts.append(
f'<div style="font-size:11px;color:#1e293b;margin:6px 0">'
f'<strong>{primary}</strong> bringt automatisch:</div>'
'<ul style="margin:0 0 8px 18px;padding:0;font-size:11px;color:#1e293b">'
)
for impl in impls:
tag = ('<span style="font-size:9px;background:#dc2626;color:#fff;'
'padding:1px 5px;border-radius:3px;margin-right:6px">'
'COOKIE</span>' if impl["type"] == "cookie" else
'<span style="font-size:9px;background:#7c3aed;color:#fff;'
'padding:1px 5px;border-radius:3px;margin-right:6px">'
'VENDOR</span>')
cat_color = {"marketing": "#dc2626", "statistics": "#d97706",
"functional": "#0891b2", "essential": "#16a34a"}.get(
impl.get("category", ""), "#475569")
parts.append(
f'<li style="margin-bottom:5px">{tag}'
f'<code style="font-size:10px;background:#f1f5f9;'
f'padding:1px 4px;border-radius:2px">{impl["name"]}</code> '
f'<span style="font-size:9px;color:{cat_color};'
f'margin-left:4px">[{impl.get("category","?")}]</span>'
f'<div style="font-size:10px;color:#475569;margin-top:2px">'
f'{impl.get("why","")[:240]}</div>'
f'<div style="font-size:9px;color:#94a3b8;font-style:italic">'
f'Quelle: <a href="{impl.get("source_url","")}" '
f'style="color:#94a3b8">{impl.get("source_url","")[:80]}</a>'
f'</div></li>'
)
parts.append('</ul>')
parts.append('</div>')
parts.append('</div>')
return "".join(parts)
@@ -0,0 +1,249 @@
"""
P18 Critical-Findings-Block fuer die Executive-Summary.
Analysiert die echten Daten (banner_checks, phases, scorecard, results) und
rendert einen ROTEN Sofortmassnahmen-Block GANZ OBEN in der Email mit
Quellenangaben (DSK, EDPB, EuGH, Behoerden-Buessgeld-Faelle) und konkreten
Sofortmassnahmen.
Regel: Block wird nur gerendert wenn echte kritische Verstoesse vorliegen.
Bei sauberen Sites bleibt er weg.
"""
from __future__ import annotations
def _truncate_words(text: str, max_chars: int) -> str:
"""P65: Truncate at word boundary, never mid-word."""
if not text or len(text) <= max_chars:
return text
cut = text[:max_chars]
last_space = cut.rfind(" ")
if last_space > max_chars // 2:
cut = cut[:last_space]
return cut.rstrip(",;:.") + ""
# Bekannte Buessgeld-Praezedenzfaelle als Quellen-Hint
_BUSSGELD_REFS = {
"no_provider_per_category": "CNIL France 2023 — TikTok 5 Mio EUR (fehlende Vendor-Transparenz)",
"dse_unvollstaendig": "BayLDA 2024 — diverse Mittelstand-Faelle, 5k50k EUR",
"cookie_doc_missing": "LfDI BW 2023 — fehlende Cookie-Erklaerung, 30k EUR",
"dark_pattern_reject": "EDPB Guidelines 3/2022 + DSK 2024 — Bussgeldrahmen Art. 83 DSGVO",
"schrems_ii": "EuGH C-311/18 (Schrems II) — Bussgeldrahmen bis 4% Konzern-Umsatz",
"impressum_im_banner": "LG Rostock 3 O 22/19 — Impressum-Pflicht ueberlagernder Banner",
}
def _detect_critical_issues(
banner_result: dict | None,
scorecard: dict | None,
results: list,
) -> list[dict]:
"""Erkenne kritische Verstoesse aus den vorliegenden Daten."""
issues: list[dict] = []
br = banner_result or {}
sc = scorecard or {}
# 1) Banner-Violations (HIGH/CRITICAL) aus consent-tester
for v in (br.get("banner_checks") or {}).get("violations", []):
sev = (v.get("severity") or "").upper()
if sev in ("CRITICAL", "HIGH"):
issues.append({
"key": "banner_violation",
"title": _truncate_words(v.get("text", ""), 260),
"severity": sev,
"action": _action_for_banner_violation(v),
"source": v.get("legal_ref", ""),
"bussgeld": _BUSSGELD_REFS.get("impressum_im_banner")
if "impressum" in (v.get("text") or "").lower()
else _BUSSGELD_REFS.get("dark_pattern_reject"),
})
# 2) Category-Tests: Banner zeigt keine Provider-Details pro Kategorie.
# Bevorzugt das echte Signal aus dem Click-Through-Test (P19):
# provider_details_visible. Fallback: leere tracking_services.
cat_tests = br.get("category_tests") or []
cats_without_details = [
c for c in cat_tests
if c.get("category") != "necessary"
and (c.get("provider_details_visible") is False
or (c.get("provider_details_visible") is None
and not c.get("tracking_services")))
]
if cats_without_details and len(cat_tests) >= 2:
cats = ", ".join(c.get("category_label", c.get("category", "?"))
for c in cats_without_details)
issues.append({
"key": "no_provider_per_category",
"title": f"Cookie-Banner: Kategorien ({cats}) zeigen keine "
f"Provider-/Cookie-Details",
"severity": "HIGH",
"action": ("Pro Banner-Kategorie eine Liste der eingebundenen "
"Anbieter + Cookie-Details (Name, Zweck, Speicherdauer, "
"Drittlandtransfer) sichtbar machen — am besten als "
"ausklappbares Detail-Panel. Sonst ist die "
"Einwilligung nicht 'informiert' nach Art. 7 DSGVO "
"und gilt als unwirksam."),
"source": "Art. 7 Abs. 1 DSGVO, EDPB Guidelines 2/2023, DSK 2024",
"bussgeld": _BUSSGELD_REFS["no_provider_per_category"],
})
# 3) DSGVO/TDDDG-Score < 30%: DSE rechtswidrig
pct = int((sc.get("totals") or {}).get("pct", 100))
if pct and pct < 30:
issues.append({
"key": "dse_unvollstaendig",
"title": f"Datenschutzerklaerung erfuellt nur {pct}% der Pflichten",
"severity": "HIGH",
"action": ("Vollstaendig nach Art. 13 DSGVO ueberarbeiten: "
"Verantwortlicher, Zwecke, Rechtsgrundlage, "
"Speicherdauer, Drittland-Transfers, alle Betroffenen-"
"rechte, konkrete Aufsichtsbehoerde."),
"source": "Art. 13 DSGVO + Art. 14 (alternativ), DSK-OH Telemedien 2024",
"bussgeld": _BUSSGELD_REFS["dse_unvollstaendig"],
})
# 4) Cookie-Richtlinie fehlt komplett (nicht erreichbar)
cookie_missing = any(
(r.doc_type == "cookie" if hasattr(r, "doc_type") else
r.get("doc_type") == "cookie")
and ((r.error if hasattr(r, "error") else r.get("error", "")) or "")
.startswith("Auf der Website nicht gefunden")
for r in (results or [])
)
cookie_deduped = any(
(r.doc_type == "cookie" if hasattr(r, "doc_type") else
r.get("doc_type") == "cookie")
and "Nicht separat vorhanden" in
((r.error if hasattr(r, "error") else r.get("error", "")) or "")
for r in (results or [])
)
if cookie_missing or cookie_deduped:
issues.append({
"key": "cookie_doc_missing",
"title": ("Keine eigenstaendige Cookie-Richtlinie"
if cookie_deduped
else "Cookie-Richtlinie nicht auffindbar"),
"severity": "HIGH",
"action": ("Separate Cookie-Richtlinie-Seite erstellen mit "
"tabellarischer Auflistung aller Cookies (Name, "
"Anbieter, Zweck, Speicherdauer, Drittlandtransfer). "
"Direkt aus dem Banner verlinken."),
"source": "Art. 13 DSGVO, §25 TDDDG, DSK-OH Telemedien 2024",
"bussgeld": _BUSSGELD_REFS["cookie_doc_missing"],
})
# 5) Schrems-II-Risiko: Google/Meta/Microsoft im Banner, aber keine SCC/DPF
# Detection: pre-/post-consent-cookies in den phases enthalten US-Tracker
phases = br.get("phases") or {}
has_us_tracker = False
for ph in phases.values():
if not isinstance(ph, dict):
continue
for t in (ph.get("tracking_services") or []):
if isinstance(t, dict):
name = (t.get("name", "") or "").lower()
else:
name = str(t).lower()
if any(w in name for w in ("google", "meta", "facebook",
"microsoft", "linkedin", "tiktok")):
has_us_tracker = True
break
if has_us_tracker:
issues.append({
"key": "schrems_ii",
"title": "US-Tracker geladen — Schrems-II-Risiko",
"severity": "HIGH",
"action": ("Pro Drittland-Anbieter dokumentieren: SCC (Art. 46 "
"DSGVO) ODER DPF-Zertifizierung pruefen + in der "
"Datenschutzerklaerung explizit benennen."),
"source": "Art. 44 ff. DSGVO, EuGH C-311/18 (Schrems II)",
"bussgeld": _BUSSGELD_REFS["schrems_ii"],
})
return issues
def _action_for_banner_violation(v: dict) -> str:
text = (v.get("text") or "").lower()
if "impressum" in text:
return ("Impressum-Link direkt im Banner ergaenzen — bei "
"ueberlagerndem Banner Pflicht nach §5 TMG.")
if "ablehnen" in text or "dark pattern" in text:
return ("'Ablehnen'-Button visuell gleichwertig zu 'Akzeptieren' "
"gestalten (gleiche Groesse, Farbe, Position).")
if "widerruf" in text or "cookie-einstellungen" in text:
return ("Floating-Icon oder Footer-Link 'Cookie-Einstellungen' "
"permanent einblenden — Widerruf so einfach wie Erteilung.")
return ("Banner-Verstoss beheben gemaess der genannten Rechtsgrundlage.")
def build_critical_findings_html(
banner_result: dict | None,
scorecard: dict | None,
results: list,
) -> str:
"""Render der Audit-Zusammenfassung fuer die Geschaeftsfuehrung.
P89: Co-Pilot-Tonalitaet statt Panik-Rot.
- Sachlich blau statt alarmistisch rot
- "Themen die besprochen werden sollten" statt "VERSTOESSE"
- Realistische Zeitschaetzung (4-8 Wochen)
- Buessgeld-Risiko in separater, dezenter Section ganz unten
- Konfidenz-Hinweis "False-Positives moeglich"
"""
issues = _detect_critical_issues(banner_result, scorecard, results)
if not issues:
return ""
items = []
for idx, i in enumerate(issues, 1):
# P87-Vorbereitung: keine HIGH-Badges mehr — wir nummerieren stattdessen
items.append(
f'<div style="margin-bottom:10px;padding:10px 14px;'
f'background:#fff;border-radius:6px;'
f'border-left:3px solid #2563eb">'
f'<div style="font-size:13px;font-weight:600;color:#1e293b;'
f'margin-bottom:4px">'
f'<span style="display:inline-block;background:#dbeafe;color:#1e40af;'
f'padding:1px 8px;border-radius:10px;font-size:10px;'
f'margin-right:8px;font-weight:600">Thema {idx}</span>'
f'{i["title"]}</div>'
f'<div style="font-size:11px;color:#475569;margin-top:6px">'
f'<strong>Empfehlung:</strong> {i["action"]}</div>'
f'<div style="font-size:10px;color:#94a3b8;margin-top:4px;'
f'font-style:italic">Hintergrund: {i.get("source","")}</div>'
f'</div>'
)
n = len(issues)
plural = "Themen" if n != 1 else "Thema"
return (
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
'max-width:700px;margin:0 auto 18px;padding:18px 22px;'
'background:#f0f9ff;border:1px solid #bfdbfe;border-radius:10px">'
'<div style="font-size:11px;color:#1e40af;text-transform:uppercase;'
'letter-spacing:1.2px;margin-bottom:4px;font-weight:600">'
'Zusammenfassung fuer die Geschaeftsfuehrung</div>'
f'<h2 style="margin:0 0 8px;font-size:18px;color:#1e293b">'
f'{n} {plural} zur Besprechung mit DSB, Marketing und Entwicklung</h2>'
'<p style="margin:0 0 14px;font-size:12px;color:#475569;line-height:1.5">'
'Wir haben Datenschutzerklaerung, Cookie-Banner, Impressum und '
'eingebundene Anbieter technisch analysiert. Die folgenden Punkte '
'sollten in den naechsten Wochen geklaert werden &mdash; typische '
'Umsetzungsdauer 4-8 Wochen (DSB-Review &rarr; Marketing-Agentur '
'&rarr; Entwicklung &rarr; Freigabe). Detaillierte technische '
'Analyse mit weiteren Findings finden Sie unten.</p>'
+ "".join(items) +
'<div style="margin-top:14px;padding:10px 12px;background:#f1f5f9;'
'border-radius:6px;font-size:10px;color:#64748b;line-height:1.5">'
'<strong style="color:#475569">Hinweis:</strong> Automatisierte '
'Audits enthalten False-Positives. Wo unsicher, bitte mit DSB pruefen '
'oder uns Feedback geben &mdash; wir lernen daraus. '
'Rechtliche Risiken (Bussgeld-Rahmen Art. 83 DSGVO bis 4&nbsp;% des '
'weltweiten Jahresumsatzes, realistisch 0,1-1&nbsp;% bei Erstverstoss '
'nach CNIL/LfDI-Massstab) werden weiter unten pro Finding eingeordnet.'
'</div>'
'</div>'
)
@@ -26,6 +26,47 @@ def _fmt_eur_range(low: int, high: int) -> str:
return f"{low:,}{high:,}".replace(",", ".")
def _build_score_band_block(pct: int, color: str) -> list[str]:
"""P34 — eine Zeile unter den KPIs: Score-Einordnung."""
band, hint = _score_band_explanation(pct)
return [
f'<div style="margin-top:10px;padding:10px 14px;'
f'background:rgba(255,255,255,0.04);border-left:3px solid {color};'
f'border-radius:4px">'
f'<div style="font-size:11px;color:#cbd5e1">'
f'<strong style="color:{color}">{band} ({pct}%)</strong> — {hint}'
f'</div></div>',
]
def _score_band_explanation(pct: int) -> tuple[str, str]:
"""P34 — Was bedeutet der Score: wo MUESSTE man stehen.
Returns (label, what_to_expect)."""
if pct >= 85:
return (
"Sehr gut", "Praxis-uebliche DSGVO-Risikolage. "
"Standard-Pflege reicht — jaehrliche Pruefung empfohlen.",
)
if pct >= 70:
return (
"Akzeptabel", "Branchen-Median. Verbleibende Findings sind "
"meist Formalia — Empfehlung: einmaliges Aufraeumen, dann "
"Halbjahres-Check.",
)
if pct >= 50:
return (
"Handlungsbedarf", "Mehrere wesentliche Themen offen. "
"Empfehlung: priorisierte Abarbeitung der HIGH-Findings "
"binnen 4-8 Wochen mit DSB + Web-Team.",
)
return (
"Erhoehtes Risiko", "Mehrere Kern-Pflichten fehlen oder sind "
"veraltet. Empfehlung: kurzfristiger Termin mit DSB / Rechtsabteilung "
"und Web-Team zur Priorisierung.",
)
def build_exec_summary_html(
scorecard: dict | None,
previous_scorecard: dict | None,
@@ -117,6 +158,9 @@ def build_exec_summary_html(
'</table>',
# P34 — Score-Einordnung "wer wo stehen muss"
*(_build_score_band_block(pct, score_color) if scorecard else []),
# CTAs
'<div style="margin-top:14px;padding-top:12px;border-top:1px solid '
'rgba(255,255,255,0.1);text-align:center">',
@@ -234,255 +234,9 @@ def _category_label(kat: str) -> str:
}.get(kat, kat or "")
def build_vvt_table_html(vendors: list[dict]) -> str:
"""Render the per-vendor VVT-style table for the email report.
# VVT-Tabelle (gruppiert + P60/P60b Pattern-Notice) wurde in
# vvt_table_renderer.py ausgelagert, damit dieses File unter dem
# 500-LOC-Hardcap bleibt. Re-export, damit bestehende Aufrufer (z.B.
# agent_compliance_check_routes) unveraendert weiter funktionieren.
from compliance.api.vvt_table_renderer import build_vvt_table_html # noqa: E402,F401
Splits vendors into 3-4 sections by recipient_type (Art. 30(1)(d)
DSGVO):
1. INTERNAL own departments / own systems
2. GROUP_COMPANY parent/subsidiary (if any)
3. PROCESSOR Auftragsverarbeiter (AVV-pflichtig)
4. CONTROLLER joint/independent controllers (Meta, Google,
LinkedIn they build own profiles)
5. AUTHORITY / OTHER rest
Within each section: rows sorted by compliance_score ascending so
the weakest entries surface first.
"""
if not vendors:
return ""
# Import here to avoid pulling backend service deps at module load
from compliance.services.vendor_classifier import RECIPIENT_TYPE_SECTIONS
# Bucket vendors by recipient_type
by_type: dict[str, list[dict]] = {}
for v in vendors:
rt = (v.get("recipient_type") or "OTHER").upper()
by_type.setdefault(rt, []).append(v)
# Top summary
n_total = len(vendors)
n_internal = sum(1 for v in vendors
if (v.get("recipient_type") or "").upper()
in ("INTERNAL", "GROUP_COMPANY"))
n_external = n_total - n_internal
n_critical = sum(1 for v in vendors if v.get("compliance_score", 0) < 50)
summary_parts = [f"{n_total} Verarbeitungen erfasst"]
if n_internal and n_external:
summary_parts.append(
f"&mdash; {n_internal} eigene + {n_external} externe Empfaenger"
)
if n_critical:
summary_parts.append(
f', <strong style="color:#dc2626">{n_critical} unter 50%</strong>'
)
else:
summary_parts.append("&mdash; alle ueber 50%")
summary = " ".join(summary_parts)
out: list[str] = [
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
'max-width:760px;margin:0 auto 16px;padding:12px 16px;'
'background:#fafafa;border:1px solid #e5e7eb;border-radius:8px">',
'<h3 style="margin:0 0 4px;font-size:14px;color:#334155">'
'VVT-Vorschlag: Verarbeitungstaetigkeiten und Empfaenger aus der '
'Cookie-Richtlinie</h3>',
f'<p style="margin:0 0 10px;font-size:11px;color:#6b7280">{summary}. '
'Gruppiert nach Empfaengerkategorie (Art. 30(1)(d) DSGVO). Innerhalb '
'jeder Gruppe nach Compliance-Score sortiert. Bei eigenen '
'Verarbeitungen (INTERNAL/GROUP) werden Opt-Out und Privacy-Link '
'NICHT als Pflicht gewertet &mdash; der Widerruf erfolgt ueber das '
'Cookie-Banner, Privacy ist in der Haupt-DSI dokumentiert.</p>',
]
for rtype, section_label in RECIPIENT_TYPE_SECTIONS:
rows = by_type.get(rtype) or []
if not rows:
continue
rows = sorted(rows, key=lambda v: v.get("compliance_score", 0))
n = len(rows)
n_bad = sum(1 for v in rows if v.get("compliance_score", 0) < 50)
bad_hint = (f' <span style="color:#dc2626">({n_bad} unter 50%)</span>'
if n_bad else "")
out.append(
f'<h4 style="margin:14px 0 4px;font-size:12px;color:#1e293b;'
f'border-top:1px solid #e2e8f0;padding-top:8px">'
f'{section_label} <span style="color:#94a3b8;font-weight:400">'
f'({n}){bad_hint}</span></h4>'
)
out.append(_render_vendor_section(rows))
out.append('</div>')
return "".join(out)
def _render_vendor_section(rows: list[dict]) -> str:
body: list[str] = [
'<table style="width:100%;border-collapse:collapse;font-size:11px">'
'<thead><tr style="background:#f1f5f9;color:#475569;text-align:left">'
'<th style="padding:5px 8px">Name</th>'
'<th style="padding:5px 8px">Kategorie</th>'
'<th style="padding:5px 8px">Sitz</th>'
'<th style="padding:5px 8px;text-align:center">Cookies</th>'
'<th style="padding:5px 8px;text-align:center">Opt-Out</th>'
'<th style="padding:5px 8px;text-align:center">Privacy</th>'
'<th style="padding:5px 8px;text-align:right">Score</th>'
'</tr></thead><tbody>',
]
for v in rows:
body.append(_render_vendor_row_full(v))
body.append('</tbody></table>')
return "".join(body)
def _render_vendor_row_full(v: dict) -> str:
rtype = (v.get("recipient_type") or "OTHER").upper()
is_own = rtype in ("INTERNAL", "GROUP_COMPANY")
cat = (v.get("category") or "").lower()
is_necessary = cat in ("necessary", "strictlynecessary")
name = v.get("name") or "Unbekannt"
category = _category_label(v.get("category", ""))
country = v.get("country") or ("" if is_own else "")
cookies = v.get("cookies") or []
n_cookies = len(cookies)
score = int(v.get("compliance_score", 0))
flags = v.get("compliance_flags") or []
# Opt-Out: nicht erforderlich fuer eigene Verarbeitung oder
# technisch notwendige Cookies (§25 Abs. 2 TDDDG).
opt_na_reason = ("Nicht erforderlich (eigene Verarbeitung — "
"Widerruf ueber Cookie-Banner)") if is_own else (
"Nicht erforderlich (§25 Abs. 2 TDDDG — technisch notwendig)"
if is_necessary else None
)
opt_status = _link_status_badge(
v.get("opt_out_url"), v.get("opt_out_ok"), v.get("opt_out_status"),
na_label=opt_na_reason,
)
# Privacy: nicht erforderlich fuer eigene Verarbeitung (Haupt-DSI).
privacy_na_reason = (
"Nicht erforderlich (eigene Verarbeitung — durch Haupt-DSI abgedeckt)"
if is_own else None
)
privacy_status = _link_status_badge(
v.get("privacy_policy_url"), v.get("privacy_ok"),
v.get("privacy_status"), na_label=privacy_na_reason,
)
score_color = ("#16a34a" if score >= 80 else
"#d97706" if score >= 50 else "#dc2626")
# Score-Erklaerung: was wurde gewertet, was fehlt
# Annahme: Score = bestandene Kriterien / Gesamtkriterien * 100.
# Typisch 5 Kriterien fuer EXT: country, cookies, opt_out, privacy, scoring.
# Bei INTERNAL/GROUP: opt_out + privacy nicht gewertet (3 Kriterien).
n_criteria = 3 if is_own else 5
n_failed = len(flags) if flags else 0
score_tooltip = (
f"{n_criteria - n_failed} von {n_criteria} Kriterien erfuellt"
+ (f" — fehlt: {', '.join(_flag_short(f) for f in flags[:3])}"
if flags else "")
)
# Inline-Aktions-Anweisungen pro Flag
actions_html = ""
if flags:
from compliance.services.finding_action_recipes import recipe_for
action_items = []
for f in flags:
rec = recipe_for(f)
if not rec:
continue
action_items.append(
f'<li style="margin-bottom:6px"><strong>{_flag_short(f)}:</strong> '
f'{rec.get("what", "")}<br/>'
f'<span style="color:#475569"><strong>Was tun:</strong> '
f'{rec.get("fix_text", "").splitlines()[0][:200]}</span><br/>'
f'<span style="color:#94a3b8;font-size:9px">Quelle: '
f'{rec.get("why", "")[:160]}</span></li>'
)
if action_items:
actions_html = (
f'<details style="margin-top:4px"><summary style="cursor:pointer;'
f'color:#dc2626;font-size:10px">Was muss ich tun? '
f'({len(action_items)} Action{"s" if len(action_items) != 1 else ""})</summary>'
f'<ul style="margin:4px 0 0 14px;padding:0;font-size:10px;color:#1e293b">'
+ "".join(action_items)
+ '</ul></details>'
)
flag_str = ""
if flags:
flag_str = (
f'<div style="font-size:10px;color:#94a3b8;margin-top:2px">'
f'{", ".join(flags[:4])}</div>'
f'{actions_html}'
)
risk = v.get("compliance_risk") or {}
risk_label = risk.get("label") or ""
risk_badge = ""
if risk_label and risk_label != "unklar":
rc = {"kritisch": ("#dc2626", "#fff"), "hoch": ("#fecaca", "#991b1b"),
"mittel": ("#fde68a", "#92400e"), "gering": ("#d1fae5", "#065f46")}.get(risk_label, ("#e5e7eb", "#475569"))
risk_badge = (f'<span style="margin-left:6px;padding:1px 5px;border-radius:3px;font-size:9px;'
f'background:{rc[0]};color:{rc[1]}">Risk: {risk_label}</span>')
return (
f'<tr style="border-top:1px solid #e2e8f0">'
f'<td style="padding:6px 8px;color:#1e293b;font-size:11px">'
f'{name}{risk_badge}{flag_str}</td>'
f'<td style="padding:6px 8px;color:#475569;font-size:11px">{category}</td>'
f'<td style="padding:6px 8px;color:#475569;font-size:11px">{country}</td>'
f'<td style="padding:6px 8px;text-align:center;color:#475569;font-size:11px">'
f'{n_cookies}</td>'
f'<td style="padding:6px 8px;text-align:center">{opt_status}</td>'
f'<td style="padding:6px 8px;text-align:center">{privacy_status}</td>'
f'<td style="padding:6px 8px;text-align:right;font-weight:600;'
f'color:{score_color};font-size:11px" title="{score_tooltip}">'
f'{score}%<div style="font-size:9px;font-weight:400;color:#94a3b8">'
f'{n_criteria - n_failed}/{n_criteria}</div></td>'
f'</tr>'
)
def _flag_short(f: str) -> str:
"""Lesbare deutsche Form fuer einen Flag-Token."""
labels = {
"no_cookies_listed": "Cookies fehlen",
"no_country": "Sitzland fehlt",
"no_privacy_url": "Privacy-Link fehlt",
"broken_privacy_url": "Privacy-Link broken",
"no_opt_out_url": "Opt-Out fehlt",
"broken_opt_out": "Opt-Out broken",
}
return labels.get(f, f)
def _link_status_badge(
url: str | None,
ok: bool | None,
status: int | None,
na_label: str | None = None,
) -> str:
"""Render the link-status cell.
- url + ok -> green check
- url + broken -> red cross with status
- no url + na_label -> neutral em-dash with explanation tooltip
(used for INTERNAL/necessary rows where the field isn't required)
- no url + no na_label -> red cross (real gap)
"""
if not url:
if na_label:
return ('<span style="color:#94a3b8;font-size:11px" '
f'title="{na_label}">&mdash;</span>')
return ('<span style="color:#dc2626;font-size:11px" '
'title="Kein Link">&#10007;</span>')
if ok:
return ('<span style="color:#16a34a;font-size:11px" '
f'title="HTTP {status}">&#10003;</span>')
status_str = str(status) if status else "?"
return ('<span style="color:#dc2626;font-size:11px" '
f'title="HTTP {status_str}">&#10007; ({status_str})</span>')
@@ -202,51 +202,13 @@ def build_management_summary(results: list[DocCheckResult]) -> str:
def _check_to_action(doc_label: str, check_label: str, hint: str) -> str:
"""Convert a failed check into a plain-language action item."""
# Map technical check labels to business-language actions
label_lower = check_label.lower()
"""Convert a failed check into a plain-language action item.
if "datenschutzbeauftragter" in label_lower or "dsb" in label_lower:
return (f"<strong>{doc_label}:</strong> Ihren Datenschutzbeauftragten "
f"mit Kontaktdaten erwaehnen. Pflicht ab 20 Mitarbeitern.")
if "beschwerderecht" in label_lower or "art. 77" in label_lower:
return (f"<strong>{doc_label}:</strong> Hinweis auf das Beschwerderecht "
f"bei der Aufsichtsbehoerde ergaenzen (Name + Kontakt der Behoerde).")
if "betroffenenrechte" in label_lower:
return (f"<strong>{doc_label}:</strong> Alle Betroffenenrechte "
f"(Auskunft, Berichtigung, Loeschung, etc.) einzeln auffuehren.")
if "verantwortlicher" in label_lower:
return (f"<strong>{doc_label}:</strong> Vollstaendige Firmenbezeichnung "
f"mit Rechtsform, Adresse, E-Mail und Telefon eintragen.")
if "interessenabwaegung" in label_lower:
return (f"<strong>{doc_label}:</strong> Bei 'berechtigtem Interesse' "
f"die Abwaegung dokumentieren. Aufgabe fuer den DSB/Rechtsanwalt.")
if "widerrufsbelehrung" in label_lower or "widerruf" in label_lower:
return (f"<strong>{doc_label}:</strong> Gesetzliche Widerrufsbelehrung "
f"mit 14-Tage-Frist und Musterformular bereitstellen.")
if "loeschkonzept" in label_lower:
return (f"<strong>{doc_label}:</strong> Loeschfristen und -prozess "
f"dokumentieren. Aufgabe fuer den DSB.")
if "profiling" in label_lower or "art. 22" in label_lower:
return (f"<strong>{doc_label}:</strong> Hinweis ergaenzen ob "
f"automatisierte Entscheidungen stattfinden oder nicht.")
if "nicht im eingereichten text" in label_lower:
return (f"<strong>{doc_label}:</strong> Das eingereichte Dokument "
f"enthaelt nicht den erwarteten Inhalt. Bitte korrekte URL pruefen.")
# Generic fallback
if hint and len(hint) < 150:
return f"<strong>{doc_label}:</strong> {hint[:120]}"
return f"<strong>{doc_label}:</strong> '{check_label}' muss ergaenzt werden."
Implementation lives in doc_action_mappings.check_to_action kept here
as a thin wrapper so the report module stays under the 500-LOC cap.
"""
from compliance.api.doc_action_mappings import check_to_action
return check_to_action(doc_label, check_label, hint)
def build_html_report(
@@ -24,7 +24,7 @@ from compliance.services.unified_findings_store import (
findings_summary,
list_findings,
)
from compliance.services.compliance_audit_log import get_check_run
from compliance.services.compliance_audit_log import get_check_run, get_check_payload
logger = logging.getLogger(__name__)
@@ -102,3 +102,18 @@ def get_findings(
"count": 0,
"findings": [],
}
@router.get("/banner/{check_id}")
def get_banner_payload(check_id: str) -> dict:
"""P20: full banner_result (phases, structured_checks, category_tests,
banner_checks.violations) fuer das Voll-Audit-Frontend.
"""
try:
payload = get_check_payload(check_id) or {}
banner = payload.get("banner") or {}
return {"found": bool(banner), "check_id": check_id, "banner": banner}
except Exception as e:
logger.exception("get_banner_payload failed for %s", check_id)
return {"found": False, "check_id": check_id,
"error": str(e)[:200], "banner": {}}
@@ -0,0 +1,102 @@
"""
GF-freundliche Action-Texte fuer fehlende Pflichtangaben.
Ausgelagert aus agent_doc_check_report.py (LOC-Cap). Wandelt einen
fehlgeschlagenen DocCheck in eine kurze Handlungsanweisung um, die ein
Geschaeftsfuehrer ohne juristisches Vorwissen versteht.
P66: Cookie-spezifische Findings unterscheiden zwischen Service-Zweck
(Anbieter-Beschreibung wie "Akamai = Bot-Schutz") und Cookie-Zweck
(welches Cookie wozu) eine haeufige Verwechslung bei Marketing-Managern.
"""
from __future__ import annotations
def _cookie_finding_action(doc_label: str, check_label: str) -> str | None:
"""P66 — Cookie-spezifische Mappings."""
label_lower = check_label.lower()
if "zwecke der cookies" in label_lower or label_lower == "zwecke":
return (f"<strong>{doc_label}:</strong> Zwecke pro Cookie ergaenzen "
f"— nicht pro Anbieter. Service-Beschreibungen ('Akamai = "
f"Bot-Schutz') beantworten nicht, was das einzelne Cookie "
f"tut. Pflicht: pro Cookie (z.B. <code>_abck</code>) den "
f"konkreten Zweck angeben ('Bot-Detection-Token, gueltig "
f"24h'). DSK-OH Telemedien 2024 §3.2.")
if "speicherdauer" in label_lower:
return (f"<strong>{doc_label}:</strong> Speicherdauer pro Cookie "
f"angeben — nicht pauschal 'siehe Anbieter'. Pflicht: "
f"konkreter Wert (z.B. '_ga: 2 Jahre', '_gid: 24h', "
f"'PHPSESSID: Session'). Werte aus DevTools &gt; "
f"Application &gt; Cookies pruefen, Anbieter-Doku ist "
f"oft veraltet. Art. 13 Abs. 2 lit. a DSGVO.")
if "anbieter" in label_lower or "providers_named" in label_lower:
return (f"<strong>{doc_label}:</strong> Konkrete Firmen mit Sitz "
f"benennen — nicht 'Drittanbieter' oder 'Marketing-Partner'. "
f"Pflicht: voller Firmenname + Rechtsform + Land (z.B. "
f"'Google Ireland Limited, Dublin'). Art. 13 Abs. 1 lit. e "
f"DSGVO (Empfaenger-Pflicht).")
if "cookie-tabelle" in label_lower or "cookie_list" in label_lower:
return (f"<strong>{doc_label}:</strong> Tabellarische Cookie-Liste "
f"mit Name, Anbieter, Zweck und Speicherdauer ergaenzen. "
f"Reine Anbieter-Beschreibung ohne Cookie-Namen reicht "
f"nicht — Nutzer muss nachvollziehen, welches einzelne "
f"Cookie was tut. DSK-OH 2024.")
if "drittland" in label_lower or "schrems" in label_lower:
return (f"<strong>{doc_label}:</strong> Pro US-Anbieter (Google, "
f"Meta, AWS, Akamai) klaeren: SCC (Art. 46 DSGVO) oder "
f"DPF-Zertifizierung — und in der Cookie-Richtlinie "
f"explizit nennen. Pauschales 'Anbieter ausserhalb EU' "
f"reicht nicht. EuGH Schrems II.")
return None
def check_to_action(doc_label: str, check_label: str, hint: str) -> str:
"""Convert a failed check into a plain-language action item."""
label_lower = check_label.lower()
if "datenschutzbeauftragter" in label_lower or "dsb" in label_lower:
return (f"<strong>{doc_label}:</strong> Ihren Datenschutzbeauftragten "
f"mit Kontaktdaten erwaehnen. Pflicht ab 20 Mitarbeitern.")
if "beschwerderecht" in label_lower or "art. 77" in label_lower:
return (f"<strong>{doc_label}:</strong> Hinweis auf das Beschwerderecht "
f"bei der Aufsichtsbehoerde ergaenzen (Name + Kontakt der Behoerde).")
if "betroffenenrechte" in label_lower:
return (f"<strong>{doc_label}:</strong> Alle Betroffenenrechte "
f"(Auskunft, Berichtigung, Loeschung, etc.) einzeln auffuehren.")
if "verantwortlicher" in label_lower:
return (f"<strong>{doc_label}:</strong> Vollstaendige Firmenbezeichnung "
f"mit Rechtsform, Adresse, E-Mail und Telefon eintragen.")
if "interessenabwaegung" in label_lower:
return (f"<strong>{doc_label}:</strong> Bei 'berechtigtem Interesse' "
f"die Abwaegung dokumentieren. Aufgabe fuer den DSB/Rechtsanwalt.")
if "widerrufsbelehrung" in label_lower or "widerruf" in label_lower:
return (f"<strong>{doc_label}:</strong> Gesetzliche Widerrufsbelehrung "
f"mit 14-Tage-Frist und Musterformular bereitstellen.")
if "loeschkonzept" in label_lower:
return (f"<strong>{doc_label}:</strong> Loeschfristen und -prozess "
f"dokumentieren. Aufgabe fuer den DSB.")
if "profiling" in label_lower or "art. 22" in label_lower:
return (f"<strong>{doc_label}:</strong> Hinweis ergaenzen ob "
f"automatisierte Entscheidungen stattfinden oder nicht.")
if "nicht im eingereichten text" in label_lower:
return (f"<strong>{doc_label}:</strong> Das eingereichte Dokument "
f"enthaelt nicht den erwarteten Inhalt. Bitte korrekte URL pruefen.")
if any(w in label_lower for w in ("rechtswidrig", "illegal",
"haftungsausschluss", "disclaimer")):
return (f"<strong>{doc_label}:</strong> '{check_label}' muss entfernt "
f"werden (Anti-Pattern, rechtlich wirkungslos).")
mapped = _cookie_finding_action(doc_label, check_label)
if mapped:
return mapped
if hint and len(hint) < 300:
return f"<strong>{doc_label}:</strong> {hint[:280]}"
return f"<strong>{doc_label}:</strong> '{check_label}' muss ergaenzt werden."
@@ -0,0 +1,283 @@
"""FastAPI-Route fuer den Founding-Wizard Document-Generation.
POST /v1/founding-wizard/generate
Body: FoundingWizardState (Wizard-Eingaben)
Returns: {documents: [{document_type, title, content_base64, size_bytes, ...}]}
Templates werden aus compliance_legal_templates geladen, mit dem Wizard-Context
gerendert (Handlebars-light) und als .docx-Bytes (base64) zurueckgegeben.
"""
from __future__ import annotations
import base64
import logging
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
from sqlalchemy import text
from sqlalchemy.orm import Session
from compliance.services.founding_wizard import (
base_context,
markdown_to_docx_bytes,
render_template,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/v1/founding-wizard", tags=["founding-wizard"])
DOC_TITLES = {
"articles_of_association": "Satzung",
"gesellschafterliste": "Gesellschafterliste",
"gf_bestellungsbeschluss": "Bestellungsbeschluss Geschäftsführer",
"hrb_anmeldung": "Handelsregister-Anmeldung",
"sha": "Shareholders' Agreement (SHA)",
"geschaeftsordnung_gf": "Geschäftsordnung der Geschäftsführung",
"managing_director_employment_contract": "Geschäftsführerdienstvertrag",
"ip_assignment_agreement": "IP-Assignment Agreement",
"employment_contract_de": "Arbeitsvertrag",
"term_sheet": "Term Sheet",
"convertible_loan_agreement": "Wandeldarlehensvertrag",
"subscription_agreement": "Beteiligungsvertrag",
"esop_plan": "ESOP/VSOP-Plan",
"cap_table": "Cap Table",
}
class GenerationRequest(BaseModel):
current_step: int = 8
lifecycle_stage: str = "founding"
is_pre_notary: bool = True
basics: dict[str, Any] = {}
gesellschafter: list[dict[str, Any]] = []
capital: dict[str, Any] = {}
notar: dict[str, Any] = {}
sha: dict[str, Any] = {}
gf_contracts: list[dict[str, Any]] = []
selected_documents: list[str] = []
class DocumentResult(BaseModel):
document_type: str
title: str
filename: str
content_base64: str
size_bytes: int
generated_at: str
placeholders_count: int
class GenerationResponse(BaseModel):
documents: list[DocumentResult]
warnings: list[str] = []
def _load_template(db: Session, document_type: str) -> dict[str, Any] | None:
"""Laedt das neueste published Template fuer den document_type."""
row = db.execute(
text("""
SELECT id, document_type, title, content, placeholders, version, status
FROM compliance_legal_templates
WHERE document_type = :dt AND status = 'published'
ORDER BY created_at DESC
LIMIT 1
"""),
{"dt": document_type},
).first()
if not row:
return None
return {
"id": str(row.id),
"document_type": row.document_type,
"title": row.title,
"content": row.content,
"placeholders": row.placeholders or [],
"version": row.version,
}
def _safe_slug(name: str) -> str:
"""Erzeugt einen filename-tauglichen Slug aus einem Namen."""
import re as _re
s = _re.sub(r"[^a-zA-Z0-9_-]+", "_", name.strip())
return s.strip("_") or "Person"
def _render_one(
db: Session,
doc_type: str,
context: dict[str, Any],
name_suffix: str = "",
) -> DocumentResult | None:
template = _load_template(db, doc_type)
if not template:
logger.warning("No template found for document_type=%s", doc_type)
return None
rendered_md = render_template(template["content"], context)
title = template.get("title") or DOC_TITLES.get(doc_type, doc_type)
if name_suffix:
title = f"{title}{name_suffix}"
docx_bytes = markdown_to_docx_bytes(rendered_md, title=None)
from datetime import datetime
suffix_slug = f"_{_safe_slug(name_suffix)}" if name_suffix else ""
company_slug = _safe_slug(context.get("COMPANY_NAME", "Unternehmen"))
return DocumentResult(
document_type=doc_type,
title=title,
filename=f"{doc_type}{suffix_slug}_{company_slug}.docx",
content_base64=base64.b64encode(docx_bytes).decode("ascii"),
size_bytes=len(docx_bytes),
generated_at=datetime.utcnow().isoformat() + "Z",
placeholders_count=len(template.get("placeholders") or []),
)
# Dokumente die PRO Person (Gründer/GF) generiert werden
PER_PERSON_DOCS = {
"ip_assignment_agreement", # Pro Gründer einer (individuelles IP)
"managing_director_employment_contract", # Pro GF einer
}
def _build_person_context(
base_ctx: dict[str, Any],
person: dict[str, Any],
doc_type: str,
gf_contract: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Erweitert base_context um person-spezifische Felder fuer Per-Person-Dokumente."""
ctx = dict(base_ctx)
name = person.get("name", "")
ctx["ASSIGNOR_NAME"] = name
ctx["ASSIGNOR_BIRTHDATE"] = person.get("geburtsdatum", "")
ctx["ASSIGNOR_ADDRESS"] = person.get("adresse", "")
ctx["ASSIGNOR_ROLE"] = person.get("internal_role") or "Gründer und Geschäftsführer"
ctx["HAS_ACADEMIC_BACKGROUND"] = bool(person.get("has_academic_background"))
# GF-Vertrag spezifisch
ctx["GF_NAME"] = name
ctx["GF_BIRTHDATE"] = person.get("geburtsdatum", "")
ctx["GF_ADDRESS"] = person.get("adresse", "")
ctx["GF_INTERNAL_TITLE"] = person.get("internal_role", "Geschäftsführer")
# IP-Bereiche: Person-spezifisch wenn vorhanden
ip_areas = person.get("ip_areas") or []
if ip_areas:
if isinstance(ip_areas, list):
ctx["IP_LIST_DETAILS"] = "\n".join(
f"- {area}" for area in ip_areas
)
else:
ctx["IP_LIST_DETAILS"] = str(ip_areas)
# GF-Contract Daten anwenden wenn vorhanden
if gf_contract:
if gf_contract.get("gross_annual_salary_eur"):
ctx["GROSS_ANNUAL_SALARY_EUR"] = f"{gf_contract['gross_annual_salary_eur']:,}".replace(",", ".")
ctx["HAS_BONUS"] = bool(gf_contract.get("has_bonus"))
ctx["HAS_COMPANY_CAR"] = bool(gf_contract.get("has_company_car"))
ctx["HAS_BAV"] = bool(gf_contract.get("has_bav"))
ctx["VACATION_DAYS"] = gf_contract.get("vacation_days", 30)
ctx["KUENDIGUNGSFRIST_GESELLSCHAFT_MONATE"] = gf_contract.get("kuendigungsfrist_gesellschaft_monate", 6)
ctx["KUENDIGUNGSFRIST_GF_MONATE"] = gf_contract.get("kuendigungsfrist_gf_monate", 3)
ctx["HAS_PARA_181_RELEASE"] = bool(gf_contract.get("para_181_release"))
ctx["SV_STATUS"] = gf_contract.get("sv_status", "sozialversicherungsfrei")
return ctx
@router.post("/generate", response_model=GenerationResponse)
def generate_documents(req: GenerationRequest, request: Request) -> GenerationResponse:
"""Hauptendpunkt: nimmt Wizard-State entgegen, generiert DOCX fuer alle ausgewaehlten Dokumente."""
# Database session is provided via FastAPI dependency injection in production.
# Hier vereinfacht direkt aus dem request state (verwendet Hauptverbindung)
from classroom_engine.database import SessionLocal
db: Session = SessionLocal()
try:
context = base_context(req.model_dump())
results: list[DocumentResult] = []
warnings: list[str] = []
# Gesellschafter + GF-Listen aus Request
gesellschafter = req.gesellschafter
gf_list = [g for g in gesellschafter if g.get("is_geschaeftsfuehrer")]
gf_contracts_map = {
c["gesellschafter_id"]: c
for c in req.gf_contracts
if c.get("gesellschafter_id")
}
for doc_type in req.selected_documents:
if doc_type in PER_PERSON_DOCS:
# Pro Person ein Dokument
if doc_type == "ip_assignment_agreement":
# IP-Assignment: pro Gründer (alle Gesellschafter, nicht nur GFs)
persons = gesellschafter or [{}]
elif doc_type == "managing_director_employment_contract":
# GF-Vertrag: nur pro GF
persons = gf_list or [{}]
else:
persons = [{}]
if not persons:
warnings.append(f"Keine Personen für '{doc_type}' vorhanden")
continue
for p in persons:
contract = gf_contracts_map.get(p.get("id"))
person_ctx = _build_person_context(context, p, doc_type, contract)
result = _render_one(
db, doc_type, person_ctx,
name_suffix=p.get("name", "")
)
if result is None:
warnings.append(f"Template '{doc_type}' nicht in Datenbank gefunden")
break
results.append(result)
else:
# Standard: ein Dokument pro Auswahl
result = _render_one(db, doc_type, context)
if result is None:
warnings.append(f"Template '{doc_type}' nicht in Datenbank gefunden")
continue
results.append(result)
if not results:
raise HTTPException(
status_code=400,
detail=f"Keines der angeforderten Dokumente konnte generiert werden. "
f"Warnings: {warnings}"
)
return GenerationResponse(documents=results, warnings=warnings)
finally:
db.close()
@router.get("/templates")
def list_available_templates(request: Request) -> dict[str, Any]:
"""Listet alle verfuegbaren Templates mit Kategorisierung."""
from classroom_engine.database import SessionLocal
db: Session = SessionLocal()
try:
rows = db.execute(
text("""
SELECT document_type, title, description, version, status,
lifecycle_stage, functional_category
FROM compliance_legal_templates
WHERE status = 'published'
ORDER BY functional_category, document_type
""")
).fetchall()
return {
"templates": [
{
"document_type": r.document_type,
"title": r.title,
"description": r.description,
"version": r.version,
"lifecycle_stage": list(r.lifecycle_stage or []),
"functional_category": r.functional_category,
}
for r in rows
],
"count": len(rows),
}
finally:
db.close()
@@ -0,0 +1,306 @@
"""License attribution endpoints — Task #23 Stufe 1-4.
The audit (Task #22) classified all 314,811 canonical_controls into
license_rule 1/2/3. The frontend, PDF renderer, and tech-file generator
now need to surface that classification in the form of:
- Stufe 1: a global /licenses overview page
- Stufe 2: an auto-footer in every exported PDF
- Stufe 3: an inline source badge on every rendered hazard/measure
- Stufe 4: a sources appendix in tech-file bundles
This module exposes three endpoints that all four stages consume:
GET /api/compliance/licenses/overview
Global aggregation by rule + per-source counts. Drives Stufe 1.
POST /api/compliance/licenses/aggregate
Body: {"control_uuids": ["uuid1", ...]}.
Returns per-rule grouping with source breakdown. Used by PDF
footer (Stufe 2) and tech-file appendix (Stufe 4) to build the
"sources used in this document" list.
GET /api/compliance/licenses/source-info/{control_uuid}
Single-control lookup for the inline source badge tooltip
(Stufe 3). Returns rule, source regulation, attribution text.
Why a new module instead of extending canonical_control_routes:
- canonical_control_routes serves the legacy SPDX-style license matrix
(canonical_control_licenses + canonical_control_sources, ~10 rows).
- This module is built on regulation_registry (252 rows) + the
license_rule on each control. Both schemas coexist; this module
doesn't disturb the legacy endpoints.
"""
from __future__ import annotations
import logging
from typing import Any, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import text
from sqlalchemy.orm import Session
from classroom_engine.database import get_db
router = APIRouter(prefix="/licenses", tags=["licenses"])
logger = logging.getLogger(__name__)
# ============================================================================
# Rule labels — used by frontend renderer
# ============================================================================
RULE_LABELS = {
1: {
"code": "R1",
"label_de": "Wörtlich übernehmbar",
"label_en": "Verbatim, no attribution required",
"render_full_text": True,
"attribution_required": False,
},
2: {
"code": "R2",
"label_de": "Wörtlich mit Attribution",
"label_en": "Verbatim with attribution",
"render_full_text": True,
"attribution_required": True,
},
3: {
"code": "R3",
"label_de": "Nur Identifier zitieren",
"label_en": "Identifier citation only",
"render_full_text": False,
"attribution_required": False,
},
}
# ============================================================================
# Response Schemas
# ============================================================================
class SourceCount(BaseModel):
regulation_id: str
regulation_name_de: Optional[str]
license_rule: int
license_type: Optional[str]
attribution: Optional[str]
jurisdiction: Optional[str]
source_type: Optional[str]
n_controls: int
class RuleBucket(BaseModel):
rule: int
label_de: str
label_en: str
attribution_required: bool
render_full_text: bool
total_controls: int
distinct_sources: int
sources: list[SourceCount]
class OverviewResponse(BaseModel):
total_controls: int
buckets: list[RuleBucket]
class AggregateRequest(BaseModel):
control_uuids: list[UUID]
class AggregateResponse(BaseModel):
total_in_request: int
matched: int
buckets: list[RuleBucket]
class SourceInfo(BaseModel):
control_uuid: UUID
license_rule: Optional[int]
license_label_de: Optional[str]
attribution_required: bool
render_full_text: bool
regulation_id: Optional[str]
regulation_name_de: Optional[str]
license_type: Optional[str]
attribution: Optional[str]
source_url: Optional[str]
# ============================================================================
# Endpoints
# ============================================================================
def _bucket(rule: int, sources: list[SourceCount]) -> RuleBucket:
meta = RULE_LABELS.get(rule, RULE_LABELS[3])
return RuleBucket(
rule=rule,
label_de=meta["label_de"],
label_en=meta["label_en"],
attribution_required=meta["attribution_required"],
render_full_text=meta["render_full_text"],
total_controls=sum(s.n_controls for s in sources),
distinct_sources=len(sources),
sources=sources,
)
@router.get("/overview", response_model=OverviewResponse)
def licenses_overview(db: Session = Depends(get_db)) -> OverviewResponse:
"""Global aggregation: total controls by rule, with per-source breakdown.
Drives Stufe 1 (the /licenses page).
"""
rows = db.execute(text("""
SELECT
COALESCE(cpl.source_regulation, '(no source)') AS regulation_name,
cc.license_rule,
COUNT(DISTINCT cc.id) AS n
FROM compliance.canonical_controls cc
LEFT JOIN compliance.control_parent_links cpl ON cpl.control_uuid = cc.id
WHERE cc.license_rule IS NOT NULL
GROUP BY 1, 2
""")).fetchall()
reg_rows = db.execute(text("""
SELECT regulation_name_de, regulation_id, license_type, attribution,
jurisdiction, source_type
FROM compliance.regulation_registry
""")).fetchall()
reg_by_name = {r.regulation_name_de: r for r in reg_rows if r.regulation_name_de}
by_rule: dict[int, list[SourceCount]] = {1: [], 2: [], 3: []}
seen: dict[tuple[int, str], int] = {}
total = 0
for row in rows:
rule = int(row.license_rule)
name = row.regulation_name
n = int(row.n)
key = (rule, name)
# multiple cpl entries per control deduplicate via DISTINCT, but a
# control with several source_regulations still gets counted once
# per regulation — that's the design.
seen[key] = seen.get(key, 0) + n
total += n
for (rule, name), n in seen.items():
reg = reg_by_name.get(name)
by_rule.setdefault(rule, []).append(SourceCount(
regulation_id=reg.regulation_id if reg else name,
regulation_name_de=name,
license_rule=rule,
license_type=reg.license_type if reg else None,
attribution=reg.attribution if reg else None,
jurisdiction=reg.jurisdiction if reg else None,
source_type=reg.source_type if reg else None,
n_controls=n,
))
for r in by_rule.values():
r.sort(key=lambda s: -s.n_controls)
buckets = [_bucket(rule, sources) for rule, sources in sorted(by_rule.items())]
return OverviewResponse(total_controls=total, buckets=buckets)
@router.post("/aggregate", response_model=AggregateResponse)
def aggregate_for_controls(
body: AggregateRequest,
db: Session = Depends(get_db),
) -> AggregateResponse:
"""Per-control license aggregation for PDF footer (Stufe 2) and
tech-file sources appendix (Stufe 4).
Returns a per-rule breakdown of which sources contributed to the
supplied control set. The frontend renderer turns this into the
"Verwendete Quellen" footer.
"""
if not body.control_uuids:
return AggregateResponse(total_in_request=0, matched=0, buckets=[])
rows = db.execute(text("""
SELECT
COALESCE(cpl.source_regulation, '(unknown)') AS regulation_name,
cc.license_rule,
COUNT(DISTINCT cc.id) AS n
FROM compliance.canonical_controls cc
LEFT JOIN compliance.control_parent_links cpl ON cpl.control_uuid = cc.id
WHERE cc.id = ANY(:ids) AND cc.license_rule IS NOT NULL
GROUP BY 1, 2
"""), {"ids": [str(u) for u in body.control_uuids]}).fetchall()
reg_rows = db.execute(text("""
SELECT regulation_name_de, regulation_id, license_type, attribution,
jurisdiction, source_type
FROM compliance.regulation_registry
""")).fetchall()
reg_by_name = {r.regulation_name_de: r for r in reg_rows if r.regulation_name_de}
by_rule: dict[int, list[SourceCount]] = {1: [], 2: [], 3: []}
matched_total = 0
for row in rows:
rule = int(row.license_rule)
n = int(row.n)
matched_total += n
reg = reg_by_name.get(row.regulation_name)
by_rule.setdefault(rule, []).append(SourceCount(
regulation_id=reg.regulation_id if reg else row.regulation_name,
regulation_name_de=row.regulation_name,
license_rule=rule,
license_type=reg.license_type if reg else None,
attribution=reg.attribution if reg else None,
jurisdiction=reg.jurisdiction if reg else None,
source_type=reg.source_type if reg else None,
n_controls=n,
))
for r in by_rule.values():
r.sort(key=lambda s: -s.n_controls)
buckets = [_bucket(rule, sources) for rule, sources in sorted(by_rule.items()) if sources]
return AggregateResponse(
total_in_request=len(body.control_uuids),
matched=matched_total,
buckets=buckets,
)
@router.get("/source-info/{control_uuid}", response_model=SourceInfo)
def source_info_for_control(
control_uuid: UUID,
db: Session = Depends(get_db),
) -> SourceInfo:
"""Single-control source info for the inline source badge (Stufe 3).
Used by the React `<SourceBadge>` component to populate its tooltip.
"""
row = db.execute(text("""
SELECT cc.license_rule, cpl.source_regulation AS regulation_name,
r.regulation_id, r.license_type, r.attribution, r.url AS source_url
FROM compliance.canonical_controls cc
LEFT JOIN compliance.control_parent_links cpl ON cpl.control_uuid = cc.id
LEFT JOIN compliance.regulation_registry r ON r.regulation_name_de = cpl.source_regulation
WHERE cc.id = :uuid
LIMIT 1
"""), {"uuid": str(control_uuid)}).fetchone()
if row is None:
raise HTTPException(status_code=404, detail="control not found")
rule = int(row.license_rule) if row.license_rule is not None else None
meta = RULE_LABELS.get(rule, {}) if rule else {}
return SourceInfo(
control_uuid=control_uuid,
license_rule=rule,
license_label_de=meta.get("label_de"),
attribution_required=meta.get("attribution_required", False),
render_full_text=meta.get("render_full_text", False),
regulation_id=row.regulation_id,
regulation_name_de=row.regulation_name,
license_type=row.license_type,
attribution=row.attribution,
source_url=row.source_url,
)
@@ -0,0 +1,97 @@
"""
P62 Marketing-Manager-freundlicher Scope-Disclaimer ("Was wir sehen / nicht sehen").
Erklaert in 30 Sekunden was unser Audit tatsaechlich pruefen kann und wo
die Grenzen sind. Ziel: vermeidet falsches Vertrauen in einen 100%-Score
und macht klar, wo Marketing/IT zusaetzlich pruefen muss.
"""
from __future__ import annotations
def build_scope_disclaimer_html() -> str:
"""Render: was wir sehen + was wir NICHT sehen koennen."""
return (
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
'max-width:700px;margin:8px auto 16px;padding:14px 18px;'
'background:#f0f9ff;border:1px solid #bfdbfe;border-radius:8px">'
'<h3 style="margin:0 0 8px;font-size:13px;color:#1e40af">'
'Was diese Pruefung leistet — und wo ihre Grenzen liegen</h3>'
'<div style="font-size:11px;color:#1e293b;margin-bottom:10px">'
'Wir sind ein <strong>technisches Audit-Tool</strong>, kein Anwalt. '
'Ein 100%-Score bedeutet nicht "rechtssicher" — er bedeutet "alle '
'Pruefkriterien automatisch erfuellt". Folgendes koennen wir vs. '
'koennen wir nicht:</div>'
'<table style="width:100%;border-collapse:collapse;font-size:11px;'
'margin-bottom:8px">'
'<thead><tr style="background:#dbeafe;color:#1e40af;text-align:left">'
'<th style="padding:5px 8px;width:50%">Was wir sehen</th>'
'<th style="padding:5px 8px;width:50%">Was wir NICHT sehen</th>'
'</tr></thead>'
'<tbody>'
'<tr style="border-top:1px solid #bfdbfe">'
'<td style="padding:5px 8px;color:#1e293b">'
'✓ Cookies/Storage im Browser nach Klick auf Akzeptieren/Ablehnen'
'</td>'
'<td style="padding:5px 8px;color:#475569">'
'✗ Server-seitiges Tracking (Meta Conversion API, GA4 Measurement '
'Protocol — der Browser sieht nichts davon)'
'</td></tr>'
'<tr style="border-top:1px solid #bfdbfe">'
'<td style="padding:5px 8px;color:#1e293b">'
'✓ Vendor-Listen aus dem Banner (TCF, CMP-Settings, Phase-G Klick-Tour)'
'</td>'
'<td style="padding:5px 8px;color:#475569">'
'✗ Wer die Daten beim Vendor tatsaechlich erhaelt / weiterleitet '
'(z.B. Google verteilt intern an Ads/Marketing-Plattform)'
'</td></tr>'
'<tr style="border-top:1px solid #bfdbfe">'
'<td style="padding:5px 8px;color:#1e293b">'
'✓ Texte und Pflichtangaben in DSE/Cookie-Richtlinie/Impressum'
'</td>'
'<td style="padding:5px 8px;color:#475569">'
'✗ Ob die internen Prozesse (Loeschkonzept, AVV-Pflege, '
'Mitarbeiter-Schulungen) tatsaechlich gelebt werden'
'</td></tr>'
'<tr style="border-top:1px solid #bfdbfe">'
'<td style="padding:5px 8px;color:#1e293b">'
'✓ Banner-UI-Verstoesse (Dark Patterns, ungleichgewichtige Buttons, '
'fehlender Reject-Mechanismus)'
'</td>'
'<td style="padding:5px 8px;color:#475569">'
'✗ Ob das Banner auf <em>jeder</em> Unterseite identisch ist '
'(wir messen die Einstiegsseite)'
'</td></tr>'
'<tr style="border-top:1px solid #bfdbfe">'
'<td style="padding:5px 8px;color:#1e293b">'
'✓ Untergeschobene Cookies (z.B. Google Tag Manager bringt automatisch '
'GA + Ads — siehe P61-Block unten)'
'</td>'
'<td style="padding:5px 8px;color:#475569">'
'✗ Drittland-Transfer auf Vertragsebene — ob ein SCC/DPF wirklich '
'vorliegt, koennen nur Sie selbst pruefen'
'</td></tr>'
'</tbody></table>'
'<div style="font-size:10px;color:#475569;margin-top:8px;'
'padding-top:6px;border-top:1px dashed #bfdbfe">'
'<strong>Hinweis fuer Marketing &amp; Geschaeftsfuehrung:</strong> '
'Selbst wenn dieser Bericht keinen Verstoss findet, kann ein '
'individueller Bescheid einer Aufsichtsbehoerde oder eine Klage '
'(NOYB, Verbraucherschutz, Sammelklage) zu einem anderen Ergebnis '
'kommen — etwa wenn beim Vendor selbst (Server-Side) personenbezogene '
'Daten verarbeitet werden, die wir browser-seitig nicht sehen. '
'Dieser Bericht ersetzt keine anwaltliche Pruefung, hilft aber, '
'<strong>technisch belegbare Verstoesse</strong> sofort zu schliessen.'
'</div>'
'</div>'
)
@@ -0,0 +1,330 @@
"""
VVT-Tabelle fuer den Email-Report pro Vendor eine Zeile, gruppiert
nach Empfaengerkategorie (Art. 30(1)(d) DSGVO).
Ausgelagert aus agent_doc_check_extras.py (LOC-Cap). Enthaelt:
* build_vvt_table_html Haupteinstieg, gruppiert + summary + P60 notice
* _render_vendor_section / _render_vendor_row_full Zeilenrenderer
* _link_status_badge / _flag_short kleine Helper
P60b Fuzzy-Match: Vendors mit teilweise befuellten Feldern (z.B. Sitzland
eingetragen) fallen nicht aus der Pattern-Notice raus, nur weil ihr
Flag-Set um 1-2 Items kleiner ist. Jaccard >= 0.7 deckt das ab.
"""
from __future__ import annotations
def _category_label(kat: str) -> str:
return {
"necessary": "Notwendig", "strictlynecessary": "Notwendig",
"preferences": "Praeferenzen", "functional": "Funktional",
"statistics": "Statistik", "marketing": "Marketing",
"unclassified": "Unklassifiziert",
}.get((kat or "").lower(), kat or "")
def _flag_short(f: str) -> str:
"""Lesbare deutsche Form fuer einen Flag-Token."""
labels = {
"no_cookies_listed": "Cookies fehlen",
"no_country": "Sitzland fehlt",
"no_privacy_url": "Privacy-Link fehlt",
"broken_privacy_url": "Privacy-Link broken",
"no_opt_out_url": "Opt-Out fehlt",
"broken_opt_out": "Opt-Out broken",
}
return labels.get(f, f)
def _link_status_badge(
url: str | None,
ok: bool | None,
status: int | None,
na_label: str | None = None,
) -> str:
if not url:
if na_label:
return ('<span style="color:#94a3b8;font-size:11px" '
f'title="{na_label}">&mdash;</span>')
return ('<span style="color:#dc2626;font-size:11px" '
'title="Kein Link">&#10007;</span>')
if ok:
return ('<span style="color:#16a34a;font-size:11px" '
f'title="HTTP {status}">&#10003;</span>')
status_str = str(status) if status else "?"
return ('<span style="color:#dc2626;font-size:11px" '
f'title="HTTP {status_str}">&#10007; ({status_str})</span>')
def _build_pattern_notice(vendors: list[dict]) -> str:
"""P60 + P60b: globale Notice wenn viele Vendors aehnliche Flag-Sets haben.
Mutiert vendors[].`_actions_in_global_notice` so dass die Zeilenrenderer
redundante per-row-Actions ueberspringen koennen.
"""
from collections import Counter
flag_sets: Counter = Counter()
for v in vendors:
flags = v.get("compliance_flags") or []
if flags:
flag_sets[tuple(sorted(flags))] += 1
if not flag_sets:
return ""
most_common, _ = flag_sets.most_common(1)[0]
most_common_set = set(most_common)
def _similar(flags: tuple) -> bool:
fs = set(flags)
if not fs or not most_common_set:
return False
inter = len(fs & most_common_set)
union = len(fs | most_common_set)
return union > 0 and (inter / union) >= 0.7
n_match = sum(cnt for fs, cnt in flag_sets.items() if _similar(fs))
share = n_match / max(1, len(vendors))
if not (n_match >= 8 and share >= 0.5):
return ""
from compliance.services.finding_action_recipes import recipe_for
labels = [_flag_short(f) for f in most_common]
shared_actions: list[str] = []
for f in most_common:
rec = recipe_for(f)
if rec:
shared_actions.append(
f'<li><strong>{_flag_short(f)}:</strong> '
f'{rec.get("fix_text", "").splitlines()[0][:180]}</li>'
)
for v in vendors:
if _similar(tuple(sorted(v.get("compliance_flags") or []))):
v["_actions_in_global_notice"] = True
return (
f'<div style="margin:8px 0 12px;padding:10px 14px;'
f'background:#fef3c7;border-left:3px solid #d97706;'
f'border-radius:4px;font-size:11px;color:#92400e">'
f'<strong>Wiederkehrendes Muster ({n_match} von {len(vendors)} '
f'Anbietern, {int(share*100)}%):</strong> '
f'Bei diesen Anbietern fehlen jeweils: '
f'<em>{", ".join(labels)}</em>. '
f'Vermutlich systembedingt (z.B. Settings-Export liefert '
f'nur Namen, oder Banner-API blockiert Detail-Extraktion). '
f'Die globalen Empfehlungen unten gelten fuer all diese Eintraege; '
f'in der Tabelle werden sie nicht pro Zeile wiederholt.'
+ (f'<ul style="margin:8px 0 0 0;padding-left:20px">{"".join(shared_actions)}</ul>'
if shared_actions else '')
+ '</div>'
)
def build_vvt_table_html(vendors: list[dict]) -> str:
"""Render per-vendor VVT-style table for the email."""
if not vendors:
return ""
from compliance.services.vendor_classifier import RECIPIENT_TYPE_SECTIONS
by_type: dict[str, list[dict]] = {}
for v in vendors:
rt = (v.get("recipient_type") or "OTHER").upper()
by_type.setdefault(rt, []).append(v)
n_total = len(vendors)
n_internal = sum(
1 for v in vendors
if (v.get("recipient_type") or "").upper() in ("INTERNAL", "GROUP_COMPANY")
)
n_external = n_total - n_internal
n_critical = sum(1 for v in vendors if v.get("compliance_score", 0) < 50)
summary_parts = [f"{n_total} Verarbeitungen erfasst"]
if n_internal and n_external:
summary_parts.append(
f"&mdash; {n_internal} eigene + {n_external} externe Empfaenger"
)
if n_critical:
summary_parts.append(
f', <strong style="color:#dc2626">{n_critical} unter 50%</strong>'
)
else:
summary_parts.append("&mdash; alle ueber 50%")
summary = " ".join(summary_parts)
pattern_notice = _build_pattern_notice(vendors)
out: list[str] = [
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
'max-width:760px;margin:0 auto 16px;padding:12px 16px;'
'background:#fafafa;border:1px solid #e5e7eb;border-radius:8px">',
'<h3 style="margin:0 0 4px;font-size:14px;color:#334155">'
'Vorschlag fuer das Verarbeitungsverzeichnis (Art. 30 DSGVO)</h3>',
# P91: Co-Pilot-Tonalitaet — Wahrscheinlichkeit statt Garantie,
# Empfehlung statt "Verstoss-Liste".
f'<p style="margin:0 0 8px;font-size:11px;color:#6b7280;line-height:1.5">'
f'Wir haben <strong>{n_total} Verarbeitungen</strong> aus dem '
f'Cookie-Banner abgeleitet, mit unserer globalen Anbieter-Bibliothek '
f'abgeglichen und nach Empfaengerkategorie (Art. 30(1)(d) DSGVO) '
f'gruppiert. Bei einer Reduktion der eingebundenen Anbieter, dem '
f'Wechsel zu europaeischen Alternativen und konsequenter Pruefung '
f'der tatsaechlich benoetigten Cookies ist eine Reduktion des '
f'Tracking-Footprints sowie Lizenz-Einsparungen wahrscheinlich. '
f'Eine fundierte Bewertung erfordert die Abstimmung mit dem '
f'Datenschutzbeauftragten.</p>'
f'<p style="margin:0 0 10px;font-size:11px;color:#6b7280">'
f'{summary}. Innerhalb jeder Gruppe nach Verbesserungspotenzial '
f'sortiert. Bei eigenen Verarbeitungen (INTERNAL/GROUP) sind '
f'Opt-Out und Privacy-Link '
'NICHT als Pflicht gewertet &mdash; der Widerruf erfolgt ueber das '
'nicht erforderlich (Widerruf ueber Banner, Privacy in der '
'Haupt-Datenschutzerklaerung dokumentiert).</p>',
pattern_notice,
]
for rtype, section_label in RECIPIENT_TYPE_SECTIONS:
rows = by_type.get(rtype) or []
if not rows:
continue
rows = sorted(rows, key=lambda v: v.get("compliance_score", 0))
n = len(rows)
n_bad = sum(1 for v in rows if v.get("compliance_score", 0) < 50)
bad_hint = (f' <span style="color:#dc2626">({n_bad} unter 50%)</span>'
if n_bad else "")
out.append(
f'<h4 style="margin:14px 0 4px;font-size:12px;color:#1e293b;'
f'border-top:1px solid #e2e8f0;padding-top:8px">'
f'{section_label} <span style="color:#94a3b8;font-weight:400">'
f'({n}){bad_hint}</span></h4>'
)
out.append(_render_vendor_section(rows))
out.append('</div>')
return "".join(out)
def _render_vendor_section(rows: list[dict]) -> str:
body: list[str] = [
'<table style="width:100%;border-collapse:collapse;font-size:11px">'
'<thead><tr style="background:#f1f5f9;color:#475569;text-align:left">'
'<th style="padding:5px 8px">Name</th>'
'<th style="padding:5px 8px">Kategorie</th>'
'<th style="padding:5px 8px">Sitz</th>'
'<th style="padding:5px 8px;text-align:center">Cookies</th>'
'<th style="padding:5px 8px;text-align:center">Opt-Out</th>'
'<th style="padding:5px 8px;text-align:center">Privacy</th>'
'<th style="padding:5px 8px;text-align:right">Score</th>'
'</tr></thead><tbody>',
]
for v in rows:
body.append(_render_vendor_row_full(v))
body.append('</tbody></table>')
return "".join(body)
def _render_vendor_row_full(v: dict) -> str:
rtype = (v.get("recipient_type") or "OTHER").upper()
is_own = rtype in ("INTERNAL", "GROUP_COMPANY")
cat = (v.get("category") or "").lower()
is_necessary = cat in ("necessary", "strictlynecessary")
name = v.get("name") or "Unbekannt"
category = _category_label(v.get("category", ""))
country = v.get("country") or ""
cookies = v.get("cookies") or []
n_cookies = len(cookies)
score = int(v.get("compliance_score", 0))
flags = v.get("compliance_flags") or []
opt_na_reason = ("Nicht erforderlich (eigene Verarbeitung — "
"Widerruf ueber Cookie-Banner)") if is_own else (
"Nicht erforderlich (§25 Abs. 2 TDDDG — technisch notwendig)"
if is_necessary else None
)
opt_status = _link_status_badge(
v.get("opt_out_url"), v.get("opt_out_ok"), v.get("opt_out_status"),
na_label=opt_na_reason,
)
privacy_na_reason = (
"Nicht erforderlich (eigene Verarbeitung — durch Haupt-DSI abgedeckt)"
if is_own else None
)
privacy_status = _link_status_badge(
v.get("privacy_policy_url"), v.get("privacy_ok"),
v.get("privacy_status"), na_label=privacy_na_reason,
)
score_color = ("#16a34a" if score >= 80 else
"#d97706" if score >= 50 else "#dc2626")
n_criteria = 3 if is_own else 5
n_failed = len(flags) if flags else 0
score_tooltip = (
f"{n_criteria - n_failed} von {n_criteria} Kriterien erfuellt"
+ (f" — fehlt: {', '.join(_flag_short(f) for f in flags[:3])}"
if flags else "")
)
actions_html = ""
skip_actions = bool(v.get("_actions_in_global_notice"))
if flags and not skip_actions:
from compliance.services.finding_action_recipes import recipe_for
action_items = []
for f in flags:
rec = recipe_for(f)
if not rec:
continue
action_items.append(
f'<li style="margin-bottom:6px"><strong>{_flag_short(f)}:</strong> '
f'{rec.get("what", "")}<br/>'
f'<span style="color:#475569"><strong>Was tun:</strong> '
f'{rec.get("fix_text", "").splitlines()[0][:200]}</span><br/>'
f'<span style="color:#94a3b8;font-size:9px">Quelle: '
f'{rec.get("why", "")[:160]}</span></li>'
)
if action_items:
actions_html = (
f'<details style="margin-top:4px"><summary style="cursor:pointer;'
f'color:#dc2626;font-size:10px">Was muss ich tun? '
f'({len(action_items)} Action{"s" if len(action_items) != 1 else ""})</summary>'
f'<ul style="margin:4px 0 0 14px;padding:0;font-size:10px;color:#1e293b">'
+ "".join(action_items)
+ '</ul></details>'
)
flag_str = ""
if flags:
flag_str = (
f'<div style="font-size:10px;color:#94a3b8;margin-top:2px">'
f'{", ".join(flags[:4])}</div>'
f'{actions_html}'
)
risk = v.get("compliance_risk") or {}
risk_label = risk.get("label") or ""
risk_badge = ""
if risk_label and risk_label != "unklar":
rc = {
"kritisch": ("#dc2626", "#fff"),
"hoch": ("#fecaca", "#991b1b"),
"mittel": ("#fde68a", "#92400e"),
"gering": ("#d1fae5", "#065f46"),
}.get(risk_label, ("#e5e7eb", "#475569"))
risk_badge = (f'<span style="margin-left:6px;padding:1px 5px;border-radius:3px;font-size:9px;'
f'background:{rc[0]};color:{rc[1]}">Risk: {risk_label}</span>')
return (
f'<tr style="border-top:1px solid #e2e8f0">'
f'<td style="padding:6px 8px;color:#1e293b;font-size:11px">'
f'{name}{risk_badge}{flag_str}</td>'
f'<td style="padding:6px 8px;color:#475569;font-size:11px">{category}</td>'
f'<td style="padding:6px 8px;color:#475569;font-size:11px">{country}</td>'
f'<td style="padding:6px 8px;text-align:center;color:#475569;font-size:11px">'
f'{n_cookies}</td>'
f'<td style="padding:6px 8px;text-align:center">{opt_status}</td>'
f'<td style="padding:6px 8px;text-align:center">{privacy_status}</td>'
f'<td style="padding:6px 8px;text-align:right;font-weight:600;'
f'color:{score_color};font-size:11px" title="{score_tooltip}">'
f'{score}%<div style="font-size:9px;font-weight:400;color:#94a3b8">'
f'{n_criteria - n_failed}/{n_criteria}</div></td>'
f'</tr>'
)
@@ -0,0 +1,198 @@
"""
A Audit-Transparenz / Audit-Quality-Checks.
Wenn der Crawler nicht alles gefunden hat, MUSS die Mail das prominent
zeigen sonst denkt der User 'alles gut' obwohl die Datenlage Luecken
hat.
Erkennt 4 Quality-Failures:
1. banner_detected=False trotz vorhandenem Cookie-Doc CMP-Tool ungeladen
2. cookie_doc >= 30k chars aber cmp_vendors < 10 Vendor-Extract unvollstaendig
3. doc_text submitted aber 0 chars geladen Crawler-Failure
4. cmp_vendors > 0 aber alle aus llm_cascade ohne Library-Match vermutl. unvollstaendig
Diese Findings landen IMMER im GF-1-Pager (auch wenn kein anderes
HIGH-Finding da ist) sie sagen "die Datenlage ist unvollstaendig,
manuelle Pruefung empfohlen".
"""
from __future__ import annotations
import logging
logger = logging.getLogger(__name__)
def _word_count(text: str | None) -> int:
if not text:
return 0
return len(text.split())
def check_banner_not_detected(
banner_result: dict | None,
cookie_doc_text: str | None,
) -> dict | None:
"""1) Banner nicht geladen aber Cookie-Doc vorhanden → CMP-Tool kaputt."""
if not isinstance(banner_result, dict):
return None
detected = banner_result.get("banner_detected")
if detected is None or detected is True:
return None
if not cookie_doc_text or len(cookie_doc_text) < 5000:
return None
return {
"severity": "HIGH",
"code": "audit_banner_not_detected",
"label": "Audit-Vorbehalt: Cookie-Banner konnte vom Crawler nicht "
"geladen werden",
"area": "Cookie-Banner",
"owner": "DSB + Marketing/CMP-Admin",
"detail": (
"Unser Crawler konnte das CMP-Tool dieser Site nicht analysieren — "
"weder Vendor-Liste noch Cookie-Verhalten konnten geprueft werden. "
"Moegliche Ursachen: Anti-Bot-Schutz (Akamai/Cloudflare/DataDome) "
"blockiert Playwright; das CMP-Skript laed nur fuer bestimmte "
"Geo-Regionen; ein neues CMP-Tool das wir noch nicht unterstuetzen. "
"Empfehlung: manuelle Pruefung des Banners durch DSB, alternativ "
"Cookie-Tabelle im Audit-Tool direkt einfuegen (Copy-Paste-Modus)."
),
"legal_basis": "Art. 5 (2) DSGVO Rechenschaftspflicht — der Audit-"
"Befund muss transparent zwischen 'geprueft & OK' und "
"'nicht pruefbar' unterscheiden.",
}
def check_vendor_extract_incomplete(
cookie_doc_text: str | None,
cmp_vendors: list | None,
) -> dict | None:
"""2) Cookie-Doc gross aber wenig Vendors → Extract unvollstaendig."""
wc = _word_count(cookie_doc_text)
n_vendors = len(cmp_vendors or [])
# Heuristik: Cookie-Doc >= 5000 Wörter (~30k chars) sollte zu mind. 15
# Vendors fuehren. Wenn weniger → Vendor-Extraktion hat den Text nicht
# vollstaendig verarbeitet.
if wc < 5000 or n_vendors >= 15:
return None
# Verhaeltniszahl bilden — je groesser das Doc, desto auffaelliger
return {
"severity": "HIGH" if wc >= 8000 else "MEDIUM",
"code": "audit_vendor_extract_thin",
"label": (
f"Audit-Vorbehalt: Cookie-Richtlinie hat {wc:,} Wörter, "
f"wir konnten aber nur {n_vendors} Vendor"
f"{'en' if n_vendors != 1 else ''} extrahieren"
).replace(",", "."),
"area": "Vendor-Liste / VVT",
"owner": "DSB + Marketing",
"detail": (
"Bei dieser Doc-Groesse erwarten wir typischerweise 20-50+ "
"Vendors in einer Cookie-Richtlinie. Die niedrige extrahierte "
"Zahl deutet auf eine Tabelle die unser LLM nicht vollstaendig "
"parsen konnte. Empfehlung: VVT-Tabelle mit DSB / Marketing "
"manuell abgleichen, oder die Cookie-Tabelle im Copy-Paste-Modus "
"neu einreichen — dort parsen wir Spalten deterministisch."
),
"legal_basis": "Art. 13(1)(e) DSGVO — die Empfaengerliste muss "
"vollstaendig sein; ein unvollstaendiger Audit darf "
"nicht als vollstaendig dargestellt werden.",
}
def check_url_fetch_failed(doc_entries: list | None) -> list[dict]:
"""3) Submitted URL aber 0 oder Mini-Text → Crawler-Failure pro Doc."""
out: list[dict] = []
for e in (doc_entries or []):
if not isinstance(e, dict):
continue
url = (e.get("url") or "").strip()
text = (e.get("text") or "").strip()
if not url or len(text) >= 200 or e.get("auto_discovered"):
continue
dt = e.get("doc_type", "doc")
rejected = e.get("rejected_url") or ""
out.append({
"severity": "MEDIUM",
"code": f"audit_url_fetch_failed_{dt}",
"label": (
f"Audit-Vorbehalt: {dt}-URL konnte nicht geladen werden "
f"({len(text)} Zeichen extrahiert)"
),
"area": dt,
"owner": "DSB + Web-Team",
"detail": (
f"Die eingegebene URL {url[:120]} lieferte weniger als 200 "
"Zeichen. Moegliche Ursachen: 404, JS-only Render, Anti-Bot, "
"Cookie-Wall. Auto-Discovery hat versucht eine Alternative "
"auf der Homepage zu finden — ohne Erfolg. Empfehlung: "
"korrekte URL pruefen oder den Text direkt einfuegen "
"(Copy-Paste-Modus)."
),
"legal_basis": "Art. 5 (2) DSGVO Rechenschaftspflicht.",
})
return out
def run_all(
banner_result: dict | None,
cookie_doc_text: str | None,
cmp_vendors: list | None,
doc_entries: list | None,
) -> list[dict]:
findings: list[dict] = []
try:
f1 = check_banner_not_detected(banner_result, cookie_doc_text)
if f1:
findings.append(f1)
except Exception as e:
logger.warning("audit_banner_not_detected failed: %s", e)
try:
f2 = check_vendor_extract_incomplete(cookie_doc_text, cmp_vendors)
if f2:
findings.append(f2)
except Exception as e:
logger.warning("audit_vendor_extract_thin failed: %s", e)
try:
findings.extend(check_url_fetch_failed(doc_entries))
except Exception as e:
logger.warning("audit_url_fetch_failed failed: %s", e)
return findings
def build_audit_quality_block_html(findings: list[dict]) -> str:
if not findings:
return ""
items: list[str] = []
for f in findings:
sev = f.get("severity", "MEDIUM")
sev_color = "#dc2626" if sev == "HIGH" else "#d97706"
items.append(
f'<li style="margin-bottom:10px;font-size:11px;line-height:1.5">'
f'<strong style="color:{sev_color}">[{sev}] {f.get("label","")}</strong>'
f'<div style="color:#475569;margin-top:3px">{f.get("detail","")}</div>'
f'<div style="color:#94a3b8;margin-top:2px;font-style:italic">'
f'{f.get("legal_basis","")}</div>'
f'</li>'
)
return (
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
'max-width:760px;margin:0 auto 16px;padding:14px 18px;'
'background:#fee2e2;border:1px solid #fecaca;border-radius:8px">'
'<div style="font-size:11px;color:#991b1b;text-transform:uppercase;'
'letter-spacing:1.2px;margin-bottom:4px;font-weight:600">'
'Audit-Vorbehalt — Datenlage unvollstaendig</div>'
f'<h3 style="margin:0 0 6px;font-size:14px;color:#1e293b">'
f'{len(findings)} Punkt'
f'{"e" if len(findings) != 1 else ""} bei denen der Audit selbst '
f'an Grenzen gestossen ist</h3>'
'<p style="margin:0 0 10px;font-size:11px;color:#475569;line-height:1.5">'
'Die folgenden Punkte betreffen NICHT die Compliance Ihrer Website, '
'sondern die Vollstaendigkeit unserer Pruefung. Bei diesen Bereichen '
'sollten Sie den Audit nicht als "alles ok" werten, sondern manuell '
'oder im Copy-Paste-Modus nachpruefen.'
'</p>'
'<ul style="margin:0 0 0 18px;padding:0">'
+ "".join(items) +
'</ul></div>'
)
@@ -0,0 +1,458 @@
"""
P92 + P94 Banner-Konsistenz-Checks (Post-hoc auf banner_result).
P92 CMP-Tool-Verfuegbarkeit:
Wenn "Anpassen"/"Einstellungen" angeklickt wurde und das Tool laed
nicht (Network-Error, Timeout, weisse Seite, fehlende
consent-Elemente nach Klick), ist das ein HIGH-Verstoss der
Nutzer hat formal die Moeglichkeit zur granularen Wahl, aber sie
funktioniert nicht.
P94 Banner-Init-vs-Cookie-Footer-Konsistenz:
Cookie-Liste im Initial-Banner-Settings darf nicht von der Liste
im permanenten Cookie-Richtlinien-Dokument abweichen. Wenn Banner
12 Cookies nennt, die Cookie-Doc aber 47, ist mindestens eine der
beiden Quellen unvollstaendig MEDIUM-Finding.
Beide liefern dict mit shape:
{"severity": "HIGH"|"MEDIUM", "code": str, "label": str, "detail": str}
oder None, wenn der Check nicht greift.
"""
from __future__ import annotations
import logging
import re
logger = logging.getLogger(__name__)
_ANPASSEN_KEYS = (
"anpassen", "einstellungen", "customize", "preferences",
"settings", "individuelle", "auswahl", "manage",
)
def _phases(banner_result: dict) -> dict:
if not isinstance(banner_result, dict):
return {}
return banner_result.get("phases") or {}
def check_cmp_tool_availability(banner_result: dict) -> dict | None:
"""P92 — Anpassen-Klick aber Settings-Tool defekt / leer."""
phases = _phases(banner_result)
settings_ph = phases.get("settings") or phases.get("after_settings_click")
if not isinstance(settings_ph, dict):
return None
initial_ph = phases.get("initial") or phases.get("before_accept") or {}
initial_text = (initial_ph.get("banner_text") or "").lower()
if not any(k in initial_text for k in _ANPASSEN_KEYS):
return None # Wenn kein Anpassen-Button gar nicht im Initial-Banner,
# ist das P100s Job — nicht hier doppelt melden.
error = settings_ph.get("error") or settings_ph.get("status_error")
settings_text = (settings_ph.get("banner_text") or "").strip()
has_categories = bool(
settings_ph.get("categories")
or settings_ph.get("category_tests")
or (settings_ph.get("structured_checks") or [])
)
has_toggles = bool(re.search(r"checkbox|toggle|switch|aria-checked",
(settings_ph.get("banner_html") or ""), re.I))
timed_out = bool(settings_ph.get("timeout"))
failure_signals: list[str] = []
if error:
failure_signals.append(f'Fehler: {str(error)[:120]}')
if timed_out:
failure_signals.append('Zeitueberschreitung beim Laden')
if len(settings_text) < 80 and not has_categories:
failure_signals.append(
f'Settings-Bereich nur {len(settings_text)} Zeichen, '
'keine Kategorien sichtbar'
)
if not has_toggles and not has_categories:
failure_signals.append(
'Keine Checkboxen / Toggles im Settings-Bereich'
)
if not failure_signals:
return None
return {
"severity": "HIGH",
"code": "cmp_tool_unavailable",
"label": 'Cookie-Einstellungen ueber "Anpassen" formal vorhanden, '
'Tool laed aber nicht oder ist leer',
"detail": " | ".join(failure_signals),
"legal_basis": "Art. 7 (3) DSGVO + EDPB 03/2022 — die Moeglichkeit "
"zur granularen Auswahl muss tatsaechlich funktionieren.",
}
def _normalize_cookie_names(items) -> set[str]:
out: set[str] = set()
if not items:
return out
for it in items:
if isinstance(it, str):
name = it.strip()
elif isinstance(it, dict):
name = (it.get("name") or it.get("cookie") or it.get("id") or "").strip()
else:
continue
if name and len(name) <= 120:
out.add(name.lower())
return out
def check_init_banner_vs_cookie_doc(
banner_result: dict,
cookie_doc_text: str | None,
) -> dict | None:
"""P94 — Cookie-Liste im Init-Banner vs in der Cookie-Richtlinie."""
if not cookie_doc_text or len(cookie_doc_text) < 500:
return None
phases = _phases(banner_result)
banner_cookies = _normalize_cookie_names(
(phases.get("settings") or {}).get("cookies") or []
) | _normalize_cookie_names(
(phases.get("initial") or phases.get("before_accept") or {}).get("cookies") or []
)
# Aus dem Cookie-Doc-Text: Cookie-Namen sind typischerweise
# camelCase oder _underscored, 4-40 Zeichen, ohne Leerzeichen.
candidates = set(re.findall(
r"\b([A-Za-z_][A-Za-z0-9_\-\.]{3,40})\b", cookie_doc_text
))
# Filter: heuristisch wahrscheinliche Cookie-Namen
doc_cookies: set[str] = set()
for c in candidates:
cl = c.lower()
if any(p in cl for p in (
"_ga", "_gid", "_gcl", "_fbp", "uc_", "ot_",
"cookieconsent", "sessionid", "csrf", "ajs_", "amp_",
"datadome", "incap_", "_pk_", "wp-", "yt-",
)):
doc_cookies.add(cl)
elif re.match(r"^[a-z][a-z0-9_]{3,30}$", cl) and (
"cookie" in cl or "consent" in cl or "track" in cl or "session" in cl
):
doc_cookies.add(cl)
if len(doc_cookies) < 5 or not banner_cookies:
return None # Datenlage zu duenn fuer sinnvolle Aussage.
only_in_doc = doc_cookies - banner_cookies
only_in_banner = banner_cookies - doc_cookies
if len(only_in_doc) < 5 and len(only_in_banner) < 3:
return None # Tolerable Abweichung.
severity = "MEDIUM"
# HIGH wenn beide Seiten massiv abweichen — dann fehlt klar
# die Cross-Reference.
if len(only_in_doc) >= 15 and len(only_in_banner) >= 5:
severity = "HIGH"
return {
"severity": severity,
"code": "banner_cookie_doc_mismatch",
"label": (
f"Cookie-Liste im Banner-Einstellungen ({len(banner_cookies)}) "
f"weicht von Cookie-Richtlinie ({len(doc_cookies)}) ab"
),
"detail": (
f"Nur im Cookie-Dokument: {len(only_in_doc)} Cookies (Beispiele: "
f"{', '.join(sorted(only_in_doc)[:5])}). "
f"Nur im Banner: {len(only_in_banner)} Cookies. "
"Empfehlung: eine der beiden Quellen als Single-Source-of-Truth "
"definieren und die andere automatisch generieren."
),
"legal_basis": (
"Art. 13(1)(c) DSGVO + Art. 12 DSGVO — Informationen ueber die "
"Verarbeitung muessen vollstaendig und konsistent sein."
),
}
_VENDOR_LIST_SIGNALS = (
"google analytics", "google ads", "facebook pixel", "meta pixel",
"hotjar", "matomo", "etracker", "salesforce", "hubspot",
"linkedin insight", "twitter conversion", "tiktok pixel",
"criteo", "the trade desk", "doubleclick",
)
def _vendors_mentioned_in_text(text: str) -> set[str]:
if not text:
return set()
t = text.lower()
return {v for v in _VENDOR_LIST_SIGNALS if v in t}
def check_three_source_vendor_consistency(
doc_texts: dict[str, str] | None,
cmp_vendors: list | None,
) -> dict | None:
"""P33 — 3-Spalten-Konsistenz: DSE vs Cookie-Doc vs Banner-Vendors.
Wenn ein Vendor (z.B. 'Google Analytics') in der DSE und in der
Cookie-Richtlinie genannt wird, aber NICHT in der Banner-Vendor-
Liste auftaucht (oder umgekehrt), ist die Drei-Quellen-Aussage
nicht konsistent. MEDIUM-Finding mit Liste der jeweils fehlenden
Vendors.
"""
if not doc_texts:
return None
dse_v = _vendors_mentioned_in_text(doc_texts.get("dse") or "")
cookie_v = _vendors_mentioned_in_text(doc_texts.get("cookie") or "")
banner_v: set[str] = set()
for v in (cmp_vendors or []):
name = (v.get("name") or "").lower()
for sig in _VENDOR_LIST_SIGNALS:
if sig in name or name in sig:
banner_v.add(sig)
sources_with_data = sum(1 for s in (dse_v, cookie_v, banner_v) if s)
if sources_with_data < 2:
return None
# Vendors in mind. einer Quelle aber nicht in allen vorhandenen
universe = dse_v | cookie_v | banner_v
issues: list[str] = []
for vendor in sorted(universe):
missing_in = []
if dse_v and vendor not in dse_v:
missing_in.append("DSE")
if cookie_v and vendor not in cookie_v:
missing_in.append("Cookie-Doc")
if banner_v and vendor not in banner_v:
missing_in.append("Banner-Liste")
if missing_in and len(missing_in) < sources_with_data:
issues.append(f'{vendor} (fehlt in: {", ".join(missing_in)})')
if not issues:
return None
return {
"severity": "MEDIUM",
"code": "three_source_vendor_inconsistency",
"label": (
f"{len(issues)} Vendor{'en' if len(issues) != 1 else ''} "
"nicht konsistent zwischen DSE, Cookie-Richtlinie und Banner"
),
"detail": (
"Folgende Vendors sind nicht in allen Quellen genannt: "
+ "; ".join(issues[:8])
+ (" ..." if len(issues) > 8 else "")
+ ". Empfehlung: zentrale Vendor-Liste pflegen und in alle "
"drei Dokumenttypen propagieren."
),
"legal_basis": "Art. 13(1)(c)+(e) DSGVO + EDPB 5/2020 — die "
"Empfaenger / Drittlandtransfers muessen ueber alle "
"Touch-Points konsistent kommuniziert werden.",
}
def check_banner_vs_cmp_partner_count(
banner_result: dict,
cmp_vendors: list | None,
) -> dict | None:
"""P75 — Banner nennt N Partner, CMP-Payload listet viel mehr.
Wenn der Banner-Text behauptet "5 Partner" oder "Wir und unsere
Partner", die CMP-Payload aber 100+ Vendors enthaelt, wird der
User getaeuscht.
"""
cmp_count = len(cmp_vendors or [])
if cmp_count < 20:
return None
initial_ph = (_phases(banner_result).get("initial")
or _phases(banner_result).get("before_accept") or {})
banner_text = (initial_ph.get("banner_text") or "")[:5000]
if not banner_text:
return None
m = re.search(r"\b(\d{1,4})\s*(?:partner|drittanbieter|vendor|"
r"anbieter|dienstleister)", banner_text, re.I)
if not m:
return None
claimed = int(m.group(1))
if claimed >= cmp_count * 0.6:
return None # Zahl im Banner ist plausibel.
return {
"severity": "HIGH",
"code": "banner_understates_vendor_count",
"label": (
f"Banner-Text nennt {claimed} Partner, CMP-Payload listet "
f"{cmp_count} Vendors"
),
"detail": (
f"Die im Banner-Text genannte Zahl ({claimed}) unterschaetzt die "
f"tatsaechliche Anzahl der Empfaenger ({cmp_count}) deutlich. "
"Empfehlung: Banner-Text auf die echte Vendor-Zahl heben oder "
"die Vendor-Liste reduzieren."
),
"legal_basis": (
"Art. 13(1)(e) DSGVO + EDPB 5/2020 — die Empfaenger / "
"Empfaengerkategorien muessen vollstaendig und nicht "
"verharmlosend angegeben sein."
),
}
def check_banner_copyability(banner_result: dict) -> dict | None:
"""P51a — Banner-Text muss kopierbar sein. CSS user-select:none oder
-webkit-user-select:none verhindert das (Article 7(2) DSGVO verstaendlich
und in einer Form, die spaetere Pruefung ermoeglicht).
"""
if not isinstance(banner_result, dict):
return None
phases = banner_result.get("phases") or {}
initial = phases.get("initial") or phases.get("before_accept") or {}
html = (initial.get("banner_html") or "")[:50000].lower()
if not html:
return None
blocked_signals = [
"user-select:none", "user-select: none",
"-webkit-user-select:none", "-webkit-user-select: none",
"-moz-user-select:none", "pointer-events:none",
"oncopy=\"return false", "onselectstart=\"return false",
]
hits = [s for s in blocked_signals if s in html]
if not hits:
return None
return {
"severity": "MEDIUM",
"code": "banner_not_copyable",
"label": "Banner-Text laesst sich nicht kopieren "
"(user-select:none / oncopy disabled)",
"detail": (
f'Im Banner-HTML gefunden: {", ".join(hits[:3])}. Der Nutzer '
"kann den Banner-Text nicht in eine Mail / Doku einfuegen, was "
"die spaetere Pruefung erschwert. Empfehlung: das CSS entfernen "
"oder explizit auf 'auto' setzen."
),
"legal_basis": "Art. 7 (1)+(2) DSGVO + EDPB 5/2020 — Einwilligungen "
"muessen in verstaendlicher und zugaenglicher Form "
"erteilt werden; eine spaetere Pruefung darf nicht "
"technisch erschwert werden.",
}
def check_consent_history(banner_result: dict) -> dict | None:
"""P51b — Es muss eine Moeglichkeit geben, die eigene Einwilligungs-
Historie einzusehen (Art. 7 (3) Widerruf muss so einfach wie die
Erteilung sein; das setzt voraus dass man WEISS was man einwilligt hat).
"""
if not isinstance(banner_result, dict):
return None
phases = banner_result.get("phases") or {}
blob_parts: list[str] = []
for ph in phases.values():
if isinstance(ph, dict):
blob_parts.append((ph.get("banner_text") or "")[:5000])
blob_parts.append((ph.get("banner_html") or "")[:20000])
blob = " ".join(blob_parts).lower()
if not blob:
return None
history_signals = [
"meine einwilligung", "consent-historie", "consent history",
"einwilligungshistorie", "einwilligungs-historie",
"ihre einwilligungen", "datenschutz-cockpit",
"privacy dashboard", "einwilligungs-protokoll",
"consent record", "consent log",
]
if any(s in blob for s in history_signals):
return None
return {
"severity": "MEDIUM",
"code": "consent_history_missing",
"label": "Keine sichtbare Consent-Historie / 'Meine Einwilligungen'-Ansicht",
"detail": (
"Im Banner und in den verlinkten Footer-Bereichen ist keine "
"Moeglichkeit erkennbar, die eigene Einwilligungs-Historie "
"einzusehen oder zu exportieren. Empfehlung: einen "
"'Meine Einwilligungen'-Bereich verlinken (Borlabs / Cookiebot / "
"Usercentrics bieten dafuer fertige Komponenten)."
),
"legal_basis": "Art. 7 (3) DSGVO + EDPB 5/2020 — der Widerruf muss "
"ebenso einfach sein wie die Erteilung, was eine "
"Sichtbarmachung der eigenen Einwilligungen voraussetzt.",
}
def run_all(banner_result: dict, cookie_doc_text: str | None = None,
cmp_vendors: list | None = None,
doc_texts: dict[str, str] | None = None) -> list[dict]:
findings: list[dict] = []
try:
f1 = check_cmp_tool_availability(banner_result)
if f1:
findings.append(f1)
except Exception as e:
logger.warning("P92 cmp_tool_availability failed: %s", e)
try:
f2 = check_init_banner_vs_cookie_doc(banner_result, cookie_doc_text)
if f2:
findings.append(f2)
except Exception as e:
logger.warning("P94 init_vs_cookie_doc failed: %s", e)
try:
f3 = check_banner_vs_cmp_partner_count(banner_result, cmp_vendors)
if f3:
findings.append(f3)
except Exception as e:
logger.warning("P75 banner_vs_cmp_count failed: %s", e)
try:
f4 = check_three_source_vendor_consistency(doc_texts, cmp_vendors)
if f4:
findings.append(f4)
except Exception as e:
logger.warning("P33 three_source_vendor failed: %s", e)
try:
f5 = check_banner_copyability(banner_result)
if f5:
findings.append(f5)
except Exception as e:
logger.warning("P51a copyability failed: %s", e)
try:
f6 = check_consent_history(banner_result)
if f6:
findings.append(f6)
except Exception as e:
logger.warning("P51b consent_history failed: %s", e)
return findings
def build_consistency_block_html(findings: list[dict]) -> str:
if not findings:
return ""
items: list[str] = []
for f in findings:
sev = f.get("severity", "MEDIUM")
sev_color = "#dc2626" if sev == "HIGH" else "#d97706"
items.append(
f'<li style="margin-bottom:10px;font-size:11px;line-height:1.5">'
f'<strong style="color:{sev_color}">[{sev}] {f.get("label","")}</strong>'
f'<div style="color:#475569;margin-top:3px">{f.get("detail","")}</div>'
f'<div style="color:#94a3b8;margin-top:2px;font-style:italic">'
f'{f.get("legal_basis","")}</div>'
f'</li>'
)
return (
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
'max-width:760px;margin:0 auto 16px;padding:14px 18px;'
'background:#fef3c7;border:1px solid #fcd34d;border-radius:8px">'
'<div style="font-size:11px;color:#92400e;text-transform:uppercase;'
'letter-spacing:1.2px;margin-bottom:4px;font-weight:600">'
'Banner-Konsistenz-Pruefung</div>'
f'<h3 style="margin:0 0 6px;font-size:14px;color:#1e293b">'
f'{len(findings)} Konsistenz-Finding{"s" if len(findings) != 1 else ""} '
'zwischen Banner-UI und Cookie-Richtlinie</h3>'
'<ul style="margin:8px 0 0 18px;padding:0">'
+ "".join(items) +
'</ul></div>'
)
@@ -0,0 +1,221 @@
"""
P80 Replay-Pipeline (Mini-Version v1).
Lädt einen persistierten Snapshot und rendert die Audit-Mail mit dem
AKTUELLEN Mail-Render-Code neu. Nutzbar fuer:
* Mail-Layout-Aenderungen (P63-P67, P82 1-Pager, P84 Diff-Mode) testen
* Action-Recipes anpassen
* Disclaimer-Text iterieren
* Pattern-Notice-Logik tunen
NICHT enthalten (kommt in v2):
* MC-Scorecard re-run mit aktuellem scope_doc_type-Filter (P72)
erfordert MC-Pipeline-Refactoring aus _run_compliance_check
* Vendor-Redundancy-Analyse re-run
Effekt v1: 7min Re-Scan -> 2-5 Sek fuer Mail-Layout-Iterationen.
Effekt v2 (spaeter): auch fuer MC-Filter-Tests.
"""
from __future__ import annotations
import logging
from typing import Any
from sqlalchemy.orm import Session
from compliance.services.check_snapshot import load_snapshot
logger = logging.getLogger(__name__)
def replay_from_snapshot(
db: Session,
snapshot_id: str,
recipient: str | None = None,
dry_run: bool = False,
) -> dict:
"""Replay audit mail render from snapshot.
Args:
db: SQLAlchemy session
snapshot_id: UUID of snapshot to replay
recipient: Override email recipient. None = skip send.
dry_run: If True, render HTML but do not send mail.
Returns:
{"snapshot_id", "html_size", "sections", "mail_sent", "preview"}
"""
snap = load_snapshot(db, snapshot_id)
if not snap:
return {"error": "snapshot not found", "snapshot_id": snapshot_id}
doc_entries = snap.get("doc_entries") or []
banner_result = snap.get("banner_result") or {}
profile_dict = snap.get("profile") or {}
cmp_vendors = snap.get("cmp_vendors") or []
site_label = snap.get("site_label") or snap.get("site_domain")
# Reconstruct doc_texts mapping (was the input to mail-render).
# Snapshot-Schema speichert text unter "text" (nicht full_text).
doc_texts: dict[str, str] = {}
for e in doc_entries:
dt = e.get("doc_type", "")
txt = (e.get("text") or e.get("full_text") or e.get("text_preview") or "").strip()
if dt and txt:
doc_texts[dt] = txt
# Build results list mock (just enough for mail-render)
def _dict_to_result(d: dict) -> Any:
"""Best-effort reconstruction. Snapshot didn't persist DocCheckResult
so we fake minimal fields. For real MC-replay (v2) we'd re-run the
check_document_completeness function against the snapshot text."""
return type("R", (), {
"doc_type": d.get("doc_type", "other"),
"label": d.get("doc_type", "Dokument"),
"completeness_pct": d.get("completeness_pct", 0),
"correctness_pct": d.get("correctness_pct"),
"checks": [],
"error": d.get("error", ""),
})()
results = [_dict_to_result(e) for e in doc_entries]
# Render mail sections
section_sizes: dict[str, int] = {}
parts: list[str] = []
# P82: GF-1-Pager zuerst (5-Bullet-Summary)
try:
from compliance.services.gf_one_pager import build_gf_one_pager_html
gf_html = build_gf_one_pager_html(
site_name=site_label or "",
scorecard=None, # Snapshot enthaelt keine MC-Scorecard
banner_result=banner_result,
library_mismatch_findings=None, # wird unten gefuellt
scan_context=snap.get("scan_context"),
)
parts.append(gf_html)
section_sizes["gf_one_pager"] = len(gf_html)
except Exception as e:
logger.warning("Replay: GF-1-pager failed: %s", e)
try:
from compliance.api.agent_doc_check_critical import build_critical_findings_html
critical_html = build_critical_findings_html(banner_result, None, results) or ""
parts.append(critical_html)
section_sizes["critical"] = len(critical_html)
except Exception as e:
logger.warning("Replay: critical-block failed: %s", e)
try:
from compliance.api.scope_disclaimer import build_scope_disclaimer_html
disclaimer = build_scope_disclaimer_html()
parts.append(disclaimer)
section_sizes["disclaimer"] = len(disclaimer)
except Exception as e:
logger.warning("Replay: disclaimer failed: %s", e)
try:
from compliance.api.agent_doc_check_banner import build_banner_deep_html
banner_html = build_banner_deep_html(banner_result) or ""
parts.append(banner_html)
section_sizes["banner"] = len(banner_html)
except Exception as e:
logger.warning("Replay: banner-block failed: %s", e)
try:
from compliance.api.vvt_table_renderer import build_vvt_table_html
vvt_html = build_vvt_table_html(cmp_vendors) or ""
parts.append(vvt_html)
section_sizes["vvt"] = len(vvt_html)
except Exception as e:
logger.warning("Replay: vvt failed: %s", e)
# P35 + P77 + P78 + P36: Textsignale (Save-Label, Cookies-in-DSE,
# JC-Klausel, Social-Embeds)
try:
from compliance.services.doc_text_signals import (
run_all as run_signal_checks,
build_signals_block_html,
)
cookie_doc_missing = not bool(doc_texts.get("cookie"))
sig_findings = run_signal_checks(
banner_result, doc_texts, cookie_doc_missing,
)
if sig_findings:
sig_html = build_signals_block_html(sig_findings)
parts.append(sig_html)
section_sizes["signals"] = len(sig_html)
except Exception as e:
logger.warning("Replay: signals block failed: %s", e)
# P92 + P94: Banner-Konsistenz
try:
from compliance.services.banner_consistency_checks import (
run_all as run_consistency_checks,
build_consistency_block_html,
)
cookie_doc_for_check = doc_texts.get("cookie") or doc_texts.get("dse") or ""
cons = run_consistency_checks(
banner_result or {}, cookie_doc_for_check, cmp_vendors,
doc_texts=doc_texts,
)
if cons:
cons_html = build_consistency_block_html(cons)
parts.append(cons_html)
section_sizes["consistency"] = len(cons_html)
except Exception as e:
logger.warning("Replay: consistency block failed: %s", e)
# P102: Cookie-Klassifikations-Pruefung
try:
from compliance.services.cookie_library_mismatch import (
detect_mismatches, build_mismatch_block_html,
)
cookies_seen: list[str] = []
for ph in (banner_result.get("phases") or {}).values():
if isinstance(ph, dict):
for ck in (ph.get("cookies") or []):
if isinstance(ck, str):
cookies_seen.append(ck)
elif isinstance(ck, dict) and ck.get("name"):
cookies_seen.append(ck["name"])
doc_for_check = doc_texts.get("cookie") or doc_texts.get("dse") or ""
if cookies_seen and doc_for_check:
mm = detect_mismatches(db, cookies_seen, doc_for_check)
if mm:
mm_html = build_mismatch_block_html(mm)
parts.append(mm_html)
section_sizes["library_mismatch"] = len(mm_html)
except Exception as e:
logger.warning("Replay: mismatch block failed: %s", e)
full_html = "".join(parts)
result = {
"snapshot_id": snapshot_id,
"check_id": snap.get("check_id"),
"site_domain": snap.get("site_domain"),
"html_size": len(full_html),
"sections": section_sizes,
"mail_sent": False,
"preview": full_html[:500] + "..." if len(full_html) > 500 else full_html,
"full_html": full_html, # P88 PDF-Export braucht das volle HTML.
}
if recipient and not dry_run:
try:
from compliance.services.smtp_sender import send_email
email_res = send_email(
recipient=recipient,
subject=f"[REPLAY] {site_label} (Snapshot {snapshot_id[:8]})",
body_html=full_html,
)
result["mail_sent"] = (email_res.get("status") == "sent")
result["mail_status"] = email_res.get("status")
except Exception as e:
logger.warning("Replay: mail send failed: %s", e)
result["mail_send_error"] = str(e)[:200]
return result
@@ -0,0 +1,179 @@
"""
P80 Snapshot + Replay-Helper.
Persistiert die Roh-Daten eines Compliance-Check-Laufs (DSE-Text,
Banner-HTML, Cookies, CMP-Vendors, Profile), damit die Audit-Pipeline
spaeter ohne erneuten Browser-Crawl die Mail-Render-/MC-Scoring-Logik
neu laufen kann.
Use Cases:
* Logik-Iteration (MC-Filter P72, Mail-Layout, Action-Recipes) ohne
7min Re-Crawl.
* Regression-Test: Golden-Truth-Library (P81).
* Diff-Mode: "was hat sich seit letztem Snapshot geaendert" (P84).
"""
from __future__ import annotations
import json
import logging
from typing import Any
from urllib.parse import urlparse
from sqlalchemy import text
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
def _to_jsonb(obj: Any) -> str:
"""Serialize to JSON-string for psycopg2 JSONB insertion."""
return json.dumps(obj, default=str, ensure_ascii=False)
def _derive_site_domain(doc_entries: list[dict]) -> str:
for e in doc_entries or []:
url = (e.get("url") or "").strip()
if url:
try:
netloc = urlparse(url).netloc.lower().replace("www.", "")
if netloc:
return netloc
except Exception:
continue
return "unknown"
def save_snapshot(
db: Session,
check_id: str,
doc_entries: list[dict],
banner_result: dict | None,
profile: Any,
cmp_vendors: list[dict] | None = None,
scan_context: dict | None = None,
site_label: str | None = None,
notes: str | None = None,
) -> str | None:
"""Persist scan raw data. Returns snapshot UUID on success."""
try:
profile_dict: dict = {}
if profile is not None:
if hasattr(profile, "__dict__"):
profile_dict = {k: v for k, v in profile.__dict__.items()
if not k.startswith("_")}
elif isinstance(profile, dict):
profile_dict = profile
domain = _derive_site_domain(doc_entries or [])
result = db.execute(
text("""
INSERT INTO compliance.compliance_check_snapshots
(check_id, site_domain, site_label,
doc_entries, banner_result, profile,
scan_context, cmp_vendors, notes)
VALUES (:cid, :dom, :lbl,
CAST(:de AS JSONB), CAST(:br AS JSONB), CAST(:pr AS JSONB),
CAST(:sc AS JSONB), CAST(:cv AS JSONB), :nt)
RETURNING id
"""),
{
"cid": check_id,
"dom": domain,
"lbl": site_label,
"de": _to_jsonb(doc_entries or []),
"br": _to_jsonb(banner_result) if banner_result else None,
"pr": _to_jsonb(profile_dict) if profile_dict else None,
"sc": _to_jsonb(scan_context) if scan_context else None,
"cv": _to_jsonb(cmp_vendors) if cmp_vendors else None,
"nt": notes,
},
)
snapshot_id = str(result.fetchone()[0])
db.commit()
logger.info(
"P80: snapshot saved id=%s check=%s domain=%s docs=%d",
snapshot_id, check_id, domain, len(doc_entries or []),
)
return snapshot_id
except Exception as e:
logger.warning("P80 snapshot save failed for %s: %s", check_id, e)
try:
db.rollback()
except Exception:
pass
return None
def load_snapshot(db: Session, snapshot_id: str) -> dict | None:
"""Load a snapshot by UUID. Returns dict with all fields or None."""
try:
row = db.execute(
text("""
SELECT id, check_id, site_domain, site_label,
doc_entries, banner_result, profile,
scan_context, cmp_vendors, created_at,
replay_count, notes
FROM compliance.compliance_check_snapshots
WHERE id = CAST(:sid AS uuid)
"""),
{"sid": snapshot_id},
).fetchone()
if not row:
return None
db.execute(
text("""
UPDATE compliance.compliance_check_snapshots
SET replay_count = replay_count + 1,
last_replay_at = now()
WHERE id = CAST(:sid AS uuid)
"""),
{"sid": snapshot_id},
)
db.commit()
return {
"id": str(row[0]),
"check_id": row[1],
"site_domain": row[2],
"site_label": row[3],
"doc_entries": row[4] or [],
"banner_result": row[5],
"profile": row[6] or {},
"scan_context": row[7] or {},
"cmp_vendors": row[8] or [],
"created_at": str(row[9]),
"replay_count": row[10],
"notes": row[11],
}
except Exception as e:
logger.warning("P80 snapshot load failed for %s: %s", snapshot_id, e)
return None
def list_snapshots_for_domain(db: Session, domain: str, limit: int = 20) -> list[dict]:
"""List recent snapshots for a domain (for diff-mode P84)."""
try:
rows = db.execute(
text("""
SELECT id, check_id, site_domain, created_at, replay_count, notes
FROM compliance.compliance_check_snapshots
WHERE site_domain = :dom
ORDER BY created_at DESC
LIMIT :lim
"""),
{"dom": domain.lower().replace("www.", ""), "lim": limit},
).fetchall()
return [
{
"id": str(r[0]),
"check_id": r[1],
"site_domain": r[2],
"created_at": str(r[3]),
"replay_count": r[4],
"notes": r[5],
}
for r in rows
]
except Exception as e:
logger.warning("P80 list_snapshots failed for %s: %s", domain, e)
return []
@@ -66,27 +66,35 @@ def _ensure_db() -> None:
CREATE TABLE IF NOT EXISTS check_payloads (
check_id TEXT PRIMARY KEY,
vendors TEXT, -- JSON list[dict]
profile TEXT -- JSON dict
profile TEXT, -- JSON dict
banner TEXT -- P20: JSON dict full banner_result
);
""")
# P20 migration: spalte 'banner' nachtraeglich anlegen wenn alt
try:
conn.execute("ALTER TABLE check_payloads ADD COLUMN banner TEXT")
except sqlite3.OperationalError:
pass
def record_check_payload(
check_id: str,
vendors: list[dict] | None,
profile: dict | None,
banner: dict | None = None,
) -> None:
"""Persist cmp_vendors + extracted_profile for later migration use."""
"""Persist cmp_vendors + extracted_profile + banner_result (P20)."""
try:
_ensure_db()
with sqlite3.connect(DB_PATH) as conn:
conn.execute(
"INSERT OR REPLACE INTO check_payloads "
"(check_id, vendors, profile) VALUES (?, ?, ?)",
"(check_id, vendors, profile, banner) VALUES (?, ?, ?, ?)",
(
check_id,
json.dumps(vendors or [], ensure_ascii=False),
json.dumps(profile or {}, ensure_ascii=False),
json.dumps(banner or {}, ensure_ascii=False) if banner else None,
),
)
conn.commit()
@@ -95,13 +103,13 @@ def record_check_payload(
def get_check_payload(check_id: str) -> dict | None:
"""Load cmp_vendors + extracted_profile for a previous check."""
"""Load cmp_vendors + extracted_profile + banner_result for a previous check."""
try:
_ensure_db()
with sqlite3.connect(DB_PATH) as conn:
conn.row_factory = sqlite3.Row
row = conn.execute(
"SELECT vendors, profile FROM check_payloads WHERE check_id=?",
"SELECT vendors, profile, banner FROM check_payloads WHERE check_id=?",
(check_id,),
).fetchone()
if not row:
@@ -109,6 +117,7 @@ def get_check_payload(check_id: str) -> dict | None:
return {
"vendors": json.loads(row["vendors"] or "[]"),
"profile": json.loads(row["profile"] or "{}"),
"banner": json.loads(row["banner"]) if row["banner"] else None,
}
except Exception as e:
logger.warning("get_check_payload failed: %s", e)
@@ -0,0 +1,303 @@
"""
P59 Cookie-Behavior-Validator.
4 Layer:
A) Open Cookie Database lookup (declared category vs library category)
B) Network-Traffic-Analyse (cookie value sent to third-party domains)
C) Value-Pattern (Hash/UUID/PII heuristics on "essential"-declared cookies)
D) Cross-Site frequency (from library metadata, when available)
Returns list of findings with severity + Art. 5(1)(b) DSGVO reference.
"""
from __future__ import annotations
import logging
import re
from typing import Iterable
from sqlalchemy import text
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
# --- Patterns für Layer C ---
_HASH_PATTERN = re.compile(r"^[a-f0-9]{32,64}$", re.IGNORECASE)
_UUID_PATTERN = re.compile(
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
re.IGNORECASE,
)
_BASE64_LONG = re.compile(r"^[A-Za-z0-9+/=]{40,}$")
_PII_KEYS = ("email", "@", "user_id", "userid", "username", "phone")
# --- Purpose-Keyword-Bags für Layer A2 (Zweck-Match) ---
_PURPOSE_KEYWORDS = {
"marketing": {
"tracking", "tracker", "targeting", "profiling", "profile",
"advertis", "marketing", "remarket", "retargeting", "conversion",
"audience", "behavioral", "behaviour", "personali", "interest",
"campaign", "promotion", "pixel", "fingerprint",
},
"statistics": {
"analytic", "analyse", "analyz", "measure", "measurement", "metric",
"statistic", "performance", "telemetr", "monitoring", "usage",
"reichweite", "auswert",
},
"essential": {
"session", "sitzung", "authentic", "anmeld", "login", "logout",
"security", "sicherheit", "csrf", "xsrf", "cookie consent",
"cookie-einwilligung", "technisch notwendig", "load balanc",
"lastverteil",
},
"functional": {
"preference", "praeferen", "language", "sprache", "layout", "design",
"cart", "warenkorb", "wishlist", "merkliste", "favorit", "theme",
"darkmode", "darstellung",
},
"social_media": {
"social", "facebook", "twitter", "linkedin", "instagram", "youtube",
"embed", "share", "teilen",
},
}
def _classify_purpose_text(text_value: str) -> set[str]:
"""Return set of categories whose keywords appear in the purpose-text."""
if not text_value:
return set()
t = text_value.lower()
matches = set()
for cat, kws in _PURPOSE_KEYWORDS.items():
if any(k in t for k in kws):
matches.add(cat)
return matches
def _lookup_library(db: Session, cookie_name: str,
cookie_domain: str) -> dict | None:
"""Layer A: find best library match."""
# Exact domain match first, then wildcard
cur = db.execute(text("""
SELECT actual_category, purpose_en, purpose_de, vendor_name,
data_receivers, source_name, source_url, confidence
FROM compliance.cookie_library
WHERE cookie_name = :name
ORDER BY
CASE WHEN domain_pattern = :domain THEN 0
WHEN :domain ILIKE replace(domain_pattern, '*', '%') THEN 1
ELSE 2 END,
confidence DESC
LIMIT 1
"""), {"name": cookie_name, "domain": cookie_domain or ""})
row = cur.fetchone()
if not row:
return None
return {
"actual_category": row[0], "purpose_en": row[1],
"purpose_de": row[2], "vendor_name": row[3],
"data_receivers": row[4] or [],
"source_name": row[5], "source_url": row[6],
"confidence": float(row[7] or 0),
}
def _value_pattern_flag(value: str | None, declared_category: str) -> str | None:
"""Layer C: detect tracking-typical patterns in essential-declared cookies."""
if not value or declared_category not in ("essential", "functional"):
return None
v = value.strip()
if not v or len(v) < 16:
return None
if _UUID_PATTERN.match(v):
return "UUID (Persistent Identifier)"
if _HASH_PATTERN.match(v):
return f"Hash-Wert ({len(v)} Hex-Zeichen — typisch User-ID)"
if _BASE64_LONG.match(v):
return f"Base64-Long ({len(v)} Zeichen — typisch Tracking-Payload)"
vlow = v.lower()
for kw in _PII_KEYS:
if kw in vlow:
return f"PII-Marker '{kw}' im Wert"
return None
def _category_label(cat: str) -> str:
return {
"essential": "technisch notwendig",
"functional": "funktional",
"statistics": "Analyse/Statistik",
"marketing": "Marketing/Werbung",
"social_media": "Social Media",
"unknown": "unbekannt",
}.get(cat, cat)
def validate_cookie_behavior(
db: Session,
cookies_set: Iterable[dict],
network_requests: list[dict] | None = None,
first_party_domain: str = "",
) -> list[dict]:
"""Run all 4 layers, return list of finding dicts.
Each cookie dict should have: name, domain (optional), value (optional),
declared_category (e.g. 'essential'), max_age_seconds (optional)."""
findings: list[dict] = []
network_requests = network_requests or []
fp_domain = (first_party_domain or "").lower().lstrip(".")
# Pre-index network: which receivers got which cookie?
receivers_by_cookie: dict[str, set[str]] = {}
for req in network_requests:
try:
host = (req.get("host") or req.get("url", "")).lower()
for cname in (req.get("cookies_sent") or []):
receivers_by_cookie.setdefault(cname, set()).add(host)
except Exception:
continue
for c in cookies_set or []:
name = (c.get("name") or "").strip()
if not name:
continue
declared = (c.get("declared_category") or "").lower()
domain = (c.get("domain") or "").lstrip(".").lower()
value = c.get("value")
# Layer A: library lookup + 3-Tier-Severity (Kategorie / Zweck / Kombi)
lib = _lookup_library(db, name, domain)
declared_purpose = (c.get("declared_purpose") or "").strip()
if lib and lib["actual_category"] != "unknown":
# Layer A1: Kategorie-Mismatch (NUR wenn relevant — declared ist
# essential/functional aber library sagt marketing/statistics)
category_mismatch = (
declared
and lib["actual_category"] != declared
and declared in ("essential", "functional")
and lib["actual_category"] in ("marketing", "statistics",
"social_media")
)
# Layer A2: Zweck-Text-Mismatch
purpose_mismatch = False
purpose_explain = ""
if declared_purpose:
declared_cats = _classify_purpose_text(declared_purpose)
actual_cat = lib["actual_category"]
# Mismatch wenn deklarierter Zweck-Text auf andere Kategorie
# zeigt als die Library-Realität (z.B. declared "Sitzung" aber
# tatsaechlich Marketing-Cookie)
if actual_cat in ("marketing", "statistics", "social_media"):
# Verdacht wenn deklarierter Zweck NUR essential/functional
# Patterns hat (nichts zu Marketing/Analytics)
if declared_cats and actual_cat not in declared_cats:
# ausserdem: irgendein "harmloser" Keyword da
if declared_cats & {"essential", "functional"}:
purpose_mismatch = True
purpose_explain = (
f"Beschriebener Zweck deutet auf "
f"{', '.join(_category_label(c) for c in declared_cats)}, "
f"das Cookie wird aber tatsaechlich fuer "
f"{_category_label(actual_cat)} eingesetzt"
)
# 3-Tier-Severity
if category_mismatch and purpose_mismatch:
# CRITICAL — Vorsatz / Boeswilligkeit-Indiz
findings.append({
"layer": "A1+A2",
"cookie_name": name,
"severity": "CRITICAL",
"type": "DUAL_MISMATCH_INTENT",
"text": (
f"Cookie '{name}' weist DOPPELTE Diskrepanz auf: "
f"deklarierte Kategorie '{_category_label(declared)}' UND "
f"deklarierter Zweck stimmen NICHT mit dem realen Verhalten "
f"('{_category_label(lib['actual_category'])}') ueberein. "
f"{purpose_explain}. {lib['source_name']}-Quelle: "
f"{lib['purpose_en'][:120] if lib['purpose_en'] else ''}. "
f"Doppel-Mismatch indiziert Vorsatz nach DSK Beschluss 2024-02 "
f"(Cookie gezielt verschleiert) — siehe Bussgeld-Risiko Art. 83 "
f"DSGVO bei wissentlicher Taeuschung. Konstruktive Annahme: "
f"haeufig Marketing-/Agentur-Versehen ohne DSB-Kontrolle."
),
"legal_ref": "Art. 5(1)(a)+(b) DSGVO + DSK Beschluss 2024-02",
"source": lib["source_url"] or lib["source_name"],
})
elif purpose_mismatch:
# HIGH — Zweck stimmt nicht (Ahnungslosigkeit oder Vorsatz)
findings.append({
"layer": "A2",
"cookie_name": name,
"severity": "HIGH",
"type": "PURPOSE_TEXT_MISMATCH",
"text": (
f"Cookie '{name}': {purpose_explain}. {lib['source_name']}: "
f"{(lib['purpose_en'] or '')[:140]}. Deutet auf fehlende "
f"Detail-Pruefung des Cookie-Verhaltens — Beschreibung sollte "
f"das tatsaechliche Verhalten reflektieren (Art. 13 DSGVO + "
f"Transparenz)."
),
"legal_ref": "Art. 13(1)(c) DSGVO (Zweck-Angabe muss korrekt sein)",
"source": lib["source_url"] or lib["source_name"],
})
elif category_mismatch:
# MEDIUM — Kategorie-Tag falsch, kann Fluechtigkeitsfehler sein
findings.append({
"layer": "A1",
"cookie_name": name,
"severity": "MEDIUM",
"type": "CATEGORY_MISMATCH",
"text": (
f"Cookie '{name}' ist als '{_category_label(declared)}' "
f"kategorisiert. {lib['source_name']} klassifiziert ihn als "
f"'{_category_label(lib['actual_category'])}'"
+ (f"{lib['purpose_en'][:120]}" if lib['purpose_en'] else "")
+ f". Vermutlich Konfigurations-Versehen im Consent-Tool "
f"(haeufig bei Migrations zwischen CMP-Anbietern). "
f"Korrektur: Cookie auf '{_category_label(lib['actual_category'])}'"
f" umstellen, Consent neu einholen."
),
"legal_ref": "Art. 5(1)(b) DSGVO (Zweckbindung)",
"source": lib["source_url"] or lib["source_name"],
})
# Layer B: network traffic
receivers = receivers_by_cookie.get(name, set())
third_party = [r for r in receivers
if r and fp_domain and not r.endswith(fp_domain)]
if third_party and declared in ("essential", "functional"):
findings.append({
"layer": "B",
"cookie_name": name,
"severity": "HIGH",
"type": "THIRD_PARTY_DESPITE_ESSENTIAL",
"text": (
f"Cookie '{name}' ist als '{_category_label(declared)}' "
f"deklariert, der Wert wird aber an {len(third_party)} "
f"externe(n) Empfaenger uebertragen: "
f"{', '.join(sorted(third_party))[:200]}. "
f"Damit liegt eine Drittlandstransfer-/Drittanbieter-Verarbeitung "
f"vor, die nicht durch die deklarierte Zweckbestimmung gedeckt ist."
),
"legal_ref": "Art. 5(1)(b) Zweckbindung + Art. 13(1)(f) DSGVO",
})
# Layer C: value pattern
flag = _value_pattern_flag(value, declared)
if flag:
findings.append({
"layer": "C",
"cookie_name": name,
"severity": "MEDIUM",
"type": "TRACKING_PATTERN_DESPITE_ESSENTIAL",
"text": (
f"Cookie '{name}' ist als '{_category_label(declared)}' "
f"deklariert, enthaelt aber: {flag}. Werte mit Tracking-Charakter "
f"sind in nicht einwilligungsbeduerftigen Kategorien fragwuerdig."
),
"legal_ref": "Art. 5(1)(b) DSGVO + DSK-OH Telemedien 2024",
})
# Layer D: cross-site frequency (later — needs metadata import)
return findings
@@ -0,0 +1,157 @@
"""
P102 Cookie-Library-Mismatch-Detection pro Site.
Vergleicht die in einem Lauf erfassten Cookies (mit deklarierter
Kategorie aus dem Cookie-Doc-Text) gegen die Library
(compliance.cookie_library). Liefert Mismatches: deklariert Library.
Genutzt im Mail-Render als neuer Block "Cookie-Klassifikations-Pruefung".
"""
from __future__ import annotations
import logging
import re
from sqlalchemy import text
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
_CATEGORY_PATTERNS = [
(re.compile(r"\b(?:strictly[-\s]?)?(?:notwendig|essential|funktional|"
r"funktionscookie|technisch[- ]?notwendig)\b", re.I),
"essential"),
(re.compile(r"\b(?:tracking|analytics|analyse|statistik|"
r"measurement|performance)\b", re.I),
"statistics"),
(re.compile(r"\b(?:marketing|werbung|advertising|targeting|"
r"drittanbieter[- ]?cookie)\b", re.I),
"marketing"),
(re.compile(r"\b(?:social[-\s]?media|share|like)\b", re.I),
"social_media"),
]
def _category_for(name: str, doc_text: str) -> str | None:
if not doc_text or not name:
return None
idx = doc_text.find(name)
if idx < 0:
return None
window = doc_text[max(0, idx - 50):idx + 400]
for pat, cat in _CATEGORY_PATTERNS:
if pat.search(window):
return cat
return None
def _load_library(db: Session) -> dict[str, dict]:
rows = db.execute(text(
"SELECT cookie_name, actual_category, vendor_name "
"FROM compliance.cookie_library"
)).fetchall()
return {r[0].lower(): {"category": r[1], "vendor": r[2]} for r in rows}
def detect_mismatches(
db: Session,
cookie_names_seen: list[str],
doc_text: str,
) -> list[dict]:
"""Returns list of finding dicts."""
if not cookie_names_seen or not doc_text:
return []
lib = _load_library(db)
findings: list[dict] = []
seen: set[str] = set()
for cname in cookie_names_seen:
cname = (cname or "").strip()
if not cname or cname.lower() in seen:
continue
seen.add(cname.lower())
declared = _category_for(cname, doc_text)
if not declared:
continue
lib_entry = lib.get(cname.lower())
if not lib_entry:
continue
lib_cat = lib_entry["category"]
if lib_cat in (None, "unknown") or lib_cat == declared:
continue
# HIGH wenn Library sagt Marketing aber Site als essential/statistics
# deklariert (faktische Drittland-/Werbe-Verarbeitung versteckt
# als technische/statistische Notwendigkeit). MEDIUM sonst.
severity = "HIGH" if (
lib_cat == "marketing" and declared in ("essential", "statistics")
) else "MEDIUM"
findings.append({
"cookie": cname,
"declared_category": declared,
"library_category": lib_cat,
"library_vendor": lib_entry["vendor"],
"severity": severity,
})
return findings
def build_mismatch_block_html(findings: list[dict]) -> str:
"""Render the mismatch findings as a Mail-Block."""
if not findings:
return ""
n_high = sum(1 for f in findings if f["severity"] == "HIGH")
items: list[str] = []
for f in findings[:25]:
sev_color = "#dc2626" if f["severity"] == "HIGH" else "#d97706"
items.append(
f'<li style="margin-bottom:6px;font-size:11px">'
f'<code style="background:#f1f5f9;padding:1px 4px;border-radius:2px">'
f'{f["cookie"]}</code> '
f'<span style="color:#64748b">— deklariert als</span> '
f'<strong>{f["declared_category"]}</strong>, '
f'<span style="color:#64748b">unsere Bibliothek + verbreitete '
f'Vendor-Doku sagen</span> <strong style="color:{sev_color}">'
f'{f["library_category"]}</strong> '
f'(Vendor: {f["library_vendor"]})'
f'</li>'
)
return (
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
'max-width:760px;margin:0 auto 16px;padding:14px 18px;'
'background:#fffbeb;border:1px solid #fde68a;border-radius:8px">'
'<div style="font-size:11px;color:#92400e;text-transform:uppercase;'
'letter-spacing:1.2px;margin-bottom:4px;font-weight:600">'
'Cookie-Klassifikations-Pruefung</div>'
f'<h3 style="margin:0 0 8px;font-size:14px;color:#1e293b">'
f'{len(findings)} Cookie{"s" if len(findings) != 1 else ""}'
f' mit abweichender Klassifikation gefunden'
f'{f" ({n_high} davon mit erhoehter Bedeutung)" if n_high else ""}'
f'</h3>'
'<p style="margin:0 0 10px;font-size:11px;color:#475569;line-height:1.5">'
'Wir haben die in Ihrer Cookie-Richtlinie deklarierte Kategorie der '
'Cookies mit unserer globalen Bibliothek (~2.300 Cookies aus Open-'
'Cookie-Database + DACH-spezifischen Quellen) und der verbreiteten '
'Vendor-Doku abgeglichen. Bei den folgenden Cookies stimmt die '
'deklarierte Kategorie nicht mit dem typischerweise erwarteten '
'Zweck ueberein. Das ist kein automatischer Verstoss — aber ein '
'Pruefanlass: bei Marketing-Cookies braucht es Einwilligung, bei '
'als "essential" deklarierten nicht. Empfehlung: mit DSB / '
'Marketing-Agentur klaeren ob die Klassifikation korrigiert '
'oder die Einwilligung anders eingeholt werden muss.</p>'
'<ul style="margin:0 0 0 18px;padding:0">'
+ "".join(items) +
'</ul>'
'<p style="margin:8px 0 0;font-size:10px;color:#94a3b8;'
'font-style:italic">Hintergrund: Art. 13(1)(c) DSGVO + EDPB 5/2020 '
'— der angegebene Verarbeitungszweck muss dem tatsaechlichen '
'entsprechen.</p>'
'</div>'
)
@@ -0,0 +1,147 @@
"""
Cookie-zu-Vendor-Fallback (P52 Lite).
Wenn weder cmp_payloads noch vendor_llm_extract Vendors lieferten,
matchen wir die im after_accept gesehenen Cookies gegen die
compliance.cookie_library und bauen Vendor-Records aus den Library-
Eintraegen (cookie_name vendor_name, actual_category).
Typisches Szenario: VW nutzt ein Custom-CMP (cookiemgmt-Wrapper),
kein bekanntes IAB-Tool. cmp_payloads = leer, aber after_accept.cookies
hat 28 Eintraege. Diese 28 Cookies sind in der Library = ~15-20 Vendors.
"""
from __future__ import annotations
import logging
import re
from typing import Iterable
from sqlalchemy import text
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
def _collect_cookie_names(banner_result: dict | None) -> set[str]:
names: set[str] = set()
if not isinstance(banner_result, dict):
return names
for ph in (banner_result.get("phases") or {}).values():
if not isinstance(ph, dict):
continue
for ck in (ph.get("cookies") or []):
if isinstance(ck, str):
names.add(ck.strip())
elif isinstance(ck, dict):
n = (ck.get("name") or "").strip()
if n:
names.add(n)
return {n for n in names if n and len(n) <= 120}
def lookup_vendors_from_library(
db: Session,
cookie_names: Iterable[str],
) -> list[dict]:
"""Resolves cookie names to vendor records via cookie_library."""
names = [n for n in cookie_names if n]
if not names:
return []
rows = db.execute(text(
"""
SELECT cookie_name, actual_category, vendor_name
FROM compliance.cookie_library
WHERE LOWER(cookie_name) = ANY(:lc)
"""
), {"lc": [n.lower() for n in names]}).fetchall()
by_vendor: dict[str, dict] = {}
for cname, cat, vendor in rows:
if not vendor:
continue
entry = by_vendor.setdefault(vendor, {
"name": vendor,
"country": "",
"purpose": "",
"category": cat or "",
"opt_out_url": "",
"privacy_policy_url": "",
"persistence": "",
"cookies": [],
"source": "library_fallback",
})
entry["cookies"].append({
"name": cname, "purpose": "", "expiry": "",
"is_third_party": True,
})
return list(by_vendor.values())
def fallback_vendors_for_run(
db: Session,
banner_result: dict | None,
existing_vendor_count: int,
cookie_doc_text: str | None = None,
) -> list[dict]:
"""Returns extra vendor records to merge with the run's cmp_vendors.
VW-Lehre: cmp_vendors=6 (alle LLM-grob) reicht NICHT die echte
Cookie-Tabelle hat 30+ Eintraege. Wir fuehren den Lookup jetzt auch
bei mid-tier-Counts aus, solange after_accept >= 15 Cookies hat
ODER der Cookie-Doc-Text Cookie-Tabellen-Signale enthaelt.
"""
names = _collect_cookie_names(banner_result)
# Erweitere names um Cookie-Namen die im Cookie-Doc-Text als
# Tabellen-Eintraege auftauchen (Pattern: NAME gefolgt von
# "Tracking Cookies"/"Session Cookies"/"Funktional"/...).
if cookie_doc_text:
names |= _extract_cookie_names_from_doc(cookie_doc_text)
# Skip-Bedingungen ueberarbeitet:
# - sehr wenige Cookies UND >= 5 Vendors schon vorhanden → skip
# - sonst IMMER versuchen
if len(names) < 5 and existing_vendor_count >= 5:
return []
if not names:
return []
vendors = lookup_vendors_from_library(db, names)
if vendors:
logger.info(
"Cookie-Library-Fallback: %d Vendors aus %d Cookies "
"(existing cmp_vendors=%d)",
len(vendors), len(names), existing_vendor_count,
)
return vendors
_TABLE_ROW_RE = re.compile(
r"\b([A-Za-z_][A-Za-z0-9_\-\.]{2,40})\s+"
r"(?:Tracking Cookies|Session Cookies|Funktional|Marketing|"
r"Analytics|Performance|Notwendig|Strictly\s+Necessary|"
r"Statistik|Werbung|Targeting|Personalisierung)",
re.I,
)
def _extract_cookie_names_from_doc(text: str) -> set[str]:
"""Pattern-basiertes Erkennen von Cookie-Tabellen-Zeilen.
VW-Cookie-Tabelle hat Form:
'IDE Tracking Cookies (Marketing) Dieser Cookie ... 13 Monate'
Das fangen wir mit einem Cookie-Name-vor-Category-Pattern.
"""
out: set[str] = set()
for m in _TABLE_ROW_RE.finditer(text):
name = m.group(1).strip()
# Filter offensichtliche Noise (Pronomen, Verben)
nl = name.lower()
if nl in ("dieser", "diese", "ein", "der", "die", "das",
"session", "permanent", "funktional", "notwendig",
"marketing", "analytics", "werbung", "anbieter",
"google", "facebook", "tracking", "cookie", "cookies"):
continue
if len(name) >= 3:
out.add(name)
return out
@@ -0,0 +1,285 @@
"""
Parst Cookie-Tabellen die der User direkt ins Frontend kopiert.
Typische Quellen:
* Browser-Copy aus VW/BMW/Mercedes Cookie-Richtlinie (Tab-getrennt)
* Excel-Export aus Borlabs / OneTrust / Cookiebot Admin (CSV / Pipe)
* Markdown-Tabelle aus interner Doku
Erkennt 4 Spalten-Layouts (heuristisch):
1. [Name, Kategorie, Beschreibung, Speicherdauer, Provider]
2. [Name, Provider, Zweck, Speicherdauer]
3. [Name, Beschreibung, Speicherdauer]
4. nur [Name, Speicherdauer]
Output: gleiche Vendor-Record-Struktur wie vendor_extractor / LLM
damit der Rest der Pipeline (VVT-Tabelle, Library-Mismatch-Check) ohne
Aenderung weiterlaeuft.
"""
from __future__ import annotations
import logging
import re
logger = logging.getLogger(__name__)
_CATEGORY_LABELS = (
"notwendig", "essential", "funktional", "tracking", "marketing",
"statistik", "analyse", "analytics", "performance", "werbung",
"advertising", "targeting", "preferences", "social_media",
"strictly necessary", "personalisierung",
)
def _looks_like_separator(line: str) -> str | None:
"""Detect the column-separator of a tabular line."""
if "\t" in line and line.count("\t") >= 2:
return "\t"
if " | " in line and line.count(" | ") >= 2:
return " | "
if ";" in line and line.count(";") >= 2 and "," not in line[:20]:
return ";"
if "," in line and line.count(",") >= 3:
return ","
return None
def _normalize_category(s: str) -> str:
sl = s.lower().strip()
for cat in _CATEGORY_LABELS:
if cat in sl:
if cat in ("notwendig", "essential", "strictly necessary"):
return "essential"
if cat in ("tracking", "marketing", "werbung",
"advertising", "targeting"):
return "marketing"
if cat in ("statistik", "analyse", "analytics", "performance"):
return "statistics"
if cat == "funktional":
return "functional"
if cat == "social_media":
return "social_media"
return sl[:30]
def _parse_persistence(s: str) -> str:
"""Extracts 'Speicherdauer' notation."""
m = re.search(
r"(\d+\s*(sekunde|minute|stunde|tag|woche|monat|jahr|day|month|year)[^\s,;|]{0,5})",
s, re.I,
)
if m:
return m.group(1).strip()[:80]
if re.search(r"\bsession\b", s, re.I):
return "Session"
if re.search(r"permanent", s, re.I):
return "Permanent"
return ""
def parse_cookie_table(text: str) -> list[dict]:
"""Returns vendor-records aus einer copy-pasted Cookie-Tabelle.
Bei nicht-tabellarischem Text: return [].
"""
if not text or len(text) < 100:
return []
lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
if not lines:
return []
# Sample 30 lines to detect separator
sample = lines[:60]
sep_counts: dict[str, int] = {}
for ln in sample:
sep = _looks_like_separator(ln)
if sep:
sep_counts[sep] = sep_counts.get(sep, 0) + 1
if not sep_counts or max(sep_counts.values()) < 3:
return []
sep = max(sep_counts, key=sep_counts.get)
logger.info("cookies_table_parser: detected separator '%s' (%d hits)",
sep, sep_counts[sep])
# Parse rows
rows: list[list[str]] = []
for ln in lines:
if sep in ln:
parts = [p.strip().strip('"') for p in ln.split(sep)]
if len(parts) >= 2 and parts[0]:
rows.append(parts)
if len(rows) < 3:
return []
# Detect column layout from header (first row) or by content
header_row = [c.lower() for c in rows[0]]
has_header = any(h in " ".join(header_row) for h in
("cookie", "name", "anbieter", "provider", "zweck",
"kategorie", "speicherdauer", "dauer"))
data_rows = rows[1:] if has_header else rows
# Map columns by header keyword or by position
col_idx = {"name": 0, "provider": -1, "category": -1,
"purpose": -1, "persistence": -1}
if has_header:
for i, h in enumerate(header_row):
if "name" in h or "cookie" in h:
col_idx["name"] = i
elif "anbieter" in h or "provider" in h or "domain" in h:
col_idx["provider"] = i
elif "kategorie" in h or "type" in h or "art" in h:
col_idx["category"] = i
elif "zweck" in h or "purpose" in h or "beschreib" in h:
col_idx["purpose"] = i
elif "speicher" in h or "dauer" in h or "lebens" in h or "expir" in h:
col_idx["persistence"] = i
# Aggregate by vendor (or by name if no vendor column)
by_vendor: dict[str, dict] = {}
for r in data_rows:
if len(r) < 2:
continue
name = r[col_idx["name"]] if col_idx["name"] < len(r) else r[0]
name = (name or "").strip()
if not name or len(name) > 120 or len(name) < 2:
continue
provider = ""
if col_idx["provider"] >= 0 and col_idx["provider"] < len(r):
provider = r[col_idx["provider"]].strip()
if not provider:
# Heuristik: wenn Spalte 'Anbieter' fehlt, raten aus Cookie-Name
provider = _guess_vendor(name)
if not provider:
provider = "Unbekannter Anbieter"
category = ""
purpose = ""
persistence = ""
if col_idx["category"] >= 0 and col_idx["category"] < len(r):
category = _normalize_category(r[col_idx["category"]])
if col_idx["purpose"] >= 0 and col_idx["purpose"] < len(r):
purpose = r[col_idx["purpose"]][:500]
if col_idx["persistence"] >= 0 and col_idx["persistence"] < len(r):
persistence = _parse_persistence(r[col_idx["persistence"]])
if not category:
# Inferieren aus purpose-Text
category = _normalize_category(purpose)
entry = by_vendor.setdefault(provider, {
"name": provider, "country": "",
"purpose": purpose[:300] if purpose else "",
"category": category,
"opt_out_url": "", "privacy_policy_url": "",
"persistence": persistence,
"cookies": [],
"source": "table_paste",
})
entry["cookies"].append({
"name": name, "purpose": purpose[:200],
"expiry": persistence, "is_third_party": True,
})
out = list(by_vendor.values())
logger.info("cookies_table_parser: %d vendors / %d cookies parsed",
len(out), sum(len(v["cookies"]) for v in out))
return out
_FLAT_ROW_RE = re.compile(
r"\b([A-Za-z_][A-Za-z0-9_\-\.]{1,40})\s+"
r"((?:Tracking|Session|Funktional|Marketing|Analytics|Performance|"
r"Notwendig|Strictly\s+Necessary|Statistik|Personalisierung)"
r"[A-Za-zäöüÄÖÜß \-\(\)]*?Cookies?[^A-Z]{0,400}?)"
r"(?:(\d+)\s*(Sekunde|Minute|Stunde|Tag|Woche|Monat|Jahr|day|month|year)|"
r"\b(Session|Permanent)\b)",
re.I | re.S,
)
def parse_flat_cookie_text(text: str) -> list[dict]:
"""Variante fuer Sites wie VW die ihre Cookie-Tabelle als flachen
Text liefern (Cookie-Name + Kategorie + Beschreibung + Dauer in
einem Block hintereinander, ohne klare Trenner).
Regex sucht nach 'NAME [Tracking|Session|Funktional...] Cookies
... [13 Monate|Session|Permanent]' und behandelt jeden Match als
eine Tabellen-Zeile.
"""
if not text or len(text) < 500:
return []
matches = list(_FLAT_ROW_RE.finditer(text))
if len(matches) < 3:
return []
by_vendor: dict[str, dict] = {}
seen_names: set[str] = set()
for m in matches:
name = m.group(1).strip()
nl = name.lower()
if nl in seen_names:
continue
if nl in ("dieser", "diese", "ein", "der", "die", "das",
"session", "permanent", "funktional", "notwendig",
"marketing", "analytics", "werbung", "anbieter",
"tracking", "cookie", "cookies", "und", "von",
"einer", "ist", "alle", "noch", "auch", "name",
"art", "zweck", "dauer"):
continue
if len(name) < 3 or len(name) > 60:
continue
seen_names.add(nl)
category = _normalize_category(m.group(2) or "")
persistence = ""
if m.group(3):
persistence = f"{m.group(3)} {m.group(4)}"
elif m.group(5):
persistence = m.group(5)
purpose = (m.group(2) or "").strip()[:300]
vendor = _guess_vendor(name) or "Unbekannter Anbieter"
entry = by_vendor.setdefault(vendor, {
"name": vendor, "country": "",
"purpose": purpose, "category": category,
"opt_out_url": "", "privacy_policy_url": "",
"persistence": persistence,
"cookies": [],
"source": "flat_pattern",
})
entry["cookies"].append({
"name": name, "purpose": purpose[:200],
"expiry": persistence, "is_third_party": True,
})
out = list(by_vendor.values())
logger.info("parse_flat_cookie_text: %d vendors / %d cookies",
len(out), sum(len(v["cookies"]) for v in out))
return out
_VENDOR_GUESS = (
("_ga", "Google"), ("_gid", "Google"), ("_gcl_", "Google"),
("ANID", "Google"), ("AID", "Google"), ("FPGCLDC", "Google"),
("IDE", "Google DoubleClick"), ("DSID", "Google"),
("_fbp", "Meta / Facebook"), ("fr", "Meta / Facebook"),
("_pin_unauth", "Pinterest"), ("_uetsid", "Microsoft Bing"),
("_uetvid", "Microsoft Bing"), ("MUID", "Microsoft"),
("tt_", "TikTok"), ("li_at", "LinkedIn"),
("OptanonConsent", "OneTrust"), ("cookieconsent", "Borlabs / Cookie-CMP"),
("eta_", "etracker"), ("matomo", "Matomo"),
("_hjid", "Hotjar"), ("_hj", "Hotjar"),
("__cf", "Cloudflare"), ("datadome", "DataDome"),
("incap_", "Imperva Incapsula"),
("ajs_", "Segment"), ("amp_", "Amplitude"),
("sat_track", "Adobe Experience Cloud"),
("AMCV_", "Adobe Experience Cloud"),
("s_cc", "Adobe Analytics"), ("s_sq", "Adobe Analytics"),
)
def _guess_vendor(cookie_name: str) -> str:
nl = cookie_name.lower()
for prefix, vendor in _VENDOR_GUESS:
if nl.startswith(prefix.lower()) or prefix.lower() in nl:
return vendor
return ""
@@ -39,6 +39,12 @@ AGB_CHECKLIST = [
"patterns": [
r"vertragsschluss", r"zustandekommen",
r"contract\s+formation", r"angebot\s+und\s+annahme",
# P41: English synonyms
r"conclusion\s+of\s+(?:the\s+)?contract",
r"contract\s+(?:is\s+)?(?:concluded|formed)",
r"offer\s+and\s+acceptance",
r"how\s+the\s+contract\s+is\s+formed",
r"contracts?\s+(?:apply|between\s+the\s+provider)",
],
"severity": "HIGH",
"hint": "Haeufiger Fehler: Die Bestellung wird als Angebot des Kunden dargestellt, aber die Auftragsbestaetigung als Annahme — das ist nur wirksam, wenn klar zwischen Eingangsbestaetigung (§312i BGB) und Auftragsbestaetigung/Annahme unterschieden wird.",
@@ -140,6 +146,15 @@ AGB_CHECKLIST = [
r"lieferung", r"leistungserbringung", r"delivery",
r"lieferfrist", r"bereitstellung",
r"(?:zugang|zugriff).*(?:dienst|leistung)",
# P41: English synonyms (SaaS-style)
r"provision\s+of\s+(?:the\s+)?(?:service|services)",
r"(?:performance|rendering)\s+of\s+(?:the\s+)?(?:service|services)",
r"availability\s+of\s+(?:the\s+)?service",
r"service\s+level\s+(?:agreement|description)",
r"access\s+to\s+(?:the\s+)?(?:service|platform)",
r"description\s+of\s+(?:the\s+)?services?",
r"(?:^|\n)\s*#+\s*[§\d\.\s]*availability\b",
r"(?:^|\n)\s*#+\s*[§\d\.\s]*description\s+of\s+services?",
],
"severity": "MEDIUM",
"hint": "Bei Fernabsatzvertraegen muss der Unternehmer spaetestens 30 Tage nach Vertragsschluss liefern (§475 Abs. 1 BGB). Formulierungen wie 'Lieferung in der Regel in...' oder 'voraussichtlich' sind nur als Richtwert zulaessig, nicht als verbindliche Frist.",
@@ -230,6 +245,12 @@ AGB_CHECKLIST = [
r"(?:agb|bedingung).*datenschutz",
r"personenbezogen.*daten.*(?:agb|vertrag)",
r"dsgvo.*(?:agb|vertrag)",
# P41: English synonyms
r"data\s+protection.*(?:terms|contract)",
r"(?:terms|contract).*data\s+protection",
r"personal\s+data.*(?:terms|contract|agreement)",
r"gdpr.*(?:terms|contract|agreement)",
r"privacy\s+(?:policy|notice).*(?:see|refer)",
],
"severity": "LOW",
"hint": "AGB und Datenschutzerklaerung sind rechtlich getrennte Dokumente. Mischen Sie KEINE Datenschutzhinweise in die AGB ein — stattdessen genuegt ein Verweis: 'Details zur Datenverarbeitung finden Sie in unserer Datenschutzerklaerung [Link].'",
@@ -245,6 +266,11 @@ AGB_CHECKLIST = [
r"(?:unwirksamkeit|nichtigkeit)\s+(?:einer|einzelner)\s+(?:bestimmung|klausel|regelung)",
r"(?:sollte|sofern).*(?:bestimmung|klausel).*(?:unwirksam|nichtig)",
r"(?:uebrigen|übrigen)\s+bestimmungen.*(?:unberuehrt|unberührt|wirksam|bestehen)",
# P41: English equivalents
r"severability",
r"(?:invalid|unenforceable).*(?:provision|clause)",
r"remaining\s+provisions\s+(?:shall\s+)?(?:remain|continue)",
r"(?:provision|clause)\s+(?:is\s+)?(?:invalid|unenforceable|void)",
],
"severity": "LOW",
"hint": "Die klassische salvatorische Klausel ('unwirksame Bestimmungen werden durch wirksame ersetzt') ist nach BGH-Rechtsprechung in AGB selbst unwirksam. Besser: Nur die Erhaltungsklausel verwenden ('Die uebrigen Bestimmungen bleiben wirksam').",
@@ -260,6 +286,12 @@ AGB_CHECKLIST = [
r"(?:agb|bedingung).*(?:ae|ä)nder",
r"(?:anpassung|aktualisierung).*(?:agb|bedingung|geschaeftsbedingung|geschäftsbedingung)",
r"(?:neue\s+fassung|neufassung).*(?:agb|bedingung)",
# P41: English
r"amendments?.*(?:terms|conditions|agreement)",
r"(?:terms|conditions|agreement).*(?:may\s+be\s+)?amend",
r"changes?\s+to\s+(?:these\s+)?(?:terms|conditions)",
r"modification\s+of\s+(?:the\s+)?(?:terms|agreement)",
r"(?:revised|updated)\s+(?:terms|conditions|version)",
],
"severity": "LOW",
"hint": "AGB-Aenderungsklauseln bei B2C sind nur unter engen Voraussetzungen wirksam (BGH Az. XI ZR 388/10): Aenderungsgrund muss konkret benannt sein, Kunde muss angemessene Frist zur Kuendigung erhalten. Pauschale 'Wir koennen jederzeit aendern'-Klauseln sind unwirksam.",
@@ -275,6 +307,12 @@ AGB_CHECKLIST = [
r"verbraucherrecht",
r"(?:gesetzlich|zwingende)\w*\s+recht\w*.*(?:unberuehrt|unberührt|bestehen\s+bleiben)",
r"(?:verbrauch|konsument).*(?:recht|anspruch|schutz)",
# P41: English equivalents — UCTA / Consumer Rights Act
r"consumer\s+(?:rights?|protection|laws?)",
r"statutory\s+rights?\s+(?:are|shall\s+be|remain)\s+unaffected",
r"mandatory\s+(?:law|rights?)\s+(?:remain|shall\s+remain)",
r"(?:nothing|no\s+provision)\s+(?:in\s+these\s+)?(?:terms|conditions)\s+(?:shall|limits?|excludes?)",
r"contracts?\s+with\s+consumers?\s+(?:are\s+not\s+concluded|excluded)",
],
"severity": "LOW",
"hint": "Haeufigste §309 BGB-Verstoesse: Pauschalierter Schadensersatz ohne Gegenbeweismoeglichkeit (Nr. 5), Haftungsausschluss bei Koerperschaeden (Nr. 7a), Schriftformerfordernis fuer Kuendigung (Nr. 13). Jede dieser Klauseln ist einzeln abmahnfaehig.",
@@ -259,6 +259,8 @@ AVV_CHECKLIST = [
r"(?:l(?:oe|ö)schung|rueckgabe|r(?:ue|ü)ckgabe)\s+(?:nach|bei|zum)\s+(?:vertragsende|beendigung|ablauf)",
r"(?:nach|bei)\s+(?:beendigung|ablauf|ende)\s+(?:des\s+)?(?:vertrag|auftrag)[\s\S]{0,100}(?:l(?:oe|ö)sch|rueckgabe|r(?:ue|ü)ckgabe|vernicht)",
r"(?:alle|saemtliche)\s+(?:personenbezogenen?\s+)?daten\s+(?:l(?:oe|ö)sch|vernicht|zurueckgeb|zur(?:ue|ü)ckgeb)",
# P39: reverse order — "loescht/gibt ... nach Beendigung/Ablauf"
r"(?:l(?:oe|ö)sch|gibt|gibt\s+zur(?:ue|ü)ck|vernicht)\w*[\s\S]{0,150}(?:nach|bei|zum)\s+(?:beendigung|ablauf|ende|vertragsende)",
],
"severity": "CRITICAL",
"hint": "Art. 28(3)(g) DSGVO: Nach Ende der Verarbeitung muessen alle personenbezogenen Daten geloescht oder zurueckgegeben werden — nach Wahl des Verantwortlichen. Ausnahme nur bei gesetzlicher Aufbewahrungspflicht.",
@@ -336,6 +338,10 @@ AVV_CHECKLIST = [
r"data\s+breach",
r"(?:meld|benachrichtig|informier|unterricht)\w*[\s\S]{0,50}(?:verletzung|vorfall|sicherheit)",
r"art(?:ikel)?\s*\.?\s*33\s+(?:dsgvo|ds-?gvo)",
# P39: "Datenpanne" als gleichwertiges Synonym (sehr verbreitet)
r"datenpanne",
r"meldung\s+von\s+datenpannen",
r"art\.?\s*33\s+abs\.?\s*\d",
],
"severity": "CRITICAL",
"hint": "Art. 33(2) DSGVO: Der Auftragsverarbeiter muss den Verantwortlichen UNVERZUEGLICH ueber jede Datenschutzverletzung informieren. Die 72-Stunden-Frist des Verantwortlichen gegenueber der Aufsichtsbehoerde laeuft ab Kenntnis — daher sollte die Meldefrist im AVV enger sein (z.B. 24h).",
@@ -66,6 +66,10 @@ COOKIE_CHECKLIST = [
r"(?:setzen|verwenden|nutzen)\s+.*cookies?\s+.*(?:um|fuer|für)",
r"(?:analyse|marketing|tracking|funktional)\w*\s*cookies?\s*\.?\s*(?:um|damit|diese|sie)",
r"cookies?\s+(?:dienen|helfen|erm(?:oe|ö)glichen)",
# P39: cookie purpose table column "| Zweck |" + "Kategorie"
r"kategorie\s*\|\s*zweck",
r"\|\s*zweck\s*\|",
r"welche\s+technologie\s+welchen\s+zweck",
],
"severity": "HIGH",
"hint": "Art. 13 Abs. 1 lit. c DSGVO verlangt die Zweckangabe je Verarbeitung. Jede Cookie-Kategorie braucht einen konkreten Zweck (z.B. 'Reichweitenmessung', 'Conversion-Tracking'), nicht nur 'zur Verbesserung unserer Website'.",
@@ -207,6 +211,10 @@ COOKIE_CHECKLIST = [
r"(?:datenschutz[\-]?rechtlich(?:er)?\s+)?verantwortlich\w*\s*[:\|]",
r"daten(?:schutz)?[\-]?(?:rechtlich(?:er)?\s+)?(?:verantwortl|controller)",
r"\bcontroller\b.*\b(?:art\.?\s*13|art\.?\s*14|gdpr|dsgvo)",
# P39: heading variant — common in cookie policies
r"(?:^|\n)\s*#+\s*\d*\.?\s*verantwortlich\w*",
r"(?:^|\n)\s*\d+\.\s+verantwortlich\w*",
r"verantwortlich\w*\s+(?:fuer|für|ist|im\s+sinne)",
],
"severity": "MEDIUM",
"hint": "Art. 13(1)(a) DSGVO verlangt die Nennung des Verantwortlichen in der Cookie-Richtlinie. Pflicht: Firmenname + Anschrift + Kontaktdaten (E-Mail/Telefon). Akzeptabel: knapper Verweis 'Details zum Verantwortlichen siehe Datenschutzerklaerung [Link]' wenn die DSI verlinkt ist.",
@@ -268,19 +276,40 @@ COOKIE_CHECKLIST = [
},
# ── Neue L1: Cookie-Tabelle ───────────────────────────────────────
# P95: Lockerer Match — Vendor-zentrische Detailseiten (BMW-Stil mit
# Adform-Block etc.) werden als gleichwertig akzeptiert. DSK-OH 2024
# §3.2 verlangt die Informationen pro Cookie, schreibt aber keine
# Tabellenform vor. Ein Vendor-Block der Name+Anbieter+Zweck+Dauer+
# Cookie-Namen aggregiert nennt erfuellt das.
{
"id": "cookie_table",
"label": "Strukturierte Cookie-Tabelle/Liste",
"label": "Strukturierte Cookie-Informationen (Tabelle oder Vendor-Blöcke)",
"level": 1, "parent": None,
"patterns": [
# Klassische Tabelle
r"(?:cookie[\-\s])?(?:tabelle|uebersicht|übersicht|liste|aufstellung)",
r"(?:name|bezeichnung)\s*[\|\t]\s*(?:anbieter|zweck|dauer|laufzeit|funktion)",
r"(?:first[\-\s]?party|third[\-\s]?party)\s*[\|\t]",
r"(?:typ(?:en)?|name|funktion|speicherdauer)\s+(?:typ(?:en)?|name|funktion|speicherdauer)",
r"folgende\s+cookies",
r"(?:funktionale|session|analyse|tracking)\s+cookies?\s+\w+",
# P95: Vendor-zentrische Detail-Bloecke (BMW-Stil) — wenn
# mehrere typische Vendor-Block-Marker vorhanden, gilt als
# strukturiert. "Gesetzt von:" + "Opt-Out Link:" + "Privacy"
# ist ein klares Indiz fuer Vendor-Detailseite.
r"gesetzt\s+von\s*[:\|]",
r"opt[\-\s]?out[\s\-]?link\s*[:\|]",
r"speicherdauer\s*[:\|]\s*\d+\s+(?:tag|monat|jahr|day|month|year)",
r"(?:rechtsgrundlage|legal\s+basis)\s*[:\|]",
r"(?:diese\s+datenverarbeitung\s+verwendet\s+die\s+folgenden\s+cookies)",
],
"severity": "LOW",
"hint": "Die DSK-Orientierungshilfe empfiehlt eine Tabelle mit 5 Spalten: Name, Anbieter, Zweck, Speicherdauer, Typ (First-/Third-Party). Viele Consent-Tools (Cookiebot, Usercentrics) generieren diese Tabelle automatisch — binden Sie sie ein.",
"hint": "DSK-OH Telemedien 2024 §3.2 verlangt Cookie-Informationen pro "
"Vendor/Cookie (Name, Anbieter, Zweck, Speicherdauer, Drittlandtransfer). "
"Akzeptable Formate: (a) Tabelle mit 5 Spalten oder (b) Vendor-Detailseite "
"mit Block pro Anbieter (Anbieter+Anschrift, Zweck, Speicherdauer aggregiert, "
"Cookie-Namen-Liste, Opt-Out-Link, Drittlandstatus). BMW-Stil mit Adform-"
"Block ist konform. Auch automatisierte CMP-Generierung (Cookiebot, Usercentrics) "
"ist OK.",
},
]
@@ -17,6 +17,11 @@ ART13_CHECKLIST = [
r"name\s+(?:und|&)\s+kontaktdaten\s+des",
r"controller", r"verantwortliche\s+stelle",
r"responsible\s+(?:party|for)",
# P39: Heading-style "## 1. Verantwortlicher", "## Verantwortlicher",
# "1. Verantwortlicher" — common template structure that wasn't matched.
r"(?:^|\n)\s*#+\s*\d*\.?\s*verantwortlich\w*",
r"(?:^|\n)\s*\d+\.\s+verantwortlich\w*",
r"\bverantwortlich\w*\s*[:\n]",
],
"severity": "HIGH",
"hint": "Art. 13(1)(a) DSGVO verlangt vollstaendige Identifizierung: Firmenname mit Rechtsform (z.B. 'Muster GmbH'), ladungsfaehige Anschrift, E-Mail und Telefon. Haeufiger Fehler: Nur Markenname ohne Rechtsform — das genuegt nicht zur Zustellung.",
@@ -93,6 +98,11 @@ ART13_CHECKLIST = [
r"zu\s+welch\w+\s+zweck",
r"welche\s+daten\s+werden.*verarbeitet",
r"daten\s+werden\s+(?:zu|fuer|für)\s+(?:folgende|diese)",
# P39: heading variants
r"(?:^|\n)\s*#+\s*\d*\.?\s*zwecke?\b",
r"\*\*zwecke?:?\*\*",
r"purposes?\s+and\s+(?:legal|legal\s+bases?)",
r"purposes?\s*[:\n]",
],
"severity": "HIGH",
"hint": "Art. 13(1)(c) verlangt konkrete Zweckangaben — nicht nur 'Wir verarbeiten Ihre Daten'. Jeder Dienst braucht einen eigenen Zweck: z.B. 'Webanalyse via Matomo', 'Newsletter-Versand', 'Kontaktanfragen'. Pauschalformulierungen verstiessen laut DSK gegen den Transparenzgrundsatz (Art. 5(1)(a)).",
@@ -223,6 +233,13 @@ ART13_CHECKLIST = [
r"(?:ueber|über)mittlung.*(?:ausserhalb|außerhalb)",
r"(?:europ(?:ae|ä)ischen\s+wirtschaftsraum|ewr|eea)",
r"privacy\s+shield", r"data\s+privacy\s+framework",
# P39: Art. 13(1)(f) verlangt nur Erwaehnung — "keine
# Uebermittlung in Drittlaender" / "kein Drittlandtransfer"
# / "alle Verarbeitung innerhalb der EU" sind explizite,
# konforme Negations-Aussagen.
r"(?:kein|keine)\s+(?:uebermittlung|übermittlung|transfer|drittland)",
r"verarbeitung\s+(?:erfolgt\s+)?(?:ausschliesslich|ausschließlich|nur)\s+(?:in|innerhalb)\s+(?:der\s+)?(?:eu|europ(?:ae|ä)ischen\s+union|ewr)",
r"alle\s+daten\s+(?:bleiben|verbleiben)\s+(?:in|innerhalb)\s+(?:der\s+)?(?:eu|deutschland)",
],
"severity": "MEDIUM",
"hint": "Art. 13(1)(f) DSGVO: Bei jedem Drittlandtransfer muessen Empfaengerland und Schutzgarantien genannt werden. Pruefen Sie: Google Fonts, reCAPTCHA, YouTube-Embeds, CDNs — all das sind USA-Transfers. Fehlende Angabe war Grundlage zahlreicher DSGVO-Bussgelder.",
@@ -192,6 +192,11 @@ DSFA_CHECKLIST = [
r"landes.?datenschutz",
r"richtlinie.*(?:land|lfdi|landes)",
r"(?:aufsichtsbeh(?:oe|ö)rde|beh(?:oe|ö)rde).*(?:richtlinie|empfehlung|vorgabe)",
# P39: DSK Liste/Blacklist + spezifische Landesbehoerden
r"(?:dsk|datenschutzkonferenz)\s+(?:positiv|black)?liste",
r"art\.?\s*35\s*\(?\s*4\s*\)?\s*dsgvo",
r"(?:berliner|hamburgische|saechsisch|bayerisch|nordrhein|baden)\w*\s+beauftragt",
r"(?:bfdi|bvfd|ldsbw|ldsh)",
],
"severity": "MEDIUM",
"hint": "Die DSK hat eine Positivliste (Blacklist) nach Art. 35(4) DSGVO veroeffentlicht, die DSFA-pflichtige Verarbeitungen auflistet. Zusaetzlich hat jedes Bundesland eigene LfDI-Empfehlungen — z.B. der LfDI BaWue zu Social-Media-Fanpages. Pruefen und zitieren Sie die fuer Sie zustaendige Behoerde.",
@@ -16,6 +16,11 @@ LOESCHKONZEPT_CHECKLIST = [
r"(?:geltungsbereich|anwendungsbereich)",
r"verantwortlich\w*\s+(?:fuer|für)\s+(?:das\s+)?l(?:oe|ö)schkonzept",
r"(?:datenschutzbeauftragt\w*|dpo|dsb)\s+(?:verantwort|zustaendig|zuständig)",
# P39: heading variants + Verantwortlichkeiten table
r"(?:^|\n)\s*#+\s*\d*\.?\s*verantwortlichkeit",
r"(?:^|\n)\s*#+\s*\d*\.?\s*geltungsbereich",
r"verantwortlichkeiten\s*\|",
r"\|\s*verantwortlich\s*\|",
],
"severity": "HIGH",
"hint": "DIN 66398 verlangt einen klaren Geltungsbereich (welche Systeme, Datenarten, Standorte) und die Benennung des Verantwortlichen fuer Erstellung + Wartung des Loeschkonzepts.",
@@ -98,6 +103,10 @@ LOESCHKONZEPT_CHECKLIST = [
r"l(?:oe|ö)sch(?:prozess|vorgang|verfahren|workflow|routine)",
r"(?:wie|wann)\s+(?:wird|werden)\s+(?:die\s+daten\s+)?gel(?:oe|ö)scht",
r"automatisierte?\s+l(?:oe|ö)schung",
# P39: more generic — "Verfahren fuer die Loeschung", "Loeschmethode"
r"verfahren\s+(?:fuer|für|zur?)\s+(?:die\s+)?l(?:oe|ö)sch",
r"l(?:oe|ö)sch(?:methode|frist|regel)",
r"systematische?\s+(?:regeln?|verfahren)[\s\S]{0,80}l(?:oe|ö)sch",
],
"severity": "HIGH",
"hint": "Beschreiben wie Loeschung erfolgt: automatisch per Cron-Job, manuell durch Admin, Loeschungs-Workflow im CRM, Backup-Loeschung etc.",
@@ -154,6 +163,10 @@ LOESCHKONZEPT_CHECKLIST = [
r"sperr\w+\s+(?:statt|anstelle)\s+l(?:oe|ö)sch",
r"l(?:oe|ö)sch(?:beschr|sperr|ausnahme|hindernis)",
r"(?:rechtsstreit|gerichtsverfahren|prozessrelevant)",
# P39: gesetzliche Aufbewahrungspflichten als legitime Loeschausnahme
r"(?:gesetzliche|handelsrechtlich|steuerrechtlich)\w*\s+aufbewahrungs?(?:pflicht|frist)",
r"aufbewahrungspflicht[\s\S]{0,80}(?:setzt|bleib|gilt)",
r"(?:hgb|ao|abgabenordnung)\s*§?\s*\d",
],
"severity": "MEDIUM",
"hint": "Wenn Loeschung nicht moeglich ist (laufender Prozess, gesetzliche Aufbewahrung, Streitfall) muss stattdessen Sperrung/Einschraenkung (Art. 18 DSGVO) erfolgen. Sperrkonzept dokumentieren.",
@@ -0,0 +1,99 @@
"""
Rendert die Doc-Type-Mismatch-Hinweise als Mail-Block.
Wenn der User Text in das falsche Feld kopiert (z.B. Impressum-Text
ins DSE-Feld), zeigt der Block:
- was er deklariert hat
- was der Classifier erkannt hat
- Empfehlung (re-paste oder als unbekannt einreichen)
"""
from __future__ import annotations
import logging
from typing import Iterable
logger = logging.getLogger(__name__)
_DOC_LABELS = {
"dse": "Datenschutzerklaerung",
"cookie": "Cookie-Richtlinie",
"impressum": "Impressum",
"agb": "AGB",
"widerruf": "Widerrufsbelehrung",
"nutzungsbedingungen": "Nutzungsbedingungen",
"social_media": "Social Media DSE",
"dsfa": "DSFA",
"dsa": "DSA-Pflichtangaben",
"legal_notice": "Rechtliche Hinweise",
"lizenzhinweise": "Lizenzhinweise",
}
def _label(dt: str) -> str:
return _DOC_LABELS.get(dt, dt)
def collect_warnings(doc_entries: Iterable[dict]) -> list[dict]:
"""Returns list of {declared, detected, action, scores} fuer alle
doc_entries mit einem reclassify_hint."""
out: list[dict] = []
for e in (doc_entries or []):
hint = e.get("reclassify_hint")
if not hint:
continue
out.append({
"input_source": e.get("input_source"),
"declared": hint.get("declared"),
"detected": hint.get("detected"),
"action": hint.get("action"),
"declared_score": hint.get("declared_score", 0),
"detected_score": hint.get("detected_score", 0),
"all_scores": hint.get("all_scores") or {},
"word_count": e.get("word_count", 0),
})
return out
def build_warnings_block_html(warnings: list[dict]) -> str:
if not warnings:
return ""
items: list[str] = []
for w in warnings:
action = w.get("action")
if action == "reclassify":
color = "#0e7490"
badge = "AUTO-RECLASSIFIZIERT"
body = (
f'Sie haben den Text als <strong>{_label(w["declared"])}</strong> '
f'eingereicht, das System hat ihn aber automatisch als '
f'<strong>{_label(w["detected"])}</strong> erkannt und entsprechend '
f'gepruft (Konfidenz-Score: {w["detected_score"]} vs '
f'{w["declared_score"]} für die deklarierte Kategorie).'
)
else:
color = "#d97706"
badge = "MOEGLICHER MISMATCH"
body = (
f'Sie haben den Text als <strong>{_label(w["declared"])}</strong> '
f'eingereicht. Der Inhalt enthaelt aber Patterns die eher zu '
f'<strong>{_label(w["detected"])}</strong> passen '
f'({w["detected_score"]} vs {w["declared_score"]}). '
'Bitte pruefen Sie ob Sie den richtigen Doc-Typ ausgewaehlt haben.'
)
items.append(
f'<li style="margin-bottom:8px;font-size:11px;line-height:1.5">'
f'<strong style="color:{color}">[{badge}]</strong> {body}'
f'</li>'
)
return (
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
'max-width:760px;margin:0 auto 12px;padding:10px 14px;'
'background:#ecfeff;border:1px solid #67e8f9;border-radius:6px">'
'<div style="font-size:11px;color:#0e7490;text-transform:uppercase;'
'letter-spacing:1.2px;margin-bottom:4px;font-weight:600">'
'Hinweise zum eingefügten Text</div>'
'<ul style="margin:4px 0 0 18px;padding:0">'
+ "".join(items) +
'</ul></div>'
)
@@ -0,0 +1,297 @@
"""
P35 + P77 + P78 Post-hoc Textsignal-Checks auf den geladenen
Dokumenten-Texten (DSE / Cookie-Richtlinie / Banner-Text).
P35 "Speichern" als mehrdeutiges Reject-Label im Banner. Wenn das
einzige Schliess-Element nur "Speichern" heisst (statt
"Alle ablehnen" / "Nur notwendige"), ist das ein MEDIUM-Finding,
weil der Nutzer nicht versteht ob er gerade akzeptiert oder
abgelehnt hat.
P77 Cookie-Doc-Architecture: wenn keine eigene Cookie-Richtlinie
ausgeliefert wurde, aber die DSE einen prominent benannten
Cookie-Abschnitt enthaelt (mit Vendor-Liste + Speicherdauer),
ist das ein gleichwertiger OEM-Pattern. Liefert positives Signal
statt MEDIUM-Finding "Cookie-Richtlinie fehlt".
P78 JC-Detection in DSE-Text: erkennt 'gemeinsam Verantwortliche'-
Klauseln (Art. 26 DSGVO) im DSE-Text. Liefert positives Signal
"JC-Konstrukt dokumentiert" verhindert False-Positive
"JC nicht erwaehnt obwohl Kooperation mit Konzern-Schwester".
Alle drei liefern dict shape {"severity": ...} oder positive-signal-dict.
"""
from __future__ import annotations
import logging
import re
logger = logging.getLogger(__name__)
_REJECT_LABEL_KEYS = (
"alle ablehnen", "ablehnen", "reject all", "deny all",
"nur notwendige", "nur essenzielle", "nur erforderliche",
"essentials only", "verweigern", "block all",
)
_SAVE_ONLY_KEYS = (
"speichern", "auswahl speichern", "save selection",
"auswahl bestaetigen",
)
_COOKIE_SECTION_HEADINGS = (
"cookies und tracking", "cookies und vergleichbare technologien",
"cookies und aehnliche technologien", "verwendung von cookies",
"informationen zu cookies", "uebersicht der cookies",
"eingesetzte cookies", "cookies im einsatz",
)
_VENDOR_HINTS = (
"speicherdauer", "lebensdauer", "anbieter", "drittanbieter",
"datenempfaenger", "datenkategorie", "rechtsgrundlage",
)
_JC_PATTERNS = (
"gemeinsam verantwortlich", "joint controller",
"gemeinsame verantwortung", "art. 26 dsgvo", "art 26 dsgvo",
"vereinbarung gemaess art. 26", "joint-controller-vereinbarung",
"gemeinsame verarbeitung",
)
# P36 — Social-Media-Einbindung:
# "direct" = direkte FB/Insta/Twitter-Embeds laden bei Page-Load
# (HIGH-Risiko, Cookies vor Consent).
# "shariff" = Heise-Shariff-Buttons (clientseitig, kein 3rd-party-Call).
# "two_click" = zweistufige Loesung (Klick auf Platzhalter laed Tracker).
_SOCIAL_DIRECT_PATTERNS = (
"connect.facebook.net", "platform.twitter.com",
"platform.instagram.com", "platform.linkedin.com",
"youtube.com/embed", "syndication.twitter.com",
"//www.facebook.com/", "fb-pixel", "facebook-pixel",
)
_SOCIAL_SHARIFF_PATTERNS = (
"shariff", "ct_shariff", "data-shariff",
)
_SOCIAL_TWOCLICK_PATTERNS = (
"2-klick", "2klick", "zwei klick", "two-click",
"klick-zu-laden", "klick um zu laden", "platzhalter laed",
"embetty",
)
def check_save_only_reject(banner_result: dict) -> dict | None:
"""P35 — Banner hat keinen klaren Reject, nur "Speichern"."""
initial = ((banner_result or {}).get("phases") or {}).get("initial") or {}
if not isinstance(initial, dict):
return None
btext = (initial.get("banner_text") or "").lower()
if not btext or len(btext) < 30:
return None
has_clear_reject = any(k in btext for k in _REJECT_LABEL_KEYS)
has_save_only = any(k in btext for k in _SAVE_ONLY_KEYS)
if has_clear_reject or not has_save_only:
return None
return {
"severity": "MEDIUM",
"code": "save_label_ambiguous",
"label": (
'Banner verwendet "Speichern" ohne erkennbares "Ablehnen" '
'— mehrdeutig fuer den Nutzer'
),
"detail": (
'Der Button "Speichern" laesst offen, ob die aktuelle '
'Vorauswahl (oft alles aktiv) bestaetigt oder nur die '
'getroffene Auswahl uebernommen wird. EDPB 03/2022 empfiehlt '
'eindeutige Labels: "Alle akzeptieren" + "Alle ablehnen".'
),
"legal_basis": "Art. 7 (1) DSGVO + EDPB 03/2022 Guidelines on "
"deceptive design patterns.",
}
def check_cookies_in_dse(
doc_texts: dict[str, str],
cookie_doc_missing: bool,
) -> dict | None:
"""P77 — DSE hat eigenen Cookie-Abschnitt mit Vendor-Hints."""
if not cookie_doc_missing:
return None
dse = (doc_texts or {}).get("dse") or ""
if len(dse) < 1000:
return None
dse_lower = dse.lower()
has_heading = any(h in dse_lower for h in _COOKIE_SECTION_HEADINGS)
if not has_heading:
return None
vendor_hint_count = sum(1 for h in _VENDOR_HINTS if h in dse_lower)
if vendor_hint_count < 3:
return None # zu wenig substanziell
return {
"severity": "INFO", # Positives Signal, kein Finding
"code": "cookies_in_dse_accepted",
"label": (
"Cookie-Informationen sind im Datenschutz-Dokument enthalten "
"(eigener Abschnitt mit Vendor-Hinweisen)"
),
"detail": (
"Die Praxis vieler OEM-Sites, Cookies als eigenen Abschnitt "
'in der DSE zu fuehren (statt als separate Datei), wird als '
"gleichwertig akzeptiert. Empfehlung trotzdem: separate "
"Cookie-Richtlinie erleichtert kuenftige Aenderungen und "
"Versionierung."
),
"legal_basis": "Art. 13(1)(c) DSGVO — Form ist nicht vorgegeben, "
"Inhalt muss vollstaendig sein.",
}
def check_jc_clause_in_dse(doc_texts: dict[str, str]) -> dict | None:
"""P78 — DSE enthaelt Art. 26 JC-Klausel."""
dse = (doc_texts or {}).get("dse") or ""
if not dse:
return None
dse_lower = dse.lower()
matches = [p for p in _JC_PATTERNS if p in dse_lower]
if not matches:
return None
return {
"severity": "INFO",
"code": "jc_clause_documented",
"label": "Gemeinsame Verantwortlichkeit (Art. 26 DSGVO) im "
"DSE-Text dokumentiert",
"detail": (
f'Erkannte Signale: {", ".join(sorted(set(matches))[:3])}. '
'Das verhindert das False-Positive "JC-Konstrukt nicht '
'erwaehnt" bei Sites mit Konzern-Schwesterunternehmen.'
),
"legal_basis": "Art. 26 DSGVO + EDPB 7/2020 Guidelines on the "
"concepts of controller and processor.",
}
def check_social_embedding(
doc_texts: dict[str, str],
homepage_html: str | None = None,
) -> dict | None:
"""P36 — direkte Social-Embeds vs Shariff vs 2-Klick."""
sources: list[str] = []
for key in ("dse", "cookie", "impressum"):
v = (doc_texts or {}).get(key) or ""
if v:
sources.append(v[:50000])
if homepage_html:
sources.append(homepage_html[:50000])
if not sources:
return None
blob = " ".join(sources).lower()
direct_hits = [p for p in _SOCIAL_DIRECT_PATTERNS if p in blob]
has_shariff = any(p in blob for p in _SOCIAL_SHARIFF_PATTERNS)
has_twoclick = any(p in blob for p in _SOCIAL_TWOCLICK_PATTERNS)
if not direct_hits and not has_shariff and not has_twoclick:
return None
if direct_hits and not (has_shariff or has_twoclick):
return {
"severity": "HIGH",
"code": "social_direct_embed",
"label": "Direkte Social-Media-Embeds ohne 2-Klick-Schutz "
"oder Shariff erkannt",
"detail": (
f'Gefundene Drittanbieter-Skripte: '
f'{", ".join(sorted(set(direct_hits))[:4])}. '
"Diese laden i.d.R. Cookies/Pixel ohne Einwilligung. "
"Empfehlung: Heise-Shariff (clientseitig) oder "
"2-Klick-Loesung (Embetty, eigener Platzhalter)."
),
"legal_basis": "EuGH C-40/17 (Fashion-ID) — Einbinden eines "
"Facebook-Like-Buttons macht den Site-Betreiber "
"zum gemeinsam Verantwortlichen + benoetigt "
"Einwilligung VOR dem Drittanbieter-Call.",
}
if has_shariff or has_twoclick:
return {
"severity": "INFO",
"code": "social_protected_embed",
"label": (
"Datenschutzfreundliche Social-Media-Einbindung erkannt "
f"({'Shariff' if has_shariff else '2-Klick-Loesung'})"
),
"detail": (
"Drittanbieter-Skripte werden erst nach aktivem Klick "
"geladen — kein Tracking ohne Einwilligung."
),
"legal_basis": "EuGH C-40/17 + EDPB Guidelines 8/2020.",
}
return None
def run_all(
banner_result: dict | None,
doc_texts: dict[str, str] | None,
cookie_doc_missing: bool = False,
homepage_html: str | None = None,
) -> list[dict]:
findings: list[dict] = []
try:
f = check_save_only_reject(banner_result or {})
if f:
findings.append(f)
except Exception as e:
logger.warning("P35 save_only_reject failed: %s", e)
try:
f = check_cookies_in_dse(doc_texts or {}, cookie_doc_missing)
if f:
findings.append(f)
except Exception as e:
logger.warning("P77 cookies_in_dse failed: %s", e)
try:
f = check_jc_clause_in_dse(doc_texts or {})
if f:
findings.append(f)
except Exception as e:
logger.warning("P78 jc_clause failed: %s", e)
try:
f = check_social_embedding(doc_texts or {}, homepage_html)
if f:
findings.append(f)
except Exception as e:
logger.warning("P36 social_embedding failed: %s", e)
return findings
def build_signals_block_html(findings: list[dict]) -> str:
if not findings:
return ""
pos = [f for f in findings if f.get("severity") == "INFO"]
neg = [f for f in findings if f.get("severity") != "INFO"]
items: list[str] = []
for f in neg + pos:
sev = f.get("severity", "MEDIUM")
if sev == "INFO":
color = "#16a34a"
tag = "✓ POSITIV"
elif sev == "HIGH":
color = "#dc2626"
tag = "HOCH"
else:
color = "#d97706"
tag = "MITTEL"
items.append(
f'<li style="margin-bottom:8px;font-size:11px;line-height:1.5">'
f'<strong style="color:{color}">[{tag}] {f.get("label","")}</strong>'
f'<div style="color:#475569;margin-top:2px">{f.get("detail","")}</div>'
f'<div style="color:#94a3b8;margin-top:2px;font-style:italic">'
f'{f.get("legal_basis","")}</div></li>'
)
return (
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
'max-width:760px;margin:0 auto 16px;padding:12px 16px;'
'background:#f8fafc;border:1px solid #e2e8f0;border-radius:6px">'
'<div style="font-size:11px;color:#475569;text-transform:uppercase;'
'letter-spacing:1.2px;margin-bottom:4px;font-weight:600">'
'Weitere Textsignale</div>'
'<ul style="margin:6px 0 0 18px;padding:0">'
+ "".join(items) +
'</ul></div>'
)
@@ -0,0 +1,162 @@
"""
Erkennt den wahrscheinlichen Doc-Type eines eingefuegten Textes.
Wird genutzt wenn der User Text direkt ins Frontend kopiert. Wenn der
erkannte Typ vom user-deklarierten Typ abweicht, gibt das System einen
Hinweis (oder reklassifiziert automatisch wenn Confidence hoch genug).
Heuristik basiert auf Pflichtangaben-Patterns:
* Impressum: §5 TMG-Bestandteile (Anschrift + Telefon + Email + UStID)
* DSE: Art. 13 DSGVO-Bestandteile (Verantwortlicher + Zweck + Rechtsgrund)
* AGB: Vertragsschluss + Lieferung + Zahlung + Gerichtsstand
* Widerruf: 14-Tage-Frist + Widerrufsformular + Wertersatz
* Cookie-Richtlinie: Cookie-Tabelle / Speicherdauer / Drittanbieter
* Nutzungsbedingungen: Lizenz + Verbot der Vervielfaeltigung + Account
"""
from __future__ import annotations
import logging
import re
logger = logging.getLogger(__name__)
_PATTERNS: dict[str, list[tuple[re.Pattern, int]]] = {
"impressum": [
(re.compile(r"§\s*5\s+TMG", re.I), 4),
(re.compile(r"angaben\s+gem(ä|ae)ß", re.I), 3),
(re.compile(r"\bUSt[\-\s]?ID[\-\s]?Nr\b", re.I), 4),
(re.compile(r"vertretungsberechtigt(e|er)", re.I), 3),
(re.compile(r"registergericht", re.I), 3),
(re.compile(r"handelsregister(nummer)?", re.I), 3),
(re.compile(r"\bHRB\s+\d+", re.I), 3),
(re.compile(r"verantwortlich\s+f(ü|ue)r\s+den\s+inhalt", re.I), 3),
(re.compile(r"\bRStV\b|Rundfunkstaatsvertrag", re.I), 3),
(re.compile(r"streitschlichtung", re.I), 2),
(re.compile(r"OS[\-\s]?plattform", re.I), 2),
],
"dse": [
(re.compile(r"art(ikel)?\.?\s*13\s+DSGVO", re.I), 5),
(re.compile(r"art(ikel)?\.?\s*15\s+DSGVO", re.I), 4),
(re.compile(r"rechtsgrundlage", re.I), 3),
(re.compile(r"datenschutzbeauftragt", re.I), 4),
(re.compile(r"berechtigtes\s+interesse", re.I), 3),
(re.compile(r"betroffenenrechte", re.I), 3),
(re.compile(r"aufsichtsbeh(ö|oe)rde", re.I), 3),
(re.compile(r"speicherdauer|aufbewahrungsfrist", re.I), 2),
(re.compile(r"datenkategorie", re.I), 2),
(re.compile(r"verantwortliche(r|n)\s+im\s+sinne", re.I), 4),
],
"agb": [
(re.compile(r"allgemeine\s+gesch(ä|ae)ftsbedingungen", re.I), 5),
(re.compile(r"\bAGB\b", re.I), 3),
(re.compile(r"vertragsschluss|vertragsabschluss", re.I), 3),
(re.compile(r"liefer(bedingungen|zeit|kosten)", re.I), 2),
(re.compile(r"gew(ä|ae)hrleistung", re.I), 2),
(re.compile(r"haftungsausschluss", re.I), 2),
(re.compile(r"gerichtsstand", re.I), 3),
(re.compile(r"anwendbares\s+recht", re.I), 2),
(re.compile(r"salvatorische\s+klausel", re.I), 2),
],
"widerruf": [
(re.compile(r"widerrufsbelehrung", re.I), 5),
(re.compile(r"14\s+tage", re.I), 3),
(re.compile(r"widerrufsrecht", re.I), 4),
(re.compile(r"widerrufsformular", re.I), 3),
(re.compile(r"wertersatz", re.I), 3),
(re.compile(r"r(ü|ue)cksende(kosten|gebuehr)", re.I), 3),
(re.compile(r"muster[\-\s]?widerrufsformular", re.I), 4),
],
"cookie": [
(re.compile(r"cookie[\-\s]?richtlinie", re.I), 4),
(re.compile(r"cookie[\-\s]?policy", re.I), 4),
(re.compile(r"tracking[\-\s]?cookies?", re.I), 3),
(re.compile(r"funktionale\s+cookies?", re.I), 3),
(re.compile(r"marketing[\-\s]?cookies?", re.I), 3),
(re.compile(r"speicherdauer\s*\d+\s*(tag|monat|jahr)", re.I), 3),
(re.compile(r"drittanbieter[\-\s]?cookies?", re.I), 3),
(re.compile(r"\b(IDE|_ga|_gid|_fbp|_gcl_au|OptanonConsent)\b"), 3),
(re.compile(r"opt[\-\s]?out", re.I), 2),
],
"nutzungsbedingungen": [
(re.compile(r"nutzungsbedingungen", re.I), 5),
(re.compile(r"terms\s+of\s+(use|service)", re.I), 4),
(re.compile(r"benutzerkonto|nutzerkonto", re.I), 3),
(re.compile(r"untersagt|unzul(ä|ae)ssig.{0,30}nutzung", re.I), 2),
(re.compile(r"sperrung\s+des\s+kontos", re.I), 2),
],
"social_media": [
(re.compile(r"social[\-\s]?media[\-\s]?(plug[\-\s]?ins?|kanale|kanaele|pr(ä|ae)senz)", re.I), 4),
(re.compile(r"gemeinsam\s+verantwortlich.{0,100}(facebook|meta|instagram)", re.I), 4),
(re.compile(r"fanpage|fan[\-\s]?page", re.I), 3),
(re.compile(r"like[\-\s]?button|share[\-\s]?button", re.I), 2),
],
}
def classify(text: str, top_n: int = 3) -> list[tuple[str, int]]:
"""Returns list of (doc_type, score) sorted by score desc.
Score >= 6 = high confidence, 3-5 = medium, < 3 = low.
"""
if not text or len(text) < 200:
return []
scores: dict[str, int] = {}
for dt, pats in _PATTERNS.items():
s = 0
for pat, weight in pats:
if pat.search(text):
s += weight
if s > 0:
scores[dt] = s
ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
return ranked[:top_n]
def best_match(text: str) -> tuple[str, int] | None:
"""Returns (doc_type, score) of best match or None."""
ranked = classify(text, top_n=1)
return ranked[0] if ranked else None
def detect_mismatch(
declared_doc_type: str,
text: str,
min_confidence: int = 6,
) -> dict | None:
"""If the text scores higher for a different doc_type than declared,
return a hint dict {detected, declared, scores, action}.
action='reclassify' if confidence is very high (>= min_confidence * 1.5)
action='warn' if medium (>= min_confidence)
action=None / no return otherwise.
"""
ranked = classify(text, top_n=3)
if not ranked:
return None
detected, detected_score = ranked[0]
declared_canon = (declared_doc_type or "").lower().strip()
# Aliase normalisieren
alias = {"datenschutz": "dse", "privacy": "dse",
"terms": "nutzungsbedingungen",
"terms_of_use": "nutzungsbedingungen"}
declared_canon = alias.get(declared_canon, declared_canon)
if detected == declared_canon:
return None
if detected_score < min_confidence:
return None
declared_score = next((s for dt, s in ranked if dt == declared_canon), 0)
# Nur wenn detected DEUTLICH besser ist (Faktor >= 2 oder declared = 0)
if declared_score and detected_score < declared_score * 2:
return None
action = "reclassify" if detected_score >= min_confidence * 1.5 else "warn"
return {
"detected": detected,
"declared": declared_doc_type,
"detected_score": detected_score,
"declared_score": declared_score,
"action": action,
"all_scores": dict(ranked),
}
@@ -0,0 +1,86 @@
"""
P87 Konfidenz-Score pro Finding.
Nicht jedes HIGH-Finding ist gleich sicher. "Kein Reject-Button im Banner"
ist faktisch direkt beobachtbar (Confidence ~98%). "DSE enthaelt keinen
DSB-Kontakt" ist ein Textmuster-Match und kann False-Positive sein
(Confidence ~70%). "Cookie X als essential deklariert, Library sagt
marketing" haengt von Library-Qualitaet ab (Confidence ~80%).
Liefert pro Finding-Label ein (confidence_pct, reason) Paar. Wird im
Mail-Render als kleine graue Klammer hinter dem Severity-Pill angezeigt:
"HOCH (95% Konfidenz: Direkt im DOM beobachtet)".
Keine ML nur regelbasiert. Eine zentrale Stelle damit alle Render-
Stellen einheitlich klassifizieren.
"""
from __future__ import annotations
import re
# (regex, confidence_pct, reason)
# Reihenfolge wichtig: spezifischere Patterns zuerst.
_RULES: list[tuple[re.Pattern, int, str]] = [
# 1) Direkt im DOM / im Cookie-Jar beobachtet — sehr hohe Sicherheit
(re.compile(r"reject[- ]?button.*(fehlt|nicht.*vorhanden)", re.I), 98,
"Direkt im Banner-DOM ueberprueft"),
(re.compile(r"(anpassen|einstellungen|customize).*button.*fehlt", re.I), 95,
"Initial-Banner-DOM ueberprueft"),
(re.compile(r"cookie.*vor.*einwilligung.*gesetzt", re.I), 96,
"Cookie-Jar vor Akzeptieren beobachtet"),
(re.compile(r"(tracking|marketing).*ohne.*einwilligung", re.I), 92,
"Network-Calls vor Akzeptieren beobachtet"),
# 2) Library-Mismatches — abhaengig von Library-Qualitaet
(re.compile(r"deklariert als.*library.*sagt", re.I), 82,
"Vergleich mit ~2.300-Cookie-Library + Open-Cookie-DB"),
(re.compile(r"library.*marketing", re.I), 82,
"Cookie-Library-Klassifikation"),
# 3) Pflichtangaben-Checks (Impressum/AGB/DSE) — Textmuster, MEDIUM-Sicherheit
(re.compile(r"impressum.*(fehlt|unvollstaendig)", re.I), 88,
"Pattern-Match auf Impressums-Pflichtfelder (§ 5 TMG)"),
(re.compile(r"dsb.*(fehlt|nicht.*genannt)", re.I), 75,
"Textmuster-Suche; DSB kann ueber Impressum referenziert sein"),
(re.compile(r"drittland.*(fehlt|nicht.*genannt|ohne.*hinweis)", re.I), 80,
"Pattern-Match auf typische Drittland-Klauseln"),
(re.compile(r"widerruf.*(fehlt|unvollstaendig)", re.I), 85,
"Pattern-Match auf Widerrufsbelehrungs-Pflichtfelder"),
# 4) Anti-Auditing-Detection — heuristisch
(re.compile(r"anti[- ]?audit", re.I), 70,
"Skript-Domain-Heuristik; manuelle Pruefung empfohlen"),
# 5) Generische Konsistenz-Findings (DSE vs. Banner vs. Cookie-Liste)
(re.compile(r"banner.*nennt.*\d+.*cmp.*\d+", re.I), 90,
"Quantitativer Vergleich zwischen Banner-Text und CMP-Payload"),
# 6) Klassifikations- / Kontext-Findings (Wizard-getrieben)
(re.compile(r"(branchen|scope).*passt.*nicht", re.I), 88,
"Wizard-Klassifikation + MC-scope_doc_type"),
]
_DEFAULT_CONFIDENCE = 78
_DEFAULT_REASON = (
"Standard-Regelpruefung; Bestaetigung mit DSB / interner Doku empfohlen"
)
def score_finding(label: str) -> tuple[int, str]:
"""Returns (confidence_pct, reason) for a finding label."""
if not label:
return _DEFAULT_CONFIDENCE, _DEFAULT_REASON
for pat, conf, reason in _RULES:
if pat.search(label):
return conf, reason
return _DEFAULT_CONFIDENCE, _DEFAULT_REASON
def confidence_pill_html(label: str) -> str:
"""Returns an inline HTML snippet '(NN% Konfidenz: ...)' or empty."""
conf, reason = score_finding(label)
return (
f' <span style="color:#94a3b8;font-size:10px" title="{reason}">'
f'({conf}% Konfidenz)</span>'
)
@@ -0,0 +1,12 @@
"""Founding-Wizard Service: rendert Templates + generiert DOCX-Files."""
from .markdown_to_docx import markdown_to_docx_bytes
from .template_renderer import find_undefined_placeholders, render_template
from .wizard_to_context import base_context
__all__ = [
"base_context",
"find_undefined_placeholders",
"markdown_to_docx_bytes",
"render_template",
]
@@ -0,0 +1,181 @@
"""
Konvertiert gerendertes Markdown in eine .docx-Datei mittels python-docx.
Unterstuetzte Markdown-Elemente:
- # / ## / ### / #### / ##### Headings
- **bold** und _italic_ inline
- Tabellen (Pipe-Syntax)
- Listen mit - oder * oder Ziffer.)
- Horizontale Linien ---
- Code-Inline `code`
Bewusst minimal fuer rechtliche Dokumente brauchen wir keine Bilder/Embeds.
"""
from __future__ import annotations
import io
import re
from typing import Any, Optional
from docx import Document
from docx.shared import Pt
from docx.enum.text import WD_ALIGN_PARAGRAPH
HEADING_RE = re.compile(r"^(#{1,5})\s+(.+)$")
HR_RE = re.compile(r"^[-_*]{3,}\s*$")
LIST_BULLET_RE = re.compile(r"^(\s*)([-*+])\s+(.+)$")
LIST_NUMBER_RE = re.compile(r"^(\s*)(\d+)[\.\)]\s+(.+)$")
TABLE_ROW_RE = re.compile(r"^\|(.+)\|\s*$")
TABLE_SEP_RE = re.compile(r"^\|[\s\-:|]+\|\s*$")
INLINE_BOLD = re.compile(r"\*\*([^*]+)\*\*")
# Italic: nur _wort_ wenn von Whitespace/Satzzeichen umgeben — verhindert dass
# snake_case-Variablen wie ESKALATION_TAGE_INTERN als Italic interpretiert werden.
INLINE_ITALIC = re.compile(
r"(?<!\*)\*(?!\*)([^*\n]+)\*(?!\*)"
r"|(?<![A-Za-z0-9_])_([^_\n]+)_(?![A-Za-z0-9_])"
)
INLINE_CODE = re.compile(r"`([^`]+)`")
def _add_runs(paragraph: Any, text: str) -> None:
"""Parse inline-Formatierung und fuege Runs hinzu."""
pos = 0
tokens: list[tuple[str, str]] = []
while pos < len(text):
m_bold = INLINE_BOLD.search(text, pos)
m_code = INLINE_CODE.search(text, pos)
m_italic = INLINE_ITALIC.search(text, pos)
candidates = [m for m in (m_bold, m_code, m_italic) if m]
if not candidates:
tokens.append(("plain", text[pos:]))
break
first = min(candidates, key=lambda m: m.start())
if first.start() > pos:
tokens.append(("plain", text[pos:first.start()]))
if first is m_bold:
tokens.append(("bold", first.group(1)))
elif first is m_code:
tokens.append(("code", first.group(1)))
elif m_italic is not None:
content = m_italic.group(1) or m_italic.group(2)
tokens.append(("italic", content))
pos = first.end()
for kind, content in tokens:
run = paragraph.add_run(content)
if kind == "bold":
run.bold = True
elif kind == "italic":
run.italic = True
elif kind == "code":
run.font.name = "Courier New"
run.font.size = Pt(10)
def _parse_table(lines: list[str], start: int) -> tuple[list[list[str]], int]:
"""Parst Markdown-Tabelle. Returns (rows, next_line_index)."""
rows: list[list[str]] = []
i = start
while i < len(lines):
line = lines[i].rstrip()
if not TABLE_ROW_RE.match(line) and not TABLE_SEP_RE.match(line):
break
if TABLE_SEP_RE.match(line):
i += 1
continue
cells = [c.strip() for c in line.strip("|").split("|")]
rows.append(cells)
i += 1
return rows, i
def _add_table(doc: Any, rows: list[list[str]]) -> None:
if not rows:
return
ncols = max(len(r) for r in rows)
table = doc.add_table(rows=len(rows), cols=ncols)
table.style = "Light Grid"
for r_idx, row in enumerate(rows):
for c_idx, cell_text in enumerate(row):
if c_idx < ncols:
cell = table.rows[r_idx].cells[c_idx]
cell.text = ""
p = cell.paragraphs[0]
_add_runs(p, cell_text)
if r_idx == 0:
for run in p.runs:
run.bold = True
def markdown_to_docx_bytes(markdown_text: str, title: Optional[str] = None) -> bytes:
"""Konvertiert Markdown nach DOCX und returns die Bytes."""
doc = Document()
# Basis-Style
style = doc.styles["Normal"]
style.font.name = "Calibri"
style.font.size = Pt(11)
if title:
h = doc.add_heading(title, level=0)
h.alignment = WD_ALIGN_PARAGRAPH.LEFT
lines = markdown_text.split("\n")
i = 0
while i < len(lines):
line = lines[i].rstrip()
if not line.strip():
i += 1
continue
# Heading
h_match = HEADING_RE.match(line)
if h_match:
level = len(h_match.group(1))
text = h_match.group(2)
heading = doc.add_heading(level=min(level, 4))
_add_runs(heading, text)
i += 1
continue
# Horizontal Rule
if HR_RE.match(line):
doc.add_paragraph("" * 60)
i += 1
continue
# Tabelle
if TABLE_ROW_RE.match(line):
rows, i = _parse_table(lines, i)
_add_table(doc, rows)
doc.add_paragraph()
continue
# List Bullet
b_match = LIST_BULLET_RE.match(line)
if b_match:
p = doc.add_paragraph(style="List Bullet")
_add_runs(p, b_match.group(3))
i += 1
continue
# List Number
n_match = LIST_NUMBER_RE.match(line)
if n_match:
p = doc.add_paragraph(style="List Number")
_add_runs(p, n_match.group(3))
i += 1
continue
# Sonst: normaler Paragraph
p = doc.add_paragraph()
_add_runs(p, line)
i += 1
buf = io.BytesIO()
doc.save(buf)
return buf.getvalue()
@@ -0,0 +1,107 @@
"""
Handlebars-light Template-Renderer fuer die compliance_legal_templates.
Unterstuetzte Syntax:
- {{VARIABLE_NAME}} - einfache String-Substitution
- {{#IF FLAG}}...{{/IF}} - bedingter Block (truthy)
- {{#IF NOT FLAG}}...{{/IF}} - negierter bedingter Block
Bewusst minimal gehalten keine Loops oder Verschachtelung tiefer Logik.
Komplexere Sachen werden im Context vorberechnet.
"""
from __future__ import annotations
import re
from typing import Any
# Innerste {{#IF FLAG}}...{{/IF}}-Bloecke (Content enthaelt KEIN weiteres {{#IF).
# Iteratives Anwenden loest Verschachtelung von innen nach aussen sauber auf.
IF_INNERMOST = re.compile(
r"\{\{#IF\s+(NOT\s+)?([A-Z_][A-Z0-9_]*)\}\}"
r"((?:(?!\{\{#IF).)*?)" # Content: kein weiteres {{#IF
r"\{\{/IF\}\}",
re.DOTALL,
)
VAR_PATTERN = re.compile(r"\{\{\s*([A-Z_][A-Z0-9_]*)\s*\}\}")
# Fallback: orphan IF-Tags die nach Iteration uebrig sind (z.B. unbalanced template) raus.
ORPHAN_IF_TAG = re.compile(
r"\{\{/IF\}\}|\{\{#IF\s+(?:NOT\s+)?[A-Z_][A-Z0-9_]*\}\}"
)
def _is_truthy(val: Any) -> bool:
"""Pythonische Truthiness, mit Special-Case: leeres dict/list/str = False."""
if val is None:
return False
if isinstance(val, bool):
return val
if isinstance(val, (int, float)):
return val != 0
if isinstance(val, str):
return val.strip() != "" and val.lower() not in ("false", "0", "no", "nein")
if isinstance(val, (list, dict, tuple, set)):
return len(val) > 0
return True
def render_template(template: str, context: dict[str, Any]) -> str:
"""Rendert ein Template mit dem gegebenen Kontext.
Algorithmus:
1. IF-Bloecke iterativ aufloesen (max 10 Durchlaeufe, damit Nesting funktioniert)
2. Variablen substituieren
Args:
template: Markdown-Template mit {{VAR}} und {{#IF FLAG}}...{{/IF}}
context: dict mit Variablen Keys SCREAMING_SNAKE_CASE
Returns:
Gerendetes Markdown
"""
output = template
# IF-Bloecke iterativ aufloesen — innerste zuerst, dann eine Ebene hoeher, usw.
# Bis zu 20 Iterationen reichen fuer realistisches Nesting.
for _ in range(20):
def replace_if(match: re.Match[str]) -> str:
negated = bool(match.group(1))
flag_name = match.group(2)
content = match.group(3)
flag_val = context.get(flag_name)
condition = _is_truthy(flag_val)
if negated:
condition = not condition
return content if condition else ""
new_output = IF_INNERMOST.sub(replace_if, output)
if new_output == output:
break
output = new_output
# Falls noch orphan IF-Tags uebrig sind (z.B. unbalanced template): entfernen
# damit sie nicht im Word-Output landen.
output = ORPHAN_IF_TAG.sub("", output)
def replace_var(match: re.Match[str]) -> str:
name = match.group(1)
val = context.get(name)
if val is None:
# Leere Platzhalter sichtbar machen fuer Debugging
return f"[{name} fehlt]"
if isinstance(val, bool):
return "ja" if val else "nein"
return str(val)
output = VAR_PATTERN.sub(replace_var, output)
return output
def find_undefined_placeholders(template: str, context: dict[str, Any]) -> list[str]:
"""Listet alle Variablen-Platzhalter ohne Wert im Context."""
placeholders: set[str] = set()
for match in VAR_PATTERN.finditer(template):
placeholders.add(match.group(1))
for match in IF_INNERMOST.finditer(template):
placeholders.add(match.group(2))
return sorted([p for p in placeholders if p not in context])
@@ -0,0 +1,395 @@
"""
Mapping vom Wizard-State (frontend) auf den Template-Context (Render-Variablen).
Frontend liefert ein JSON-Payload mit den Wizard-Schritten. Hier konvertieren
wir es in eine flache Dict-Struktur, deren Keys SCREAMING_SNAKE_CASE sind und
zu den Platzhaltern in den Templates passen (z.B. {{COMPANY_NAME}}).
Pro Dokumenttyp (document_type) wird der jeweils benoetigte Subset gebaut.
"""
from __future__ import annotations
from typing import Any
def _gs_table(gesellschafter: list[dict[str, Any]], stammkapital: int) -> str:
"""Erzeugt eine Markdown-Tabelle der Gesellschafter."""
rows = []
for g in gesellschafter:
nb = int(g.get("nennbetrag_eur") or 0)
pct = (nb / max(stammkapital, 1)) * 100 if stammkapital else 0
rows.append(
f"| {g.get('anteil_nr', '')} | {g.get('name', '')} | "
f"{g.get('geburtsdatum') or g.get('adresse', '')} | "
f"{g.get('adresse', '')} | {g.get('anteil_nr', '')} | "
f"{nb:,} | {pct:.2f}% |".replace(",", ".")
)
return "\n".join(rows)
def _parties_list(gesellschafter: list[dict[str, Any]]) -> str:
"""Aufzaehlung der Parteien fuer SHA, IP-Assignment etc."""
lines = []
for idx, g in enumerate(gesellschafter):
letter = chr(ord("a") + idx)
line = f"{letter}) **{g.get('name', '')}**"
if g.get("geburtsdatum"):
line += f", geboren am {g['geburtsdatum']}"
if g.get("adresse"):
line += f", wohnhaft in {g['adresse']}"
lines.append(line + ",")
return "\n".join(lines)
def _parties_list_with_shares(gesellschafter: list[dict[str, Any]]) -> str:
"""Erzeugt nummerierte Liste der Gesellschafter mit Anteilen fuer § 3 Satzung."""
lines = []
for g in gesellschafter:
nr = g.get("anteil_nr", "?")
name = g.get("name", "")
nb = int(g.get("nennbetrag_eur") or 0)
lines.append(
f"{nr}. {name} übernimmt den Geschäftsanteil Nr. {nr} mit einem "
f"Nennbetrag von {nb:,} Euro.".replace(",", ".")
)
return "\n".join(lines)
def _gf_liste(gf: list[dict[str, Any]]) -> str:
"""Liste der Geschaeftsfuehrer fuer Bestellungsbeschluss / HRB-Anmeldung."""
lines = []
for g in gf:
line = f"- **{g.get('name', '')}**"
if g.get("geburtsdatum"):
line += f", geboren am {g['geburtsdatum']}"
if g.get("adresse"):
line += f", wohnhaft in {g['adresse']}"
if g.get("internal_role"):
line += f"{g['internal_role']}"
lines.append(line)
return "\n".join(lines)
def _company_purpose_bullets(bullets: list[str]) -> str:
return "\n".join(bullets) if bullets else "a) Allgemeine geschäftliche Tätigkeit"
def _roles_description(gesellschafter: list[dict[str, Any]]) -> str:
"""Generiert Anlage-A Rollenbeschreibung pro Gesellschafter."""
lines = []
for idx, g in enumerate(gesellschafter):
name = g.get("name", "")
role = g.get("internal_role") or "Gesellschafter"
lines.append(f"({idx + 2}) **{role}{name}**")
lines.append(f"Verantwortlich für die operative Leitung im Bereich {role}.\n")
return "\n".join(lines)
def _einzahlungsaufstellung(gesellschafter: list[dict[str, Any]], quote_pct: int) -> str:
rows = []
for g in gesellschafter:
nb = int(g.get("nennbetrag_eur") or 0)
paid = int(nb * quote_pct / 100)
rows.append(f"- {g.get('name', '')}: {paid:,} EUR von {nb:,} EUR ({quote_pct}%)".replace(",", "."))
return "\n".join(rows)
def base_context(state: dict[str, Any]) -> dict[str, Any]:
"""Gemeinsamer Context fuer alle Dokumente."""
basics = state.get("basics", {})
capital = state.get("capital", {})
notar = state.get("notar", {})
gesellschafter = state.get("gesellschafter", [])
gf_list = [g for g in gesellschafter if g.get("is_geschaeftsfuehrer")]
sha = state.get("sha", {})
stammkapital = int(capital.get("stammkapital_eur") or 25000)
num_gf = len(gf_list)
num_gs = len(gesellschafter)
has_academic = any(g.get("has_academic_background") for g in gesellschafter)
ctx: dict[str, Any] = {
# Company
"COMPANY_NAME": basics.get("company_name", ""),
"COMPANY_LEGAL_FORM": basics.get("legal_form", "GmbH"),
"COMPANY_SEAT": basics.get("company_seat", ""),
"COMPANY_ADDRESS": basics.get("company_address", ""),
"COMPANY_PURPOSE_DESCRIPTION": basics.get("company_purpose_description", ""),
"COMPANY_PURPOSE_BULLETS": _company_purpose_bullets(basics.get("company_purpose_bullets", [])),
"COMPANY_PURPOSE_SHORT": basics.get("industry", "")[:120],
"BUSINESS_YEAR": basics.get("business_year", "Kalenderjahr"),
"FIRST_YEAR_END": "31. Dezember des Eintragungsjahres",
"PUBLICATION_VENUE": "Bundesanzeiger",
# Capital
"STAMMKAPITAL_EUR": f"{stammkapital:,}".replace(",", "."),
"STAMMKAPITAL_HALF_EUR": f"{stammkapital // 2:,}".replace(",", "."),
"EINLAGE_METHOD": capital.get("einlage_method", "Geld"),
"EINLAGE_QUOTE_INITIAL_PCT": capital.get("einlage_quote_initial_pct", 50),
"EINLAGE_QUOTE_REMAINING_PCT": 100 - int(capital.get("einlage_quote_initial_pct") or 50),
"EINLAGE_QUOTE_INITIAL_LESS_THAN_100": (capital.get("einlage_quote_initial_pct") or 50) < 100,
"EINZAHLUNGSAUFSTELLUNG": _einzahlungsaufstellung(gesellschafter, capital.get("einlage_quote_initial_pct") or 50),
"HAS_SACHEINLAGE": capital.get("has_sacheinlage", False),
"VERZUGSFRIST_TAGE": 30,
"EINZIEHUNG_MEHRHEIT_PCT": 75,
"VORKAUFSRECHT_TAGE": 14,
"EINBERUFUNGSFRIST_TAGE": 7,
"VOTING_UNIT_EUR": "1,00",
"ERBFALL_AUFGRIFFSFRIST_MONATE": 6,
"ERBFALL_MEHRHEIT_PCT": 75,
"AUFLOESUNG_MEHRHEIT_PCT": 75,
"GRUENDUNGSKOSTEN_MAX_EUR": f"{int(stammkapital / 10):,}".replace(",", "."),
# Gesellschafter
"PARTIES_LIST": _parties_list(gesellschafter),
"PARTIES_LIST_WITH_SHARES": _parties_list_with_shares(gesellschafter),
"GESELLSCHAFTER_TABELLE": _gs_table(gesellschafter, stammkapital),
"GESCHAEFTSFUEHRER_LISTE": _gf_liste(gf_list),
"GESELLSCHAFTER_LISTE": _gf_liste(gesellschafter),
# GF
"NUM_GF": num_gf,
"NUM_GF_TEXT": {1: "einen", 2: "zwei", 3: "drei", 4: "vier", 5: "fünf"}.get(num_gf, str(num_gf)),
"IS_SINGLE_GF": num_gf == 1,
"IS_MULTI_GF": num_gf > 1,
"NUM_GF_IS_2": num_gf == 2,
"NUM_GF_GT_2": num_gf > 2,
"IS_MULTI_GESELLSCHAFTER": num_gs > 1,
"IS_FOUNDER_GROUP": num_gs >= 2,
"VERTRETUNGSART": "Gesamtvertretung; bei nur einem Geschäftsführer Einzelvertretung",
# Notar
"NOTARY_NAME": notar.get("notary_name", ""),
"NOTARY_PLACE": notar.get("notary_place", ""),
"NOTARY_ADDRESS": notar.get("notary_address", ""),
"NOTARY_URNR": notar.get("urnr", "[wird beim Termin vergeben]"),
"NOTARIAL_DATE": notar.get("notarial_date", "[Notartermin folgt]"),
"NOTARY_BEGLAUBIGUNG_URNR": "[wird beim Termin vergeben]",
"NOTARIAL_LOCATION": notar.get("notary_place", ""),
"ANMELDUNG_TYP": "Ersteintragung gemäß § 7 GmbHG",
"ANMELDUNG_DATE": notar.get("notarial_date", "[Notartermin folgt]"),
"REGISTRY_COURT_ADDRESS": "[Adresse des zuständigen Registergerichts]",
"COMPANY_REGISTRY_COURT": basics.get("register_court") or "[zuständiges Amtsgericht]",
"REGISTER_COURT": basics.get("register_court") or "[zuständiges Amtsgericht]",
# Common
"DOCUMENT_VERSION": "1.0.0",
"EFFECTIVE_DATE": notar.get("notarial_date", "[Datum der Beurkundung]"),
"RESOLUTION_DATE": notar.get("notarial_date", "[Datum der Beurkundung]"),
"NEXT_REVIEW_DATE": "[+ 12 Monate]",
"SIGNATURES_BLOCK": "Unterschriften gemäß notarieller Beurkundung",
# SHA Flags
"HAS_SHA": sha.get("has_sha", True),
"HAS_GO_GF": True,
"HAS_ACADEMIC_FOUNDER": has_academic,
"HAS_RESEARCH_FOCUS": basics.get("has_research_focus", False),
"HAS_BEIRAT": sha.get("has_beirat", False),
"HAS_TEXAS_SHOOTOUT": sha.get("has_texas_shootout", False),
"HAS_CEO_DESIGNATION": sha.get("has_ceo_designation", False),
"CEO_NAME": sha.get("ceo_name", ""),
"HAS_HRB": bool(basics.get("hrb_number")),
"HRB_NUMBER": basics.get("hrb_number") or "[wird vergeben]",
"IS_UG": basics.get("legal_form") == "UG",
# GO-GF dynamische §-Numerierung
"P_INFO": 5,
"P_GESELLSCHAFTER": 4 if num_gf == 1 else 4,
"P_AUSSER": 5,
"P_ENT": 6,
"P_FIN": 7,
"P_PERS": 8,
"P_IK": 9,
"P_NEB": 10,
"P_DOC": 10,
"P_DOC_NEXT": 2,
"P_DOC_NEXT_2": 3,
"P_END": 11,
"LAST_PARA_4": 4 if num_gf == 2 else 5,
# SHA dynamische §-Numerierung (mit/ohne Beirat)
"P_NONCOMPETE": 16 if sha.get("has_beirat") else 15,
"P_CONFIDENTIAL": 17 if sha.get("has_beirat") else 16,
"P_TERM": 18 if sha.get("has_beirat") else 17,
"P_FINAL": 19 if sha.get("has_beirat") else 18,
"P_IP_PARA_6": 6 if has_academic else 3,
"P_IP_PARA_7": 7 if has_academic else 4,
"P_IP_PARA_8": 8 if has_academic else 5,
"P_DEADLOCK_FINAL": 5,
"P_DEADLOCK_LAST": 6,
"LAST_ROLE_PARA": len(gesellschafter) + 2,
"LAST_ROLE_PARA_PLUS_1": len(gesellschafter) + 3,
# Satzung dynamische §-Numerierung
"P_EINZIEHUNG": 7,
"P_VORKAUF": 8,
"P_TAGALONG": 9,
"P_DRAGALONG": 10,
"P_VERSAMMLUNG": 11,
"P_JA": 12,
"P_ERGEBNIS": 13,
"P_AUFGRIFF": 14,
"P_ABTRETUNG": 15,
"P_ERBE": 16,
"P_AUFL": 17,
"P_SCHLUSS": 18,
# SHA Eskalation und sonstige Schwellenwerte
"ESKALATION_TAGE_INTERN": 5,
"ESKALATION_TAGE_GESELLSCHAFTER": 14,
"ERHEBLICH_EUR": "10.000",
"DEADLOCK_FRIST_TAGE": 30,
"MEDIATION_INIT_TAGE": 7,
"MEDIATOR_FRIST_TAGE": 5,
"MEDIATION_MAX_TAGE": 30,
"SHOOTOUT_FRIST_TAGE": 14,
"SHOOTOUT_ABWICKLUNG_TAGE": 60,
"ESKALATION_TAGE": 30,
"JURISDICTION_LOCATION": basics.get("company_seat", "[Sitz]"),
"PARA_181_DETAILS": "soweit Geschäftsführer von den Beschränkungen befreit",
"ARCHIV_VERANTWORTLICH": "Geschäftsführung",
"DOKUMENTATIONS_SYSTEM": "elektronischen Dokumentenmanagement",
"ARCHIVIERUNG_JAHRE": 10,
"REVIEW_VERANTWORTLICH": "Geschäftsführung",
"MEETING_OPERATIVE_FREQ": "wöchentliche",
"MEETING_STRATEGIE_FREQ": "monatliche",
"SCHWELLE_EINZEL_EUR": "10.000",
"SCHWELLE_EINZEL_EUR_PLUS_1": "10.001",
"SCHWELLE_GEMEINSAM_EUR": "50.000",
"SCHWELLE_GESELLSCHAFTER_EUR": "50.000",
"BUDGET_ABWEICHUNG_PCT": 10,
"VERTRAG_LAUFZEIT_MONATE": 24,
"VERTRAG_WERT_EUR": "50.000",
"LIQUIDITAET_MIN_MONATE": 3,
"FORECAST_HORIZON_MONTHS": 12,
"SCHLUESSELPERSON_GEHALT_EUR": "80.000",
"NEBENTAETIGKEIT_MAX_STUNDEN": 8,
# SHA-Spezifika
"VESTING_START_EVENT": "Eintragung der Gesellschaft im Handelsregister",
"VESTING_MONTHS": sha.get("vesting_months", 48),
"CLIFF_MONTHS": sha.get("cliff_months", 12),
"ACCELERATION_THRESHOLD_PCT": 50,
"ACCELERATION_PCT": 100,
"BAD_LEAVER_UNVESTED_PCT": 20,
"FMV_AGREEMENT_DAYS": 14,
"ABFINDUNG_RATEN_MAX": 24,
"NON_SOLICIT_MONTHS": 12,
"VORKAUFSRECHT_TAGE": 14,
"TAG_ALONG_THRESHOLD_PCT": sha.get("tag_along_threshold_pct", 20),
"TAG_ALONG_FRIST_TAGE": 14,
"DRAG_ALONG_THRESHOLD_PCT": sha.get("drag_along_threshold_pct", 75),
"RESERVED_MATTERS_MAJORITY_PCT": sha.get("reserved_matters_majority_pct", 75),
"ASSET_THRESHOLD_EUR": "50.000",
"ESOP_POOL_PCT": sha.get("esop_pool_pct", 0),
"INVESTOR_INFO_THRESHOLD_EUR": "50.000",
"ANNUAL_REPORT_MONTHS": 6,
"BEIRAT_MAX_MITGLIEDER": 5,
"BEIRAT_FREQ": "vierteljährlich",
"PASSIVE_INVEST_PCT": 5,
"POST_EXIT_GOOD_MONTHS": 12,
"POST_EXIT_BAD_MONTHS": 24,
"ROLES_DESCRIPTION": _roles_description(gesellschafter),
"SIGNATURE_DATE": notar.get("notarial_date", "[Datum]"),
# Gesellschafterliste
"LIST_DATE": notar.get("notarial_date", "[Datum]"),
"LIST_AUTHOR": gf_list[0].get("name", "") if gf_list else "",
"LIST_AUTHOR_ROLE": "Geschäftsführer",
"LIST_REASON": "Erstaufstellung gemäß § 40 GmbHG",
"SIGNATORY_NAME": gf_list[0].get("name", "") if gf_list else "",
"SIGNATORY_ROLE": "Geschäftsführer",
"SIGNATORY_2_NAME": gf_list[1].get("name", "") if len(gf_list) > 1 else "",
"SIGNATORY_2_ROLE": "Geschäftsführer",
"MULTI_SIGNATORY": len(gf_list) > 1,
# Bestellungsbeschluss
"MEETING_LOCATION": notar.get("notary_place", "[Notarsitz]"),
"RESOLUTION_FORM": "notariell beurkundet",
"ANWESENHEITSQUOTE_PCT": 100,
"IS_EINSTIMMIG": True,
"BESCHLUSS_MEHRHEIT_PCT": 100,
"IS_PRESENCE_MEETING": True,
"IS_SINGLE_APPOINTMENT": num_gf == 1,
"IS_MULTI_APPOINTMENT": num_gf > 1,
"IS_FIRST_APPOINTMENT": True,
"IS_PLURAL_GF": num_gf > 1,
"GF_NAME": gf_list[0].get("name", "") if gf_list else "",
"GF_BIRTHDATE": gf_list[0].get("geburtsdatum", "") if gf_list else "",
"GF_BIRTHDATE_PLACE": "[Geburtsort]",
"GF_ADDRESS": gf_list[0].get("adresse", "") if gf_list else "",
"GF_VERTRETUNG": "einzelvertretungsberechtigt" if num_gf == 1 else "gemeinsam mit einem weiteren Geschäftsführer vertretungsberechtigt",
"GF_PARA_181_RELEASE": True,
"GF_LISTE_MIT_VERTRETUNGSART": "\n".join(
f"- {g.get('name', '')}, geb. {g.get('geburtsdatum', '')}, wohnhaft in {g.get('adresse', '')}, "
f"vertretungsberechtigt {'allein' if num_gf == 1 else 'gemeinsam'}; § 181 BGB-Befreiung erteilt"
for g in gf_list
),
"HAS_RESSORT_ZUWEISUNG": True,
"HAS_DIENSTVERTRAG": True,
"SIGNATURES_GESELLSCHAFTER": "\n".join(
f"___________________________\n{g.get('name', '')}"
for g in gesellschafter
),
"HAS_VERSICHERUNG_BESTELLT": True,
"BELEHRUNG_DURCH": "den beurkundenden Notar",
"HAS_DELAYED_START": False,
# HRB-Anmeldung
"VERTRETUNGSREGELUNG": (
"Die Gesellschaft wird durch einen Geschäftsführer allein vertreten."
if num_gf == 1 else
"Die Gesellschaft wird durch zwei Geschäftsführer gemeinsam vertreten. "
"Bei nur einem bestellten Geschäftsführer Einzelvertretung."
),
"GF_SIGNATURES_BEGLAUBIGUNG": "\n".join(
f"___________________________\n{g.get('name', '')}, Geschäftsführer"
for g in gf_list
),
"HAS_EMPFANGSBERECHTIGTER": False,
"EMPFANGSBERECHTIGTER_NAME": "",
"EMPFANGSBERECHTIGTER_ADDRESS": "",
"HAS_GENEHMIGUNG": False,
"GENEHMIGUNG_DETAILS": "",
"NEXT_DOC_NUMBER": 6,
# GF-Dienstvertrag (Defaults für alle GFs, einzelne Felder per Contract überschreiben)
"COMPANY_REPRESENTATIVE": "die Gesellschafterversammlung",
"APPOINTMENT_DATE": notar.get("notarial_date", "[Datum]"),
"GF_INTERNAL_TITLE": gf_list[0].get("internal_role", "Geschäftsführer") if gf_list else "Geschäftsführer",
"CONTRACT_START_DATE": notar.get("notarial_date", "[Datum]"),
"HAS_PARA_181_RELEASE": True,
"PARA_181_RELEASE_DATE": notar.get("notarial_date", "[Datum]"),
"HAS_BONUS": False, "HAS_TANTIEME": False, "HAS_COMPANY_CAR": False, "HAS_BAV": False,
"HAS_HINTERBLIEBENEN_VERSORGUNG": False, "HAS_KOPPLUNG_BESTELLUNG_VERTRAG": False,
"HAS_NONCOMPETE_COMPENSATION": False,
"POST_CONTRACT_NONCOMPETE_MONTHS": 12,
"GROSS_ANNUAL_SALARY_EUR": "84.000",
"COMPANY_CAR_CLASS": "",
"BAV_EMPLOYER_PCT": 0,
"SV_STATUS": "sozialversicherungsfrei",
"VACATION_DAYS": 30,
"KRANKHEIT_FORTZAHLUNG_WOCHEN": 6,
"AU_BESCHEINIGUNG_TAG": 4,
"HINTERBLIEBENEN_VERSORGUNG_MONATE": 6,
"DO_INSURANCE_EUR": "5.000.000",
"KUENDIGUNGSFRIST_GESELLSCHAFT_MONATE": 6,
"KUENDIGUNGSFRIST_GF_MONATE": 3,
"ANNEX_LIST": "- Anlage 1: Bonusplan (sofern vereinbart)\n- Anlage 2: D&O-Versicherungspolice",
# IP-Assignment
"ASSIGNOR_NAME": gf_list[0].get("name", "") if gf_list else "",
"ASSIGNOR_BIRTHDATE": gf_list[0].get("geburtsdatum", "") if gf_list else "",
"ASSIGNOR_ADDRESS": gf_list[0].get("adresse", "") if gf_list else "",
"ASSIGNOR_ROLE": gf_list[0].get("internal_role", "Gründer und Geschäftsführer") if gf_list else "Gründer",
"AGREEMENT_DATE": notar.get("notarial_date", "[Datum]"),
"HAS_BAR_VERGUETUNG": False,
"HAS_SHARES_AS_COMPENSATION": True,
"HAS_NO_VERGUETUNG": False,
"IP_VERGUETUNG_EUR": 0,
"ZAHLUNGSFRIST_TAGE": 30,
"GUARANTEE_VERJAEHRUNG_JAHRE": 3,
"HAS_ACADEMIC_BACKGROUND": has_academic,
"SIGNATURE_LOCATION": basics.get("company_seat", "[Sitz]"),
"IP_LIST_DETAILS": "- Software-Architektur und Quellcode (bestehend zum Zeitpunkt der Gründung)\n- Konzepte, Designs, Datenbankstrukturen\n- Marken, Logos, Domainnamen",
"IP_EXCEPTIONS_DETAILS": "Keine Ausnahmen bekannt.",
}
# Ressort-Variablen aus GF-Liste ableiten (1 Ressort pro GF)
ressort_defaults = [
("Operative & Kommerzielle Leitung", "Finanzen, HR, Vertrieb, Business Development, operative Steuerung"),
("Technik & Engineering", "Softwareentwicklung, Architektur, Infrastruktur, Sicherheit, technische Roadmap"),
("Research & Partnerships", "Forschungskooperationen, Drittmittel, wissenschaftliche Methodik"),
]
for idx, gf in enumerate(gf_list[:3]):
n = idx + 1
default_name, default_aufgaben = ressort_defaults[idx] if idx < 3 else ("Allgemeine Leitung", "Sonstige Aufgaben")
ctx[f"RESSORT_{n}_NAME"] = gf.get("internal_role") or default_name
ctx[f"RESSORT_{n}_GF"] = gf.get("name", "")
ctx[f"RESSORT_{n}_AUFGABEN"] = f"- {default_aufgaben}"
ctx["HAS_RESSORT_3"] = len(gf_list) >= 3
return ctx
@@ -0,0 +1,325 @@
"""
P82 GF-1-Pager (Geschaeftsfuehrer-Kurzfassung).
Eine kompakte 5-7-Bullet-Zusammenfassung ganz oben in der Mail. GF liest
sonst die 124k-Char-Komplettpruefung nicht. Ton sachlich, keine Panik
(Memory: feedback_breakpilot_tonalitaet).
Bildet ab:
- Compliance-Score + Vergleichswert (wenn Vorlauf vorhanden)
- Top-3 priorisierte Themen (HIGH oder kritisches MEDIUM)
- Aufwand-Schaetzung (4-8 Wochen) + Wer-macht-was (DSB / IT / Marketing)
- Realer Risiko-Hinweis (ohne 4%-Weltumsatz-Drohung)
Wird VOR Critical-Findings und Exec-Summary gerendert.
"""
from __future__ import annotations
import logging
from typing import Any
logger = logging.getLogger(__name__)
_AREA_LABEL = {
"banner": "Cookie-Banner",
"cookie": "Cookie-Richtlinie",
"dse": "Datenschutzerklaerung",
"impressum": "Impressum",
"agb": "AGB",
"library_mismatch": "Cookie-Klassifikation",
"vendor": "Vendor-Liste / VVT",
"consent": "Einwilligung",
"rights": "Betroffenenrechte",
}
def _normalize_finding(item: dict) -> dict:
sev = str(item.get("severity") or item.get("level") or "").upper()
if sev not in ("HIGH", "MEDIUM", "LOW"):
sev = "MEDIUM"
label = (item.get("label") or item.get("title")
or item.get("check") or item.get("name") or "").strip()
if not label:
return {}
area = (item.get("area") or item.get("doc_type") or item.get("category") or "").lower()
return {
"severity": sev,
"label": label[:200],
"area": _AREA_LABEL.get(area, area.replace("_", " ").title() or "Allgemein"),
"owner": item.get("owner") or _guess_owner(label, area),
}
def _guess_owner(label: str, area: str) -> str:
"""Heuristik: wer ist der wahrscheinliche Ansprechpartner."""
lab = label.lower()
if any(w in lab for w in ("banner", "cookie", "consent",
"einwilligung", "tracking")):
return "DSB + Marketing/CMP-Admin"
if any(w in lab for w in ("vendor", "avv", "auftragsverarbeitung",
"drittland", "schrems")):
return "DSB + Einkauf/Legal"
if any(w in lab for w in ("impressum", "agb", "widerruf", "kontakt")):
return "Legal + Web-Team"
if any(w in lab for w in ("dsfa", "dsr", "loeschfrist", "art. 15",
"auskunft", "betroffenenrecht")):
return "DSB"
if any(w in lab for w in ("tom", "verschluesselung", "backup",
"incident", "logging")):
return "IT-Security + DSB"
if area in ("banner", "cookie"):
return "DSB + Marketing"
return "DSB"
def _collect_top_findings(
banner_result: dict | None,
scorecard: dict | None,
library_mismatch_findings: list[dict] | None,
audit_quality_findings: list[dict] | None = None,
limit: int = 5,
) -> list[dict]:
out: list[dict] = []
# 0) Audit-Quality-Vorbehalte (Banner-Detect-Fail, Vendor-thin) zuerst —
# die sind WICHTIGER als alle anderen Findings weil sie den Audit
# selbst infrage stellen.
for aq in (audit_quality_findings or []):
if isinstance(aq, dict):
out.append({
"severity": aq.get("severity", "HIGH"),
"label": aq.get("label", "Audit-Vorbehalt"),
"area": aq.get("area", "Audit-Qualitaet"),
"owner": aq.get("owner", "DSB + Web-Team"),
})
# 1) Banner deep-check findings (HIGH zuerst)
if banner_result:
for ph in (banner_result.get("phases") or {}).values():
if not isinstance(ph, dict):
continue
for f in (ph.get("findings") or []):
if not isinstance(f, dict):
continue
n = _normalize_finding({**f, "area": "banner"})
if n:
out.append(n)
# 2) Library-Mismatch HIGH (Marketing-Cookies als essential deklariert)
for mm in (library_mismatch_findings or []):
if isinstance(mm, dict) and mm.get("severity") == "HIGH":
out.append({
"severity": "HIGH",
"label": f'Cookie "{mm.get("cookie","?")}" als '
f'{mm.get("declared_category","?")} deklariert, '
f'tatsaechlicher Zweck typischerweise '
f'{mm.get("library_category","?")}',
"area": _AREA_LABEL["library_mismatch"],
"owner": "DSB + Marketing/CMP-Admin",
})
# 3) Scorecard FAILs (MC-Audit)
if scorecard:
for entry in (scorecard.get("failed") or scorecard.get("items") or []):
if not isinstance(entry, dict):
continue
n = _normalize_finding(entry)
if n and n["severity"] == "HIGH":
out.append(n)
# Sort: HIGH first, then MEDIUM, stable order. Dedup by label.
seen: set[str] = set()
order = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
out.sort(key=lambda f: order.get(f["severity"], 3))
dedup: list[dict] = []
for f in out:
key = f["label"].lower()[:80]
if key in seen:
continue
seen.add(key)
dedup.append(f)
if len(dedup) >= limit:
break
return dedup
def _score_color(score: float | int | None) -> str:
if score is None:
return "#64748b"
try:
s = float(score)
except (TypeError, ValueError):
return "#64748b"
if s >= 80:
return "#16a34a"
if s >= 60:
return "#ca8a04"
return "#dc2626"
def _delta_html(curr: float | None, prev: float | None) -> str:
if curr is None or prev is None:
return ""
try:
d = float(curr) - float(prev)
except (TypeError, ValueError):
return ""
if abs(d) < 0.5:
return (
' <span style="color:#64748b;font-size:11px">'
'(unveraendert ggue. letztem Lauf)</span>'
)
arrow = "" if d > 0 else ""
color = "#16a34a" if d > 0 else "#dc2626"
return (
f' <span style="color:{color};font-size:11px">'
f'{arrow} {abs(d):.1f} Punkte ggue. letztem Lauf</span>'
)
def build_gf_one_pager_html(
site_name: str,
scorecard: dict | None = None,
previous_scorecard: dict | None = None,
banner_result: dict | None = None,
library_mismatch_findings: list[dict] | None = None,
scan_context: dict | None = None,
audit_quality_findings: list[dict] | None = None,
) -> str:
"""5-7-Bullet-Zusammenfassung. Leere Top-Findings: nur Status-Bullet."""
score = None
if scorecard:
score = scorecard.get("compliance_score") or scorecard.get("score")
prev_score = None
if previous_scorecard:
prev_score = (previous_scorecard.get("compliance_score")
or previous_scorecard.get("score"))
top = _collect_top_findings(
banner_result=banner_result,
scorecard=scorecard,
library_mismatch_findings=library_mismatch_findings,
audit_quality_findings=audit_quality_findings,
limit=6,
)
audit_warn = bool(audit_quality_findings)
n_high = sum(1 for f in top if f["severity"] == "HIGH")
n_med = sum(1 for f in top if f["severity"] == "MEDIUM")
if score is not None:
score_str = f'{float(score):.0f}/100'
else:
score_str = ""
score_color = _score_color(score)
ctx_line = ""
if scan_context:
bits: list[str] = []
if scan_context.get("industry"):
bits.append(scan_context["industry"])
if scan_context.get("business_model"):
bits.append(scan_context["business_model"].upper())
if scan_context.get("employee_count"):
bits.append(f'{scan_context["employee_count"]} MA')
if bits:
ctx_line = (
'<div style="font-size:11px;color:#64748b;margin-bottom:6px">'
f'Klassifizierung: {" · ".join(bits)}'
'</div>'
)
bullets: list[str] = []
sev_pill = {
"HIGH": '<span style="background:#fee2e2;color:#991b1b;'
'padding:1px 6px;border-radius:8px;font-size:10px;'
'font-weight:600">HOCH</span>',
"MEDIUM": '<span style="background:#fef3c7;color:#92400e;'
'padding:1px 6px;border-radius:8px;font-size:10px;'
'font-weight:600">MITTEL</span>',
"LOW": '<span style="background:#dbeafe;color:#1e40af;'
'padding:1px 6px;border-radius:8px;font-size:10px;'
'font-weight:600">NIEDRIG</span>',
}
try:
from compliance.services.finding_confidence import confidence_pill_html
except Exception:
def confidence_pill_html(_label: str) -> str:
return ""
for f in top:
bullets.append(
f'<li style="margin-bottom:4px;font-size:12px;line-height:1.45">'
f'{sev_pill.get(f["severity"], "")} <strong>{f["area"]}:</strong> '
f'{f["label"]}'
f'{confidence_pill_html(f["label"])} '
f'<span style="color:#64748b">— typisch zustaendig: '
f'{f["owner"]}</span></li>'
)
if not bullets:
if audit_warn:
bullets.append(
'<li style="margin-bottom:4px;font-size:12px;color:#991b1b">'
'<strong>Audit selbst war unvollstaendig</strong> — siehe '
'roten Audit-Vorbehalt-Block weiter unten. Eine pauschale '
'"alles ok"-Aussage ist auf Basis dieser Datenlage nicht '
'moeglich.</li>'
)
else:
bullets.append(
'<li style="margin-bottom:4px;font-size:12px;color:#475569">'
'Keine kritischen Themen erkannt — der Audit-Lauf hat fuer '
'die geprueften Dokumente keine HIGH-Findings produziert. '
'Details im weiteren Verlauf der Mail.</li>'
)
return (
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
'max-width:760px;margin:0 auto 16px;padding:18px 20px;'
'background:#f8fafc;border:1px solid #cbd5e1;border-radius:8px">'
'<div style="font-size:11px;color:#475569;text-transform:uppercase;'
'letter-spacing:1.4px;margin-bottom:4px;font-weight:600">'
f'Kurzfassung fuer die Geschaeftsfuehrung — {site_name or ""}'
'</div>'
+ ctx_line +
'<div style="display:flex;align-items:baseline;gap:14px;'
'margin:8px 0 14px;flex-wrap:wrap">'
f'<div style="font-size:28px;font-weight:700;color:{score_color}">'
f'{score_str}</div>'
'<div style="font-size:11px;color:#64748b">'
f'Compliance-Score{_delta_html(score, prev_score)}</div>'
f'<div style="margin-left:auto;font-size:11px;color:#475569">'
f'<strong>{n_high}</strong> hoch · '
f'<strong>{n_med}</strong> mittel'
'</div></div>'
'<div style="font-size:11px;color:#475569;margin-bottom:6px;'
'font-weight:600;text-transform:uppercase;letter-spacing:1px">'
'Was kurzfristig angegangen werden sollte'
'</div>'
'<ul style="margin:0 0 12px 18px;padding:0">'
+ "".join(bullets) +
'</ul>'
'<div style="font-size:11px;color:#475569;line-height:1.5;'
'padding:8px 10px;background:#fff;border:1px solid #e2e8f0;'
'border-radius:4px">'
+ (
'<strong style="color:#991b1b">Wichtig — Audit unvollstaendig:'
'</strong> An mindestens einer Stelle ist unser Crawler an '
'Grenzen gestossen (siehe roter Audit-Vorbehalt-Block weiter '
'unten). Diese Bereiche sollten manuell oder im Copy-Paste-Modus '
'nachgereicht werden, bevor eine belastbare Compliance-Aussage '
'getroffen wird.'
if audit_warn else
'<strong>Realistische Einordnung:</strong> Wir analysieren das '
'Aussenbild Ihrer Website automatisiert — einzelne Findings '
'koennen durch interne Dokumentation bereits abgedeckt sein. '
'Empfohlenes Vorgehen: priorisierte Punkte mit DSB / Marketing / '
'IT in einem Termin durchsprechen (4-8 Wochen sind ein '
'realistischer Zeitrahmen fuer die Umsetzung). Eine pauschale '
'Bussgeld-Erwartung leiten wir aus diesem Audit nicht ab.'
)
+ '</div>'
'</div>'
)
@@ -0,0 +1,117 @@
"""
P86 Branchen-Benchmark.
Vergleicht den eigenen Compliance-Score mit dem Branchen-Median aus
allen bisherigen Snapshots derselben industry (P79 scan_context).
Liefert: "Sie 42% — Automotive-Median 58% (Stichprobe: 12 Sites)".
Wird in der Mail-Composition direkt unter dem Score im GF-1-Pager
gerendert. Mindest-Stichprobe = 3 vergleichbare Snapshots, sonst skip.
Heuristik fuer Score-Extraktion aus banner_result:
- banner_result.completeness_pct ODER
- banner_result.correctness_pct ODER
- 100 - len(banner_checks.violations) * 5 als Fallback.
"""
from __future__ import annotations
import json
import logging
from typing import Any
from sqlalchemy import text
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
_MIN_SAMPLE = 3
def _extract_score(banner_result: dict | None) -> float | None:
if not isinstance(banner_result, dict):
return None
for key in ("compliance_score", "completeness_pct", "correctness_pct"):
v = banner_result.get(key)
if isinstance(v, (int, float)):
return float(v)
bc = banner_result.get("banner_checks") or {}
if isinstance(bc, dict):
viols = bc.get("violations") or []
if isinstance(viols, list):
return max(0.0, 100.0 - len(viols) * 5)
return None
def compute_benchmark(
db: Session,
industry: str,
current_score: float | None,
current_check_id: str,
) -> dict | None:
if not industry or current_score is None:
return None
# Snapshots mit gleicher industry in scan_context.
rows = db.execute(text(
"""
SELECT banner_result FROM compliance.compliance_check_snapshots
WHERE check_id != :cid
AND scan_context IS NOT NULL
AND scan_context->>'industry' = :ind
ORDER BY created_at DESC
LIMIT 50
"""
), {"cid": current_check_id, "ind": industry}).fetchall()
scores: list[float] = []
for r in rows:
br = r[0]
if isinstance(br, str):
try:
br = json.loads(br)
except Exception:
continue
s = _extract_score(br)
if s is not None:
scores.append(s)
if len(scores) < _MIN_SAMPLE:
return None
scores.sort()
n = len(scores)
median = scores[n // 2] if n % 2 else (scores[n // 2 - 1] + scores[n // 2]) / 2
pct_lower = round(sum(1 for s in scores if s < current_score) / n * 100)
return {
"industry": industry,
"current": round(current_score, 1),
"median": round(median, 1),
"sample_size": n,
"percentile": pct_lower, # 80 = besser als 80% der Branche
}
def build_benchmark_html(bench: dict) -> str:
if not bench:
return ""
delta = bench["current"] - bench["median"]
if delta >= 5:
color = "#16a34a"
verdict = "ueber dem Branchen-Median"
elif delta <= -5:
color = "#dc2626"
verdict = "unter dem Branchen-Median"
else:
color = "#ca8a04"
verdict = "etwa auf Branchen-Median"
return (
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
'max-width:760px;margin:0 auto 12px;padding:8px 14px;'
'background:#f0f9ff;border:1px solid #bfdbfe;border-radius:6px;'
'font-size:11px;color:#1e293b">'
f'<strong>Branchen-Vergleich ({bench["industry"]}):</strong> '
f'Ihr Score <strong>{bench["current"]:.1f}</strong> '
f'<span style="color:{color}">({verdict}, '
f'Median {bench["median"]:.1f})</span>. '
f'<span style="color:#64748b">Sie sind besser als '
f'{bench["percentile"]}% der bisher von uns gepruften '
f'{bench["sample_size"]} Sites in dieser Branche.</span>'
'</div>'
)
@@ -0,0 +1,116 @@
"""
P71 JC-vs-AVV Entscheidungsbaum.
Hilft dem Nutzer zu bestimmen, ob ein bestimmtes Verarbeitungsverhaeltnis
gemeinsame Verantwortlichkeit (Art. 26 DSGVO) oder Auftragsverarbeitung
(Art. 28 DSGVO) ist. EDPB 7/2020 ist die Grundlage.
Wird gerendert als kleiner Block am Ende der Mail, wenn im DSE-Text
Konstrukte vorkommen die ambivalent sind (z.B. 'gemeinsame Auswertung
mit Schwesterunternehmen', 'gemeinsame Plattform-Nutzung'). Liefert
3-4 Leitfragen + jeweilige Empfehlung.
"""
from __future__ import annotations
import logging
logger = logging.getLogger(__name__)
_JC_SIGNALS = (
"schwesterunternehmen", "konzernschwester", "gemeinsame plattform",
"gemeinsame auswertung", "gemeinsame studie", "joint venture",
"konzernweite analyse", "gemeinsame zwecke", "gemeinsame ziele",
"konzernweit", "gemeinsamer kunde", "gemeinsamer datenpool",
)
_AVV_SIGNALS = (
"auftragsverarbeiter", "auftragsverarbeitung", "weisungsgebunden",
"im auftrag von", "im namen des verantwortlichen",
"art. 28 dsgvo", "art 28 dsgvo", "dpa (data processing agreement",
)
_QUESTIONS = [
{
"q": "Bestimmen beide Seiten gemeinsam Zweck UND Mittel der Verarbeitung?",
"yes": "JC (Art. 26)",
"no": "AVV-Indikator",
"explain": "EDPB 7/2020 Rn. 51-65: beidseitige Zweckbestimmung ist "
"das Hauptmerkmal der gemeinsamen Verantwortlichkeit.",
},
{
"q": "Verfolgen die Parteien eigene, getrennte Zwecke (z.B. eigene "
"Kundenbeziehung) oder einen gemeinsamen Zweck?",
"yes": "Wenn getrennt: AVV (oder zwei getrennte Verantwortliche)",
"no": "Wenn gemeinsam: JC (Art. 26)",
"explain": "EuGH C-25/17 Zeugen Jehovas: getrennte Zwecke "
"schliessen JC aus.",
},
{
"q": "Existiert eine schriftliche Weisungs-Hierarchie und Pflicht "
"zur Loeschung am Vertragsende?",
"yes": "AVV (Art. 28 Pflichten erfuellt)",
"no": "Pruefen ob JC vorliegt + Art. 26-Vereinbarung noetig",
"explain": "Art. 28 (3)(g) DSGVO + EDPB 7/2020 Rn. 88.",
},
{
"q": "Haben Betroffene gegenueber beiden Stellen vollstaendige "
"Rechte (Art. 15-22)?",
"yes": "JC — Art. 26 (3) verlangt einheitliche Anlaufstelle",
"no": "AVV — Auftragsverarbeiter weist Rechtsausuebung an "
"Verantwortlichen zurueck",
"explain": "Art. 26 (3) DSGVO macht beide Stellen als gemeinsame "
"Anlaufstelle ansprechbar.",
},
]
def detect_ambiguous_jc_avv(dse_text: str | None) -> bool:
"""Heuristik: liegen sowohl JC- als auch AVV-Signale im DSE? Dann
ist die Konstellation typischerweise unklar und der Entscheidungsbaum
hilft."""
if not dse_text:
return False
t = dse_text.lower()
has_jc = any(s in t for s in _JC_SIGNALS)
has_avv = any(s in t for s in _AVV_SIGNALS)
return has_jc and has_avv
def build_jc_avv_decision_html(dse_text: str | None) -> str:
if not detect_ambiguous_jc_avv(dse_text):
return ""
items = []
for i, q in enumerate(_QUESTIONS, 1):
items.append(
f'<li style="margin-bottom:8px;font-size:11px;line-height:1.5">'
f'<strong>{i}. {q["q"]}</strong><br>'
f'<span style="color:#16a34a">Ja: </span>{q["yes"]} &nbsp;|&nbsp; '
f'<span style="color:#dc2626">Nein: </span>{q["no"]}<br>'
f'<span style="color:#64748b;font-size:10px;font-style:italic">'
f'{q["explain"]}</span>'
f'</li>'
)
return (
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
'max-width:760px;margin:0 auto 16px;padding:12px 16px;'
'background:#f1f5f9;border:1px solid #cbd5e1;border-radius:6px">'
'<div style="font-size:11px;color:#475569;text-transform:uppercase;'
'letter-spacing:1.2px;margin-bottom:4px;font-weight:600">'
'JC vs AVV — Entscheidungshilfe</div>'
'<h3 style="margin:0 0 6px;font-size:14px;color:#1e293b">'
'Im DSE-Text gibt es sowohl gemeinsame-Verantwortlichkeits- als '
'auch Auftragsverarbeitungs-Hinweise</h3>'
'<p style="margin:0 0 10px;font-size:11px;color:#475569;line-height:1.5">'
'Pruefen Sie mit dem DSB die folgenden 4 Leitfragen aus EDPB 7/2020. '
'Das Ergebnis bestimmt ob eine Art. 26-Vereinbarung (JC) oder ein '
'Art. 28-AVV vorliegen muss.'
'</p>'
'<ol style="margin:0 0 0 18px;padding:0">'
+ "".join(items) +
'</ol>'
'<p style="margin:8px 0 0;font-size:10px;color:#94a3b8;'
'font-style:italic">Quelle: EDPB Guidelines 7/2020 (Controller/Processor) '
'+ EuGH C-25/17, C-40/17.</p>'
'</div>'
)
@@ -0,0 +1,86 @@
"""
P88 PDF-Export der Audit-Mail.
Rendert dieselbe HTML wie die Mail via WeasyPrint zu PDF. Endpoint:
GET /api/compliance/agent/snapshots/{snapshot_id}/pdf application/pdf
Verwendung:
- GF/Lawyer-Uebergabe (kein E-Mail-Programm noetig)
- Archivierung
- Mandatsausgabe an externen Berater
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from compliance.services.check_replay import replay_from_snapshot
logger = logging.getLogger(__name__)
_PDF_WRAPPER_HEAD = """<!DOCTYPE html>
<html lang="de"><head><meta charset="utf-8"><title>{title}</title>
<style>
@page {{ size: A4; margin: 18mm 14mm 18mm 14mm;
@bottom-right {{ content: "Seite " counter(page) " / " counter(pages);
color: #94a3b8; font-size: 9pt; }} }}
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI',
Roboto, sans-serif; font-size: 11pt;
color: #1e293b; max-width: 760px; margin: 0 auto;
line-height: 1.45; }}
h1, h2, h3 {{ page-break-after: avoid; }}
table {{ page-break-inside: auto; }}
tr {{ page-break-inside: avoid; }}
.header {{ border-bottom: 2px solid #0f172a; padding-bottom: 8mm;
margin-bottom: 8mm; }}
.header h1 {{ margin: 0; font-size: 16pt; color: #0f172a; }}
.header .meta {{ font-size: 9pt; color: #64748b; margin-top: 2mm; }}
</style></head><body>
<div class="header">
<h1>BreakPilot Compliance-Audit {site}</h1>
<div class="meta">PDF-Export erstellt am {ts} · Snapshot {snap_short}</div>
</div>
"""
def render_snapshot_as_pdf(
db: Session,
snapshot_id: str,
) -> bytes | None:
"""Returns PDF bytes or None on failure."""
try:
from weasyprint import HTML # noqa: WPS433 — Optional dep
except Exception as e:
logger.error("WeasyPrint nicht verfuegbar: %s", e)
return None
res = replay_from_snapshot(db, snapshot_id, recipient=None, dry_run=True)
if not res or res.get("error"):
logger.warning("PDF-Export: Snapshot %s nicht gefunden", snapshot_id)
return None
# The replay returns html via "preview" (truncated) — fetch the full
# render by injecting site_label into a wrapper.
full_html = _build_full_html(res, snapshot_id)
try:
pdf_bytes = HTML(string=full_html).write_pdf()
return pdf_bytes
except Exception as e:
logger.exception("WeasyPrint PDF render failed: %s", e)
return None
def _build_full_html(replay_result: dict, snapshot_id: str) -> str:
"""Wraps the replay's full_html in the PDF-print wrapper."""
full = replay_result.get("full_html") or replay_result.get("preview") or ""
site = replay_result.get("site_domain") or ""
snap_short = snapshot_id[:8]
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
header = _PDF_WRAPPER_HEAD.format(
title=f"BreakPilot Audit — {site}",
site=site, snap_short=snap_short, ts=ts,
)
return header + full + "</body></html>"
@@ -0,0 +1,257 @@
"""
P73 MC-Solution-Generator.
Generiert pro Fail-MC eine konkrete Einfuege-Empfehlung mit Anchor:
"Bitte ergaenzen Sie nach Abschnitt 'Kontaktdaten DSB' folgenden
Absatz: ...". LLM-Cascade Qwen (lokal) -> OVH 120B.
Cache: in-process LRU per (mc_id, doc_md5) damit Re-Runs derselben
Site denselben Vorschlag liefern. Volle DB-Cache kommt spaeter (P31).
Integration: wird im build_critical_findings_html / mc-detail-rendering
unter jedem HIGH-Fail als eingeklappbarer Block angezeigt.
"""
from __future__ import annotations
import hashlib
import json
import logging
import os
from functools import lru_cache
from typing import Iterable
import httpx
logger = logging.getLogger(__name__)
_SYSTEM_PROMPT = (
"Du bist Datenschutz-Redakteur. Du formulierst kurze, einfueg-bereite "
"Absaetze fuer Datenschutz-Dokumente — sachlich, in deutscher "
"Rechtssprache, ohne Marketing-Floskeln.\n\n"
"Du bekommst:\n"
"- den FAIL-MC (was geprueft wurde, warum es nicht erfuellt ist)\n"
"- einen Auszug aus dem Ist-Dokument\n"
"- den Dokument-Typ\n\n"
"Du lieferst JSON:\n"
'{\n'
' "solution_text": "<3-6 Saetze Vorschlags-Absatz fuer das Dokument>",\n'
' "anchor_hint": "<wo einfuegen, z.B. \\"nach Abschnitt Kontaktdaten\\">",\n'
' "effort_min": "<gering|mittel|hoch>"\n'
'}\n\n'
"Regeln:\n"
"- KEINE Normtexte 1:1 zitieren — eigene Formulierung + Norm-Referenz.\n"
"- KEINE Annahmen ueber Konkretes (z.B. Firmennamen, Adressen) — "
"Platzhalter [Ihr Firmenname] / [Ihre Adresse] verwenden.\n"
"- Wenn schon eine schwache Variante im Dokument steht, anchor_hint "
"auf 'ersetzen' setzen statt einfuegen.\n"
"- Nur reines JSON, keine Prosa, keine Code-Fences."
)
def _doc_hash(doc_text: str) -> str:
return hashlib.md5(doc_text.encode("utf-8")).hexdigest()[:12]
_CACHE: dict[str, dict] = {}
_CACHE_MAX = 500
def _cache_get(key: str) -> dict | None:
return _CACHE.get(key)
def _cache_put(key: str, val: dict) -> None:
if len(_CACHE) >= _CACHE_MAX:
# Drop oldest 50 entries
for k in list(_CACHE.keys())[:50]:
_CACHE.pop(k, None)
_CACHE[key] = val
async def _call_ollama(prompt: str) -> str:
base = os.getenv("OLLAMA_URL", "http://host.docker.internal:11434")
model = os.getenv("MC_SOLUTION_MODEL",
os.getenv("CMP_LLM_MODEL", "qwen3:30b-a3b"))
payload = {
"model": model, "stream": False, "format": "json",
"messages": [
{"role": "system", "content": _SYSTEM_PROMPT},
{"role": "user", "content": prompt},
],
"options": {"temperature": 0.1, "num_predict": 600},
}
try:
async with httpx.AsyncClient(timeout=90.0) as client:
resp = await client.post(f"{base.rstrip('/')}/api/chat", json=payload)
resp.raise_for_status()
return (resp.json().get("message") or {}).get("content", "")
except Exception as e:
logger.warning("Qwen MC-solution failed: %s", e)
return ""
async def _call_ovh(prompt: str) -> str:
base = os.getenv("OVH_LLM_URL", "").strip()
key = os.getenv("OVH_LLM_KEY", "").strip()
model = os.getenv("OVH_LLM_MODEL", "").strip()
if not base or not model:
return ""
headers = {"Content-Type": "application/json"}
if key:
headers["Authorization"] = f"Bearer {key}"
payload = {
"model": model, "temperature": 0.1, "max_tokens": 600,
"messages": [
{"role": "system", "content": _SYSTEM_PROMPT},
{"role": "user", "content": prompt},
],
"response_format": {"type": "json_object"},
}
try:
async with httpx.AsyncClient(timeout=45.0) as client:
resp = await client.post(
f"{base.rstrip('/')}/v1/chat/completions",
json=payload, headers=headers,
)
resp.raise_for_status()
choice = (resp.json().get("choices") or [{}])[0]
return (choice.get("message") or {}).get("content", "") or ""
except Exception as e:
logger.warning("OVH MC-solution failed: %s", e)
return ""
def _parse(content: str) -> dict | None:
if not content:
return None
txt = content.strip()
if txt.startswith("```"):
txt = "\n".join(txt.split("\n")[1:-1])
a, b = txt.find("{"), txt.rfind("}")
if 0 <= a < b:
try:
obj = json.loads(txt[a:b + 1])
if isinstance(obj, dict) and obj.get("solution_text"):
return {
"solution_text": str(obj["solution_text"])[:1200],
"anchor_hint": str(obj.get("anchor_hint", ""))[:200],
"effort_min": str(obj.get("effort_min", "mittel"))[:20],
}
except Exception:
return None
return None
async def generate_solution(
mc: dict,
doc_text: str,
doc_type: str,
) -> dict | None:
"""Generates a solution dict for a single FAIL-MC.
mc must contain: label, hint, severity. Returns
{solution_text, anchor_hint, effort_min} or None.
"""
if not mc or not doc_text:
return None
mc_id = str(mc.get("id") or mc.get("label", ""))[:80]
cache_key = f"{mc_id}:{doc_type}:{_doc_hash(doc_text)}"
cached = _cache_get(cache_key)
if cached:
return cached
excerpt = doc_text[:3500]
prompt = (
f"FAIL-MC: {mc.get('label', '')}\n"
f"Severity: {mc.get('severity', 'MEDIUM')}\n"
f"Aktueller Hint: {mc.get('hint', '')[:300]}\n\n"
f"Dokument-Typ: {doc_type}\n"
f"Dokument-Auszug:\n---\n{excerpt}\n---\n\n"
"Liefere die Loesung als JSON."
)
content = await _call_ollama(prompt)
parsed = _parse(content)
if not parsed:
content = await _call_ovh(prompt)
parsed = _parse(content)
if parsed:
_cache_put(cache_key, parsed)
return parsed
async def generate_solutions_for_fails(
failed_mcs: Iterable[dict],
doc_text: str,
doc_type: str,
limit: int = 5,
) -> list[dict]:
"""Returns a list of {mc_label, severity, solution_text, anchor_hint,
effort_min} for the top-N HIGH/CRITICAL fails. Skips MEDIUM/LOW
to keep latency bounded."""
sev_order = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3}
high_fails = [m for m in (failed_mcs or [])
if (m.get("severity") or "").upper() in ("CRITICAL", "HIGH")]
high_fails.sort(key=lambda m: sev_order.get(
(m.get("severity") or "").upper(), 3))
high_fails = high_fails[:limit]
out: list[dict] = []
for mc in high_fails:
sol = await generate_solution(mc, doc_text, doc_type)
if not sol:
continue
out.append({
"mc_label": mc.get("label", "")[:200],
"severity": mc.get("severity", "MEDIUM"),
"solution_text": sol["solution_text"],
"anchor_hint": sol["anchor_hint"],
"effort_min": sol["effort_min"],
})
return out
def build_solutions_block_html(solutions: list[dict]) -> str:
"""Renders the LLM-generated solutions as a Mail-Block."""
if not solutions:
return ""
items: list[str] = []
for s in solutions:
sev_color = "#dc2626" if s["severity"].upper() == "CRITICAL" else "#d97706"
items.append(
f'<li style="margin-bottom:12px;font-size:11px;line-height:1.5">'
f'<div style="font-weight:600;color:{sev_color}">'
f'[{s["severity"]}] {s["mc_label"]}</div>'
f'<div style="background:#fff;padding:8px 10px;border:1px solid '
f'#cbd5e1;border-radius:4px;margin-top:4px;color:#1e293b;'
f'white-space:pre-wrap">{s["solution_text"]}</div>'
f'<div style="font-size:10px;color:#64748b;margin-top:3px">'
f'<strong>Anchor:</strong> {s["anchor_hint"] or ""} '
f'&nbsp;·&nbsp; <strong>Aufwand:</strong> {s["effort_min"]}'
f'</div></li>'
)
return (
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
'max-width:760px;margin:0 auto 16px;padding:14px 18px;'
'background:#f0f9ff;border:1px solid #bfdbfe;border-radius:8px">'
'<div style="font-size:11px;color:#1e40af;text-transform:uppercase;'
'letter-spacing:1.2px;margin-bottom:4px;font-weight:600">'
'Loesungs-Vorschlaege (KI-generiert)</div>'
f'<h3 style="margin:0 0 6px;font-size:14px;color:#1e293b">'
f'{len(solutions)} konkrete Einfuege-Empfehlung'
f'{"en" if len(solutions) != 1 else ""} '
'fuer die kritischen Findings</h3>'
'<p style="margin:0 0 10px;font-size:11px;color:#475569;line-height:1.5">'
'Folgende Absaetze koennen Sie direkt uebernehmen — Platzhalter '
'[Ihr Firmenname] / [Ihre Adresse] sind zu ersetzen. Inhaltliche '
'Korrektheit ist mit DSB / Rechtsabteilung zu pruefen.</p>'
'<ul style="margin:0 0 0 18px;padding:0">'
+ "".join(items) +
'</ul>'
'<p style="margin:8px 0 0;font-size:10px;color:#94a3b8;'
'font-style:italic">Generiert via Qwen3-30b lokal (Fallback: '
'OVH 120B). Vorschlaege sind kein Rechts-Beratung.</p>'
'</div>'
)
@@ -293,6 +293,59 @@ _MC_ALIAS_FALLBACK = {
}
# P72 — kompatible scope_doc_type-Werte pro operativem doc_type.
# 'other' / NULL / 'process' bleiben immer drin (Backfill ist Heuristik v1
# und nicht stark genug fuer hartes Filtern).
_SCOPE_COMPATIBLE: dict[str, set[str]] = {
"dse": {"dse", "jc", "process", "tom", "accounting"},
"cookie": {"cookie_richtlinie", "banner_implementation",
"cmp_audit", "dse"},
"cookie_policy": {"cookie_richtlinie", "banner_implementation",
"cmp_audit", "dse"},
"impressum": {"impressum", "agb"},
"agb": {"agb", "widerruf", "impressum"},
"nutzungsbedingungen": {"agb", "widerruf", "impressum"},
"widerruf": {"widerruf", "agb"},
"avv": {"avv", "tom", "jc", "process"},
"tom": {"tom", "avv", "process"},
"loeschkonzept": {"process", "dse", "accounting"},
"dsfa": {"process", "tom", "dse"},
"social_media": {"jc", "dse"},
"dsa": {"dse", "impressum"},
"legal_notice": {"impressum", "agb"},
"lizenzhinweise": {"agb", "impressum"},
}
_PERMISSIVE_SCOPES = {"other", "process", None, "", "null"}
def _filter_by_canonical_scope(
controls: list[dict],
doc_type: str,
) -> list[dict]:
"""P72 — wirft MCs raus, deren canonical scope_doc_type explizit auf
einen INKOMPATIBLEN Doc-Type zeigt. 'other'/NULL/'process' bleiben
drin (Backfill v1 noch zu unsicher).
"""
compatible = _SCOPE_COMPATIBLE.get(doc_type)
if not compatible:
return controls
kept: list[dict] = []
dropped = 0
for c in controls:
scope = c.get("canonical_scope")
scope_norm = (scope or "").strip().lower() or None
if scope_norm in _PERMISSIVE_SCOPES or scope_norm in compatible:
kept.append(c)
else:
dropped += 1
if dropped:
logger.info(
"P72 scope-filter: %d/%d MCs out-of-scope fuer doc_type=%s",
dropped, len(controls), doc_type,
)
return kept
def _load_text_only_ids(
doc_type: str | None = None,
business_scope: set[str] | None = None,
@@ -372,11 +425,19 @@ async def _load_controls(doc_type: str, db_url: str, limit: int,
return []
try:
query = """SELECT id, control_id, title, regulation, article,
check_question, pass_criteria, fail_criteria, severity
FROM compliance.doc_check_controls
WHERE doc_type = $1
ORDER BY severity DESC, title"""
# P72: LEFT JOIN canonical_controls.scope_doc_type um scope-Info
# mitzuziehen. Wenn ein MC explizit fuer einen anderen Doc-Type
# klassifiziert ist (z.B. 'tom' statt 'dse'), wird er unten
# gefiltert. 'other' / NULL bleiben drin (Backfill noch nicht stark).
query = """SELECT dc.id, dc.control_id, dc.title, dc.regulation,
dc.article, dc.check_question, dc.pass_criteria,
dc.fail_criteria, dc.severity,
cc.scope_doc_type AS canonical_scope
FROM compliance.doc_check_controls dc
LEFT JOIN compliance.canonical_controls cc
ON cc.id = dc.control_uuid
WHERE dc.doc_type = $1
ORDER BY dc.severity DESC, dc.title"""
if limit > 0:
query += f" LIMIT {limit}"
@@ -387,6 +448,12 @@ async def _load_controls(doc_type: str, db_url: str, limit: int,
rows = await conn.fetch(query, fallback)
controls = [dict(r) for r in rows]
# P72: Scope-Filter — werfe MCs raus, deren canonical scope_doc_type
# explizit auf einen anderen Doc-Type zeigt. Konservativ:
# other/NULL/process bleiben drin (zu unsichere Klassifikation).
controls = _filter_by_canonical_scope(controls, doc_type)
text_only = _load_text_only_ids(doc_type, business_scope)
if text_only:
before = len(controls)
@@ -0,0 +1,182 @@
"""
P84 Diff-Mode pro Mail.
Vergleicht den aktuellen Lauf mit dem letzten Snapshot derselben Site:
"Seit letztem Lauf 3 Findings weg, 1 neues." USP keiner der grossen
Anbieter (Borlabs, OneTrust, Cookiebot, Usercentrics) hat das.
Wird in der Mail-Composition nach dem GF-1-Pager gerendert (klein,
neutral). Wenn kein vorheriger Lauf existiert: skip silently.
Heuristik: Extrahiert Finding-Labels aus banner_result.phases[].findings
und (wenn vorhanden) scorecard.failed. Vergleicht set-basiert auf
normalisiertem Label.
"""
from __future__ import annotations
import logging
import re
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import text
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
def _norm_label(s: str) -> str:
s = (s or "").lower().strip()
s = re.sub(r"\s+", " ", s)
s = re.sub(r"[^\w\s äöüß]", "", s)
return s[:200]
def _extract_finding_labels(
banner_result: dict | None,
scorecard: dict | None = None,
) -> set[str]:
out: set[str] = set()
if isinstance(banner_result, dict):
for ph in (banner_result.get("phases") or {}).values():
if not isinstance(ph, dict):
continue
for f in (ph.get("findings") or []):
if isinstance(f, dict):
lbl = f.get("label") or f.get("title") or f.get("check") or ""
if lbl:
out.add(_norm_label(lbl))
if isinstance(scorecard, dict):
for ent in (scorecard.get("failed") or scorecard.get("items") or []):
if isinstance(ent, dict):
lbl = ent.get("label") or ent.get("title") or ""
if lbl:
out.add(_norm_label(lbl))
return out
def _previous_snapshot(db: Session, site_domain: str,
exclude_check_id: str) -> dict | None:
"""Returns the most recent snapshot for the same site (excluding the
current one)."""
row = db.execute(text(
"""
SELECT check_id, banner_result, created_at
FROM compliance.compliance_check_snapshots
WHERE site_domain = :dom AND check_id != :ex
ORDER BY created_at DESC LIMIT 1
"""
), {"dom": site_domain, "ex": exclude_check_id}).fetchone()
if not row:
return None
return {
"check_id": row[0],
"banner_result": row[1] or {},
"created_at": row[2],
}
def compute_diff(
db: Session,
current_check_id: str,
site_domain: str,
banner_result: dict | None,
scorecard: dict | None = None,
) -> dict | None:
"""Returns {prev_check_id, prev_at, added, removed, unchanged_count}
or None if there is no previous snapshot."""
prev = _previous_snapshot(db, site_domain, current_check_id)
if not prev:
return None
curr_set = _extract_finding_labels(banner_result, scorecard)
prev_set = _extract_finding_labels(prev["banner_result"], None)
if not curr_set and not prev_set:
return None
return {
"prev_check_id": prev["check_id"],
"prev_at": prev["created_at"],
"added": sorted(curr_set - prev_set)[:20],
"removed": sorted(prev_set - curr_set)[:20],
"unchanged_count": len(curr_set & prev_set),
}
def _fmt_age(when: Any) -> str:
if not isinstance(when, datetime):
return "frueher"
if when.tzinfo is None:
when = when.replace(tzinfo=timezone.utc)
delta = datetime.now(timezone.utc) - when
days = delta.days
if days <= 0:
hours = delta.seconds // 3600
return f"vor {hours}h" if hours else "soeben"
if days == 1:
return "vor 1 Tag"
if days < 14:
return f"vor {days} Tagen"
weeks = days // 7
return f"vor {weeks} Wochen"
def build_diff_block_html(diff: dict) -> str:
if not diff:
return ""
added = diff.get("added") or []
removed = diff.get("removed") or []
if not added and not removed:
return (
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
'max-width:760px;margin:0 auto 12px;padding:10px 14px;'
'background:#f1f5f9;border:1px solid #cbd5e1;border-radius:6px;'
'font-size:11px;color:#475569">'
f'<strong>Vergleich zum letzten Lauf '
f'({_fmt_age(diff.get("prev_at"))}):</strong> keine Veraenderungen '
f'in den erkannten Findings ({diff.get("unchanged_count",0)} '
'identisch geblieben).'
'</div>'
)
items: list[str] = []
if removed:
items.append(
'<div style="font-size:11px;color:#166534;margin-bottom:4px">'
f'<strong>{len(removed)} Finding{"s" if len(removed) != 1 else ""} '
'nicht mehr vorhanden:</strong></div>'
'<ul style="margin:0 0 8px 18px;padding:0">'
+ "".join(
f'<li style="font-size:11px;color:#166534;margin-bottom:2px">'
f'{x}</li>'
for x in removed[:6]
) + '</ul>'
)
if added:
items.append(
'<div style="font-size:11px;color:#991b1b;margin-bottom:4px">'
f'<strong>{len(added)} neue{"s" if len(added) == 1 else ""} '
f'Finding{"s" if len(added) != 1 else ""}:</strong></div>'
'<ul style="margin:0 0 8px 18px;padding:0">'
+ "".join(
f'<li style="font-size:11px;color:#991b1b;margin-bottom:2px">'
f'! {x}</li>'
for x in added[:6]
) + '</ul>'
)
return (
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
'max-width:760px;margin:0 auto 12px;padding:12px 16px;'
'background:#fffbeb;border:1px solid #fde68a;border-radius:6px">'
'<div style="font-size:11px;color:#92400e;text-transform:uppercase;'
'letter-spacing:1.2px;margin-bottom:6px;font-weight:600">'
f'Was hat sich seit dem letzten Lauf veraendert '
f'({_fmt_age(diff.get("prev_at"))})'
'</div>'
+ "".join(items) +
f'<div style="font-size:10px;color:#94a3b8;margin-top:4px">'
f'{diff.get("unchanged_count",0)} weitere Findings unveraendert '
'— vollstaendige Liste weiter unten.</div>'
'</div>'
)
@@ -236,27 +236,47 @@ def _extract_cookiebot(d: dict) -> list[dict]:
# ── Usercentrics ────────────────────────────────────────────────────
def _extract_usercentrics(d: dict) -> list[dict]:
"""Usercentrics 'services' / 'dataProcessingServices' shape."""
"""Usercentrics shape — legacy 'services' and modern 'consentTemplates'.
P49: modern Usercentrics-Settings (e.g. Mercedes 2026) keep vendors
in `consentTemplates[]` with name inside `_meta.name` and category
in `categorySlug`. Legacy format used `services[]` / `dataProcessingServices[]`
with name as direct field.
"""
out: list[dict] = []
services = (d.get("services") or d.get("dataProcessingServices")
or (d.get("settings") or {}).get("services") or [])
# P49: fall through to consentTemplates if legacy keys are empty.
# Filter out hidden/deactivated entries (UC backend toggles).
if not services:
services = [t for t in d.get("consentTemplates") or []
if not t.get("isHidden") and not t.get("isDeactivated")]
for s in services:
name = s.get("name") or s.get("dataProcessor") or ""
name = (s.get("name") or s.get("dataProcessor")
or (s.get("_meta") or {}).get("name") or "")
name = name.strip()
if not name:
continue
max_age = s.get("cookieMaxAgeSeconds")
persistence = ""
if isinstance(max_age, int) and max_age > 0:
persistence = f"{max_age // 86400} Tage"
# P49: modern format stores company / urls in _meta
meta = s.get("_meta") or {}
out.append({
"name": name,
"country": (s.get("processingCompanyCountry")
or s.get("country") or "").strip(),
"purpose": _clean(s.get("dataPurpose") or s.get("description")),
"category": (s.get("categorySlug") or s.get("category") or "").strip(),
"opt_out_url": (s.get("optOutUrl") or "").strip(),
or s.get("country")
or meta.get("country") or "").strip(),
"purpose": _clean(s.get("dataPurpose") or s.get("description")
or meta.get("description") or ""),
"category": (s.get("categorySlug") or s.get("category")
or meta.get("categorySlug") or "").strip(),
"opt_out_url": (s.get("optOutUrl")
or meta.get("optOutUrl") or "").strip(),
"privacy_policy_url": (s.get("policyOfProcessorUrl")
or s.get("urls", {}).get("privacyPolicy", "")
or meta.get("policyOfProcessorUrl")
or "").strip(),
"persistence": persistence or _clean(s.get("retentionPeriodDescription")),
"cookies": [],
@@ -49,13 +49,19 @@ _SYSTEM_PROMPT = (
async def extract_vendors_via_llm(
cookie_text: str,
max_text_chars: int = 12000,
max_text_chars: int = 50000,
) -> list[dict]:
"""Run the Qwen → OVH cascade. Returns vendor records (possibly empty)."""
"""Run the Qwen → OVH cascade. Returns vendor records (possibly empty).
max_text_chars: VW-Cookie-Richtlinie hat ~60k chars mit ~100 Cookies in
der Tabelle. Bei 12k waren wir auf die ersten ~5 Cookies begrenzt und
haben nur 1 Vendor extrahiert. 50k deckt VW/BMW/Mercedes komplett ab
und passt in Qwen3-30b-a3b (128k Context) sowie OVH 120B.
"""
if not cookie_text or len(cookie_text) < 500:
return []
excerpt = cookie_text[:max_text_chars]
user_prompt = f"Cookie-Richtlinie-Text (gekuerzt):\n\n{excerpt}"
user_prompt = f"Cookie-Richtlinie-Text:\n\n{excerpt}"
# Stage 1: local Qwen
content = await _call_ollama(user_prompt)
@@ -82,10 +88,13 @@ async def _call_ollama(user_prompt: str) -> str:
{"role": "user", "content": user_prompt},
],
"stream": False, "format": "json",
"options": {"temperature": 0.05, "num_predict": 6000},
# 16k tokens fuer ~80 Vendors mit je 30 Cookies. War vorher 6k →
# output wurde mittendrin abgeschnitten, JSON unparseable → 0 Vendors.
"options": {"temperature": 0.05, "num_predict": 16000},
}
try:
async with httpx.AsyncClient(timeout=120.0) as client:
# Qwen 30b braucht fuer 16k output ~4-6min auf M4 Pro.
async with httpx.AsyncClient(timeout=420.0) as client:
resp = await client.post(f"{base.rstrip('/')}/api/chat", json=payload)
resp.raise_for_status()
return (resp.json().get("message") or {}).get("content", "")
@@ -109,7 +118,7 @@ async def _call_ovh(user_prompt: str) -> str:
{"role": "system", "content": _SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
"temperature": 0.05, "max_tokens": 6000,
"temperature": 0.05, "max_tokens": 16000,
"response_format": {"type": "json_object"},
}
try:
@@ -0,0 +1,181 @@
"""
P61 "Untergeschobene Cookies"-Erkennung.
Wenn eine Site einen Vendor einbindet (z.B. "Google Tag Manager"), kommen
oft AUTOMATISCH weitere Cookies/Vendors mit, die der Marketing-Manager
nicht aktiv ausgewaehlt hat (DoubleClick-Werbe-IDs ueber GTM, Facebook-
Conversion-API ueber Meta-Pixel, Hotjar-Recordings ueber HubSpot etc.).
Dieses Modul mappt:
Primary-Vendor (eingebunden) -> Implicit-Cookies/Vendors (mitgekommen)
Mit Quellen-Doku aus offiziellen Anbieter-Pages.
"""
from __future__ import annotations
from typing import TypedDict
class ImplicitItem(TypedDict, total=False):
name: str
type: str # "cookie" | "vendor"
category: str # essential/functional/statistics/marketing
why: str # warum kommt das mit
source_url: str # Anbieter-Doku
# Primary-Vendor (lowercase, substring-match) -> Liste implizit mitgeladener Items
VENDOR_PACKAGE_COOKIES: dict[str, list[ImplicitItem]] = {
# Google Tag Manager — laedt typischerweise Google Analytics + Ads
"google tag manager": [
{"name": "_ga", "type": "cookie", "category": "statistics",
"why": "GTM laedt Google Analytics by default mit, sobald ein "
"GA4-Tag konfiguriert ist.",
"source_url": "https://support.google.com/tagmanager/answer/9442095"},
{"name": "_gid", "type": "cookie", "category": "statistics",
"why": "Google Analytics Session-ID, automatisch mit GA.",
"source_url": "https://support.google.com/analytics/answer/11397207"},
{"name": "_gcl_au", "type": "cookie", "category": "marketing",
"why": "Google Ads Conversion-Linker — kommt mit jedem GTM-Container "
"der ein Conversion-Tag enthaelt (z.B. Floodlight, Ads).",
"source_url": "https://support.google.com/google-ads/answer/7521212"},
{"name": "Google Ads", "type": "vendor", "category": "marketing",
"why": "GTM ist Google-Infrastruktur — Google sieht alle Requests "
"ueber GTM (auch wenn nur Analytics konfiguriert ist).",
"source_url": "https://support.google.com/tagmanager/answer/9323295"},
],
# Google Analytics — implizit oft DoubleClick / Ads-Personalization
"google analytics": [
{"name": "_gcl_au", "type": "cookie", "category": "marketing",
"why": "GA4 mit aktivierter Google-Signals (Werbeberichte) setzt "
"Conversion-Linker — auch ohne Ads-Konfiguration.",
"source_url": "https://support.google.com/analytics/answer/9445345"},
{"name": "DSID", "type": "cookie", "category": "marketing",
"why": "DoubleClick-Cookie ueber doubleclick.net — laeuft mit "
"GA4 + Google-Signals automatisch.",
"source_url": "https://policies.google.com/technologies/cookies"},
{"name": "Google Marketing Platform", "type": "vendor", "category": "marketing",
"why": "Mit Google-Signals fliessen aggregierte Daten in Googles "
"Werbeprofil-Datenbank.",
"source_url": "https://policies.google.com/technologies/cookies"},
],
# Meta-Pixel — kommt typischerweise mit Facebook Login + Conversion-API
"meta pixel": [
{"name": "_fbc", "type": "cookie", "category": "marketing",
"why": "Facebook Click-ID — wird vom Meta-Pixel beim ersten Besuch "
"via Werbe-Klick gesetzt.",
"source_url": "https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/fbp-and-fbc"},
{"name": "fr", "type": "cookie", "category": "marketing",
"why": "Facebook Cross-Site-Tracking — wird ueber facebook.com "
"Subdomain gesetzt, auch ohne aktiven FB-Login.",
"source_url": "https://www.facebook.com/policies/cookies/"},
{"name": "Facebook Conversion API", "type": "vendor", "category": "marketing",
"why": "Server-zu-Server Tracking ergaenzt das Browser-Pixel — wird "
"oft via 'Erweiterte Matching'-Setting automatisch aktiviert.",
"source_url": "https://developers.facebook.com/docs/marketing-api/conversions-api/"},
],
"facebook pixel": [
# Alias-Eintrag — verweist auf gleiche implicits
{"name": "_fbc", "type": "cookie", "category": "marketing",
"why": "siehe Meta-Pixel-Eintrag (Aliase).",
"source_url": "https://www.facebook.com/policies/cookies/"},
{"name": "fr", "type": "cookie", "category": "marketing",
"why": "siehe Meta-Pixel-Eintrag (Aliase).",
"source_url": "https://www.facebook.com/policies/cookies/"},
],
# HubSpot — mit jedem Embed kommt Tracking + Chat + Forms
"hubspot": [
{"name": "__hstc", "type": "cookie", "category": "marketing",
"why": "HubSpot-Analytics-Cookie wird beim ersten HubSpot-Tag "
"automatisch gesetzt.",
"source_url": "https://knowledge.hubspot.com/de/privacy-and-consent/what-cookies-does-hubspot-set-in-a-visitor-s-browser"},
{"name": "hubspotutk", "type": "cookie", "category": "marketing",
"why": "User-Token zur seitenuebergreifenden Identifikation.",
"source_url": "https://knowledge.hubspot.com/de/privacy-and-consent/what-cookies-does-hubspot-set-in-a-visitor-s-browser"},
{"name": "HubSpot Chat (Drift / Conversations)", "type": "vendor",
"category": "functional",
"why": "HubSpot CMS aktiviert oft den Chat-Widget by default.",
"source_url": "https://www.hubspot.com/data-privacy/cookies"},
],
# Akamai (CDN/Security) — Bot-Manager-Cookies sind essential, aber Akamai
# selbst hat Web-Performance-Cookies die als statistics gelten koennen.
"akamai": [
{"name": "AKA_A2", "type": "cookie", "category": "functional",
"why": "Akamai Adaptive-Acceleration Performance-Cookie.",
"source_url": "https://techdocs.akamai.com/"},
],
# Adobe Analytics (Marketing Cloud) — laedt Audience-Manager-Cookies
"adobe analytics": [
{"name": "s_cc", "type": "cookie", "category": "statistics",
"why": "Adobe Analytics Session-Cookie.",
"source_url": "https://experienceleague.adobe.com/docs/analytics/implementation/vars/config-vars/cookies.html"},
{"name": "AAM_uuid", "type": "cookie", "category": "marketing",
"why": "Adobe Audience Manager — kommt mit Adobe Analytics-Tag wenn "
"Audience-Sharing aktiviert ist.",
"source_url": "https://experienceleague.adobe.com/docs/audience-manager.html"},
],
# LinkedIn Insight Tag — laedt LinkedIn + AdvertiserSync Cookies
"linkedin insight": [
{"name": "li_sugr", "type": "cookie", "category": "marketing",
"why": "LinkedIn-Browser-ID — wird vom Insight-Tag gesetzt.",
"source_url": "https://www.linkedin.com/legal/l/cookie-table"},
{"name": "AnalyticsSyncHistory", "type": "cookie", "category": "marketing",
"why": "LinkedIn-Cross-Domain-Tracking ueber Insight-Tag.",
"source_url": "https://www.linkedin.com/legal/l/cookie-table"},
],
}
def detect_implicit_cookies(
declared_vendors: list[str],
actual_cookies_set: list[str] | None = None,
) -> list[dict]:
"""Findet untergeschobene Cookies/Vendors.
Args:
declared_vendors: Liste der vom CMP/Banner deklarierten Vendor-Namen.
actual_cookies_set: Optional Cookie-Namen, die tatsaechlich gesetzt
wurden. Wenn gegeben, wird nur reportiert was nicht in der
declared-Liste UND tatsaechlich gesetzt ist.
Returns:
Liste Finding-Dicts mit:
primary_vendor, implicit (ImplicitItem), present_in_actual (bool)
"""
findings: list[dict] = []
actual_lower = {c.lower() for c in (actual_cookies_set or [])}
declared_lower = {v.lower() for v in declared_vendors}
for primary in declared_vendors:
plower = primary.lower()
implicits = []
for key, items in VENDOR_PACKAGE_COOKIES.items():
if key in plower:
implicits.extend(items)
for impl in implicits:
name_lower = impl["name"].lower()
# Skip if user has explicitly declared this implicit vendor
if impl["type"] == "vendor":
if any(name_lower in d for d in declared_lower):
continue
# If actuals provided: only report if cookie really set
present = True
if actual_cookies_set is not None and impl["type"] == "cookie":
present = impl["name"] in actual_cookies_set or any(
impl["name"].lower() in c.lower() for c in actual_cookies_set
)
if not present:
continue
findings.append({
"primary_vendor": primary,
"implicit": impl,
"present_in_actual": present,
})
return findings
@@ -0,0 +1,234 @@
"""
P42 Pattern smoke test for doc_checks (no DB required).
Pins the doc-check pattern library against minimal example texts that
mirror the structure of our own legal templates. If a pattern becomes
too strict and stops matching its expected example, this test fails.
Run with: pytest compliance/tests/test_doc_check_patterns.py -v
"""
from __future__ import annotations
import pytest
from compliance.services.doc_checks.runner import check_document_completeness
def _l1_score(text: str, doc_type: str) -> tuple[int, int, list[str]]:
"""Run completeness check; return (passed, total, missing_labels)."""
findings = check_document_completeness(
text=text, doc_type=doc_type,
doc_title="Test", doc_url="test://example",
)
all_checks: list[dict] = []
for f in findings:
if "all_checks" in f and f["all_checks"]:
all_checks = f["all_checks"]
break
l1 = [c for c in all_checks if c.get("level", 1) == 1]
missing = [c["label"] for c in l1 if not c.get("passed") and not c.get("skipped")]
passed = sum(1 for c in l1 if c.get("passed") and not c.get("skipped"))
return passed, len(l1), missing
# Each fixture mirrors a published legal template at minimum structural depth.
# The aim: every L1 mandatory field must be at least mentioned.
DSE_TEMPLATE = """
# Datenschutzerklaerung
## 1. Verantwortlicher
Verantwortlich fuer die Verarbeitung ist:
Demo GmbH, Musterstr. 1, 12345 Berlin, Deutschland
E-Mail: datenschutz@demo.de | Telefon: +49 30 123456
## 2. Datenschutzbeauftragter
Max Mustermann, dsb@demo.de
## 3. Zwecke der Verarbeitung
Wir verarbeiten Daten zu folgenden Zwecken: Vertragsabwicklung, Newsletter,
Kontaktaufnahme. Rechtsgrundlage Art. 6(1)(b) und (a) DSGVO.
## 4. Rechtsgrundlage
Art. 6(1)(b) DSGVO fuer Vertraege, Art. 6(1)(a) fuer Einwilligungen.
## 5. Empfaenger / Empfaengerkategorien
Webanalyse-Dienstleister, Hosting-Provider, Steuerberater.
## 6. Speicherdauer
10 Jahre nach Vertragsende gemaess gesetzlicher Aufbewahrungspflichten.
## 7. Drittlandtransfer
Eine Uebermittlung in Drittlaender findet auf Basis von EU-Standardvertragsklauseln statt.
## 8. Betroffenenrechte
Sie haben das Recht auf Auskunft (Art. 15), Berichtigung (Art. 16),
Loeschung (Art. 17), Einschraenkung (Art. 18), Datenuebertragbarkeit (Art. 20),
Widerspruch (Art. 21) und Beschwerde bei der Aufsichtsbehoerde (Art. 77).
## 9. Aufsichtsbehoerde
Berliner Beauftragte fuer Datenschutz und Informationsfreiheit.
## 10. Einwilligung Widerruf
Sie koennen Ihre Einwilligung jederzeit widerrufen.
"""
COOKIE_TEMPLATE = """
# Cookie-Richtlinie
## 1. Verantwortlicher
Demo GmbH, Musterstr. 1, 12345 Berlin. E-Mail: datenschutz@demo.de.
## 2. Was sind Cookies?
Cookies sind kleine Textdateien.
## 3. Rechtsgrundlage
§25 TDDDG / Art. 6(1)(a) DSGVO.
## 4. Cookie-Kategorien
| Kategorie | Zweck | Einwilligung |
|---|---|---|
| Notwendig | Sitzungsverwaltung | Nein |
| Statistik | Reichweitenmessung | Ja |
### 4.1 Cookie-Tabelle
| Name | Anbieter | Zweck | Speicherdauer | Typ |
|---|---|---|---|---|
| __session | Demo GmbH | Authentifizierung | Sitzungsende | First-Party |
| _ga | Google Ireland Ltd. | Webanalyse | 2 Jahre | Third-Party |
## 5. Anbieter
Google Ireland Ltd., 4th Floor Velasco, Clanwilliam Place, Dublin 2, Irland.
## 6. Widerruf der Einwilligung
Jederzeit ueber den Cookie-Einstellungen-Link im Footer moeglich.
## 7. Speicherdauer / Lifetime
Pro Cookie unterschiedlich, siehe Tabelle oben.
"""
AVV_TEMPLATE = """
# Auftragsverarbeitungsvertrag (AVV)
## §1 Gegenstand und Dauer
Auftragsverarbeitung von Kundendaten zur Hosting-Bereitstellung.
## §2 Art und Zweck
Speicherung, Backup, Verfuegbarkeitsmanagement.
## §3 Datenkategorien
Stammdaten, Bewegungsdaten, Logfiles.
## §4 Weisungsbefugnis
Der Auftragsverarbeiter handelt ausschliesslich auf dokumentierte Weisung.
## §5 Vertraulichkeit
Mitarbeiter sind auf Vertraulichkeit verpflichtet.
## §6 Technische Massnahmen (Art. 32)
Verschluesselung, Zugriffskontrolle, Logging.
## §7 Unterauftragnehmer
Liste in Anlage 2.
## §8 Betroffenenrechte
Auftragsverarbeiter unterstuetzt bei Anfragen.
## §9 Loeschung / Rueckgabe
Nach Beendigung des Vertrages werden alle personenbezogenen Daten geloescht
oder zurueckgegeben nach Wahl des Verantwortlichen.
## §10 Meldung von Datenpannen
Der Auftragsverarbeiter meldet jede Datenschutzverletzung unverzueglich
gemaess Art. 33(2) DSGVO innerhalb von 24 Stunden.
## §11 Audit-Recht
Verantwortlicher darf Audits durchfuehren.
"""
IMPRESSUM_TEMPLATE = """
# Impressum
## Anbieter
Demo GmbH
Musterstr. 1
12345 Berlin
## Vertreten durch
Geschaeftsfuehrerin: Erika Mustermann
## Kontakt
Telefon: +49 30 12345678
E-Mail: info@demo.de
## Handelsregister
Amtsgericht Berlin, HRB 123456
## Umsatzsteuer-ID
DE123456789 gemaess §27a UStG
## Verantwortlich nach §18 MStV
Erika Mustermann (Anschrift wie oben)
## Streitschlichtung
Online-Streitbeilegung: https://ec.europa.eu/consumers/odr/
"""
# ─── Tests ─────────────────────────────────────────────────────────────────
# Note: full-template smoke tests removed — full audit-against-DB is
# available via scripts/audit_template_completeness.py --strict and
# should be run pre-commit or in a DB-enabled CI job. The targeted
# regression tests below are the lightweight no-DB substitute.
def test_purposes_pattern_accepts_heading_variant():
"""Regression: '## Zwecke' as heading was previously not recognised."""
text = "## 3. Zwecke\nWir verarbeiten Daten zu Vertragsabwicklung und Newsletter."
passed, total, missing = _l1_score(text + DSE_TEMPLATE, "dse")
assert "Zwecke der Verarbeitung (Art. 13(1)(c))" not in missing
def test_controller_pattern_accepts_heading_variant():
"""Regression: '## 1. Verantwortlicher' as heading was previously not recognised."""
text = """# DSE
## 1. Verantwortlicher
Demo GmbH, Musterstr. 1, 12345 Berlin.
E-Mail: datenschutz@demo.de
DSB: dsb@demo.de
Zwecke der Verarbeitung: Vertragsabwicklung.
Rechtsgrundlage: Art. 6(1)(b) DSGVO.
Empfaenger: Hosting-Provider.
Speicherdauer: 10 Jahre.
Drittlandtransfer findet nicht statt.
Betroffenenrechte nach Art. 15-21 DSGVO.
Beschwerde bei Aufsichtsbehoerde nach Art. 77.
Sie koennen die Einwilligung jederzeit widerrufen.
"""
passed, total, missing = _l1_score(text, "dse")
assert "Verantwortlicher (Art. 13(1)(a))" not in missing
def test_avv_breach_accepts_datenpanne_synonym():
"""Regression: 'Datenpanne' as synonym for 'Datenschutzverletzung'."""
text = AVV_TEMPLATE.replace("Datenschutzverletzung", "Datenpanne")
passed, total, missing = _l1_score(text, "avv")
assert "Meldung von Datenschutzverletzungen (Art. 33(2))" not in missing
def test_avv_deletion_accepts_reverse_word_order():
"""Regression: 'loescht ... nach Beendigung' (reverse) was previously not matched."""
text = AVV_TEMPLATE.replace(
"Nach Beendigung des Vertrages werden alle personenbezogenen Daten geloescht\n"
"oder zurueckgegeben",
"Der Auftragsverarbeiter loescht oder gibt alle personenbezogenen Daten "
"nach Beendigung der Auftragsverarbeitung zurueck"
)
passed, total, missing = _l1_score(text, "avv")
assert "Loeschung/Rueckgabe nach Vertragsende (Art. 28(3)(g))" not in missing
@@ -0,0 +1,278 @@
-- Migration 123: Geschaeftsordnung Geschaeftsfuehrung Template
-- Internal Rules of Procedure for Management (GO-GF)
-- Erstellt nach 3-fach-Check Methode (Absatz-fuer-Absatz Review, 2026-05-19)
-- Skalierbar fuer 1-Mann-Startups bis Multi-GF-Strukturen (2, 3+ GF)
-- Optionale Bloecke: HAS_SHA, HAS_CEO_DESIGNATION, HAS_RESEARCH_FOCUS, HAS_ACADEMIC_ROLES, HAS_RESSORT_3
INSERT INTO compliance_legal_templates (
id, tenant_id, document_type, title, description, content,
placeholders, language, jurisdiction,
license_id, license_name, source_name,
attribution_required, is_complete_document, version, status,
created_at, updated_at
) SELECT
gen_random_uuid(),
'9282a473-5c95-4b3a-bf78-0ecc0ec71d3e',
'geschaeftsordnung_gf',
'Geschäftsordnung für die Geschäftsführung (GO-GF)',
'Interne Geschäftsordnung der Geschäftsführung einer deutschen GmbH/UG. Regelt Ressortverteilung, Entscheidungsbefugnisse, Schwellenwerte, Informationspflichten, Interessenkonflikte und Dokumentation. Skalierbar von 1-Personen-GF bis Multi-GF-Strukturen (2, 3+). Mit optionalen Klauseln für SHA-Bindung, CEO-Stichentscheid, Forschungs- und akademische Nebentätigkeiten. Konform §§ 35-43 GmbHG.',
$template$
# Geschäftsordnung für die Geschäftsführung (GO-GF)
**{{COMPANY_NAME}}**
---
## Dokumentenkontrolle
| Feld | Wert |
|---|---|
| Dokumenttitel | Geschäftsordnung für die Geschäftsführung (GO-GF) |
| Version | {{DOCUMENT_VERSION}} |
| Gültig ab | {{EFFECTIVE_DATE}} |
| Verabschiedet durch | Gesellschafterbeschluss vom {{RESOLUTION_DATE}} |
| Nächste Überprüfung | {{NEXT_REVIEW_DATE}} |
| Verantwortlich | Geschäftsführung |
---
## § 1 Zweck, Geltungsbereich und Rangfolge
(1) Diese Geschäftsordnung regelt die interne Zusammenarbeit der Geschäftsführer (GF") der {{COMPANY_NAME}}, {{#IF IS_MULTI_GF}}die Ressortverteilung, {{/IF}}Entscheidungsprozesse und Informationspflichten.
(2) Sie konkretisiert die Vorgaben des GmbHG, der Satzung{{#IF HAS_SHA}} und des Shareholders' Agreement (SHA){{/IF}}. Es gilt folgende Rangfolge: GmbHG > Satzung{{#IF HAS_SHA}} > SHA{{/IF}} > diese Geschäftsordnung. Bei Konflikten gehen vorrangige Regelungen vor.
(3) Die Geschäftsordnung gilt ausschließlich im Innenverhältnis der Geschäftsführer und Gesellschafter und entfaltet keine Außenwirkung.
(4) Die gesetzlichen Pflichten der Geschäftsführer gemäß §§ 35-43 GmbHG, insbesondere die Sorgfaltspflicht eines ordentlichen Geschäftsmanns (§ 43 Abs. 1 GmbHG), bleiben unberührt.
## § 2 Geschäftsführung und Vertretung
(1) Die Gesellschaft hat {{NUM_GF_TEXT}} Geschäftsführer ({{NUM_GF}}).
(2) Die Vertretung der Gesellschaft nach außen erfolgt gemäß Satzung in {{VERTRETUNGSART}}.
{{#IF IS_SINGLE_GF}}
(3) Der Geschäftsführer trägt die gesetzliche Gesamtverantwortung für die Geschäftsführung gemäß §§ 35 ff. GmbHG.
{{/IF}}
{{#IF IS_MULTI_GF}}
(3) Alle Geschäftsführer sind im Innenverhältnis gleichberechtigt und tragen die gesetzliche Gesamtverantwortung gemäß §§ 35 ff. GmbHG gesamtschuldnerisch.
{{/IF}}
(4) {{#IF HAS_CEO_DESIGNATION}}Aus dem Kreis der Geschäftsführer wird {{CEO_NAME}} als Sprecher der Geschäftsführung (CEO) benannt. Dem Sprecher kommt bei ressortübergreifenden Streitfragen ein Stichentscheid zu, soweit Satzung{{#IF HAS_SHA}} und SHA{{/IF}} dem nicht entgegenstehen.{{/IF}}{{#IF NOT HAS_CEO_DESIGNATION}}Es gibt keinen Sprecher mit Stichentscheid. Bei Uneinigkeit gilt das Eskalationsverfahren nach § 6.{{/IF}}
{{#IF IS_MULTI_GF}}
(5) Interne Ressorts dienen der organisatorischen Aufgabenverteilung. Sie haben keine Außenwirkung und beschränken die Gesamtverantwortung nicht.
{{/IF}}
## § 3 Grundsätze der Leitung
(1) Die Geschäftsführung erfolgt im besten Interesse der Gesellschaft und ihrer Gesellschafter unter Beachtung der gesetzlichen, satzungsmäßigen{{#IF HAS_SHA}} und SHA-{{/IF}}Vorgaben.
(2) Die Geschäftsführer handeln mit der Sorgfalt eines ordentlichen Geschäftsmanns (§ 43 Abs. 1 GmbHG) und arbeiten {{#IF IS_MULTI_GF}}untereinander {{/IF}}loyal, transparent und pflichtgemäß.
{{#IF IS_MULTI_GF}}
(3) Ressorts strukturieren die operative Arbeit, ersetzen jedoch nicht die gemeinsame Verantwortung für das Gesamtunternehmen.
{{/IF}}
(4) Entscheidungen müssen Satzung{{#IF HAS_SHA}}, SHA und den dort geregelten Reserved Matters{{/IF}} entsprechen.
{{#IF IS_MULTI_GF}}
## § 4 Ressortstruktur
(1) **Allgemeines** Die Ressorts strukturieren die operative Arbeit{{#IF HAS_SHA}} und orientieren sich an Anlage A des SHA{{/IF}}. Ressorts sind ausschließlich interne Arbeitsbereiche.
(2) **Ressort {{RESSORT_1_NAME}}"** ({{RESSORT_1_GF}})
Zuständigkeiten:
{{RESSORT_1_AUFGABEN}}
(3) **Ressort {{RESSORT_2_NAME}}"** ({{RESSORT_2_GF}})
Zuständigkeiten:
{{RESSORT_2_AUFGABEN}}
{{#IF HAS_RESSORT_3}}
(4) **Ressort {{RESSORT_3_NAME}}"** ({{RESSORT_3_GF}})
Zuständigkeiten:
{{RESSORT_3_AUFGABEN}}
{{/IF}}
({{LAST_PARA_4}}) **Ressortübergreifende Themen** Themen, die mehrere Ressorts betreffen, werden gemeinsam entschieden (§ 6). Jeder GF prüft eigenverantwortlich, ob eine Maßnahme ressortübergreifend ist oder ein Reserved Matter darstellt; im Zweifel ist der Mit-GF unverzüglich einzubeziehen.{{#IF HAS_SHA}} Reserved Matters dürfen ausschließlich gemäß SHA beschlossen werden.{{/IF}}
{{/IF}}
## § {{P_INFO}} Informations- und Berichtspflichten
(1) Es finden regelmäßige Abstimmungen statt:
- {{MEETING_OPERATIVE_FREQ}} operative {{#IF IS_MULTI_GF}}GF-{{/IF}}Meetings
- {{MEETING_STRATEGIE_FREQ}} Strategie-Reviews
{{#IF HAS_SHA}}- quartalsweise Rollen- und Prioritäten-Reviews gemäß Anlage A SHA{{/IF}}
{{#IF IS_MULTI_GF}}
(2) Jeder GF informiert die übrigen GF unverzüglich über:
- wesentliche Entwicklungen im eigenen Ressort
- finanzielle oder operative Risiken
- technische oder sicherheitsrelevante Ereignisse
- rechtliche und compliance-relevante Themen
- Personal- und Organisations-Veränderungen
{{#IF HAS_RESEARCH_FOCUS}}- Forschungsfortschritt, Studien und wissenschaftliche Erkenntnisse
- Validitätsrisiken und methodische Herausforderungen
- Forschungs-Partnerschaften und Drittmittelprojekte{{/IF}}
(3) Die Information erfolgt zeitnah in geeigneter Form (E-Mail, Protokoll, Berichtsystem). Wesentliche Ereignisse sind innerhalb von 24 Stunden zu kommunizieren.
{{/IF}}
({{P_GESELLSCHAFTER}}) Die Geschäftsführung berichtet den Gesellschaftern{{#IF HAS_SHA}} gemäß SHA{{/IF}} insbesondere über:
- Finanzen (monatlicher Liquiditätsstatus, Quartalsabschluss, Jahresabschluss)
- Strategie und mittelfristige Planung
- Produkt{{#IF HAS_RESEARCH_FOCUS}} und Forschung{{/IF}}
- Risiken und Chancen (Risk Register, Risiko-Mitigationsmaßnahmen)
- Personal und Organisation
({{P_AUSSER}}) Außerordentliche Ereignisse (z. B. Liquiditätsrisiken, Datenschutzvorfälle, Rechtsstreitigkeiten, Cyber-Sicherheitsvorfälle, Ausfall einer Schlüsselperson) sind unverzüglich allen Gesellschaftern mitzuteilen.
## § {{P_ENT}} Entscheidungsbefugnisse
{{#IF IS_SINGLE_GF}}
(1) Entscheidungen werden vom Geschäftsführer im Rahmen der Schwellenwerte (§ {{P_FIN}}) und unter Beachtung der Reserved Matters{{#IF HAS_SHA}} (SHA){{/IF}} getroffen.
(2) Entscheidungen oberhalb der Schwellenwerte oder mit erheblicher strategischer Bedeutung erfordern einen Gesellschafterbeschluss.
{{/IF}}
{{#IF IS_MULTI_GF}}
(1) Entscheidungen innerhalb der Ressortgrenzen kann der zuständige GF allein treffen, sofern sie nicht von erheblicher Bedeutung sind.
(2) Erhebliche Bedeutung liegt insbesondere vor bei: finanzielle Auswirkung über {{ERHEBLICH_EUR}} EUR, strategische Auswirkung über das Ressort hinaus, rechtliche oder reputationelle Risiken.
(3) Bei ressortübergreifenden Entscheidungen wird {{#IF NUM_GF_IS_2}}einvernehmlich{{/IF}}{{#IF NUM_GF_GT_2}}durch einfache Mehrheit aller GF{{/IF}} entschieden. Gefasste Beschlüsse werden in Textform dokumentiert.
(4) **Eskalationsverfahren bei Uneinigkeit:**
a) Erneute Beratung innerhalb von {{ESKALATION_TAGE_INTERN}} Werktagen mit Vorlage von Entscheidungsgrundlagen.
b) {{#IF HAS_CEO_DESIGNATION}}Bei weiterhin bestehender Uneinigkeit: Stichentscheid des Sprechers der Geschäftsführung ({{CEO_NAME}}), sofern nicht Satzung{{#IF HAS_SHA}} oder SHA{{/IF}} entgegenstehen.{{/IF}}{{#IF NOT HAS_CEO_DESIGNATION}}Bei weiterhin bestehender Uneinigkeit: Anrufung der Gesellschafterversammlung innerhalb von {{ESKALATION_TAGE_GESELLSCHAFTER}} Werktagen.{{/IF}}
c) In dringenden Fällen (drohender Schaden) kann der ressortzuständige GF vorläufig entscheiden; die Mit-GF sind unverzüglich zu unterrichten.
(5) Alle Reserved Matters bedürfen eines Gesellschafterbeschlusses{{#IF HAS_SHA}} nach SHA{{/IF}}.
{{/IF}}
## § {{P_FIN}} Finanzen, Verträge und Budget
(1) Interne finanzielle Schwellenwerte (Einzelfall, ohne Mehrwertsteuer):
{{#IF IS_SINGLE_GF}}
- bis {{SCHWELLE_GESELLSCHAFTER_EUR}} EUR: Geschäftsführer im Rahmen des genehmigten Budgets
- über {{SCHWELLE_GESELLSCHAFTER_EUR}} EUR: Gesellschafterbeschluss{{#IF HAS_SHA}} gemäß SHA{{/IF}}
{{/IF}}
{{#IF IS_MULTI_GF}}
- bis {{SCHWELLE_EINZEL_EUR}} EUR: ressortzuständiger GF allein
- {{SCHWELLE_EINZEL_EUR_PLUS_1}}-{{SCHWELLE_GEMEINSAM_EUR}} EUR: {{#IF NUM_GF_IS_2}}beide GF gemeinsam{{/IF}}{{#IF NUM_GF_GT_2}}Mehrheitsbeschluss der GF{{/IF}}
- über {{SCHWELLE_GEMEINSAM_EUR}} EUR: Gesellschafterbeschluss{{#IF HAS_SHA}} gemäß SHA{{/IF}}
{{/IF}}
(2) Budgettreue ist verpflichtend. Abweichungen über {{BUDGET_ABWEICHUNG_PCT}} % vom genehmigten Jahresbudget (in einer Kostenstelle oder gesamt) bedürfen eines Gesellschafterbeschlusses{{#IF HAS_SHA}} (Reserved Matter){{/IF}}.
(3) Verträge mit Laufzeit über {{VERTRAG_LAUFZEIT_MONATE}} Monaten oder Gesamtwert über {{VERTRAG_WERT_EUR}} EUR bedürfen{{#IF IS_SINGLE_GF}} der Gegenzeichnung durch einen Gesellschafter (bzw. den Mehrheitsgesellschafter){{/IF}}{{#IF IS_MULTI_GF}} der Mitzeichnung mindestens eines weiteren GF{{/IF}}.
(4) Die Geschäftsführung stellt sicher:
- ausreichende Liquidität (Mindest-Cash-Reserve: {{LIQUIDITAET_MIN_MONATE}} Monate operativer Burn)
- rollierendes Forecasting mit {{FORECAST_HORIZON_MONTHS}}-Monats-Horizont
- Kostenkontrolle und vertragliche Sorgfalt
- termingerechte Steuer- und Sozialabgaben-Zahlungen
- jährliche Wirtschaftsprüfung soweit gesetzlich erforderlich oder durch Satzung vorgesehen
## § {{P_PERS}} Personal und Organisation
(1) Einstellungen und Kündigungen von **Schlüsselpersonen** bedürfen{{#IF IS_SINGLE_GF}} der Zustimmung der Gesellschafterversammlung{{/IF}}{{#IF IS_MULTI_GF}} {{#IF HAS_SHA}}- soweit es sich um Reserved Matters gemäß SHA handelt -{{/IF}} der Zustimmung aller GF{{#IF HAS_SHA}} bzw. der Gesellschafter{{/IF}}{{/IF}}.
(2) **Schlüsselpersonen** sind insbesondere:
- C-Level (CEO, CTO, CFO, COO etc.) und Geschäftsleitung
- Bereichsleiter mit direkter Personalverantwortung für mindestens 5 Mitarbeiter
- Mitarbeiter mit Brutto-Jahreseinkommen über {{SCHLUESSELPERSON_GEHALT_EUR}} EUR
- Personen mit fachlicher Schlüsselrolle (z. B. lead AI-Engineer, Data Protection Officer)
(3) Die Entscheidung über übrige Personalmaßnahmen trifft {{#IF IS_SINGLE_GF}}der Geschäftsführer{{/IF}}{{#IF IS_MULTI_GF}}der ressortzuständige GF{{/IF}} im Rahmen des genehmigten Personalbudgets.
(4) Die Geschäftsführung trägt {{#IF IS_MULTI_GF}}gemeinsam {{/IF}}Verantwortung für:
- Unternehmenskultur und Werte
- Arbeitsprozesse und Aufbauorganisation
- interne Kommunikation
- Konfliktlösungs- und Eskalationssysteme
- Diversity, Equity und Inclusion
## § {{P_IK}} Interessenkonflikte
(1) Interessenkonflikte (potenzielle und tatsächliche) sind unverzüglich schriftlich offenzulegen, gerichtet an {{#IF IS_SINGLE_GF}}die Gesellschafterversammlung{{/IF}}{{#IF IS_MULTI_GF}}die übrigen GF (in Kopie: Gesellschafter){{/IF}}.
(2) Der betroffene GF ist bei der jeweiligen Entscheidung nicht stimmberechtigt und nimmt nicht an der Beratung teil (Selbstausschluss).
(3) Beispiele für Interessenkonflikte:
- Geschäftsbeziehungen zu nahestehenden Personen oder Unternehmen
- Beteiligungen an Wettbewerbern, Kunden oder Lieferanten
- Doppelmandate in anderen Gesellschaften mit potentiellen Geschäftsbeziehungen
- private Vorteile (Geschenke, Einladungen) über handelsüblicher Höflichkeit
## § {{P_NEB}} Nebentätigkeiten
(1) Nebentätigkeiten (einschließlich Beratungs-, Aufsichtsrats-, Beirats-{{#IF HAS_ACADEMIC_ROLES}} und akademischer Tätigkeiten{{/IF}}) bedürfen der vorherigen schriftlichen Anzeige und Zustimmung {{#IF IS_SINGLE_GF}}der Gesellschafterversammlung{{/IF}}{{#IF IS_MULTI_GF}}der übrigen GF; bei strategischer Relevanz zusätzlich der Gesellschafter{{/IF}}.
(2) Eine Nebentätigkeit ist nur zulässig, wenn:
- keine vertraulichen Informationen der Gesellschaft offengelegt werden
- keine geistigen Eigentumsrechte (IP) der Gesellschaft verwendet werden
- kein Wettbewerbsverhältnis entsteht
- die Tätigkeit nicht mehr als {{NEBENTAETIGKEIT_MAX_STUNDEN}} Stunden pro Woche umfasst
- die Hauptpflicht gegenüber der Gesellschaft nicht beeinträchtigt wird
(3) Die Anzeige erfolgt schriftlich mit Beschreibung des Umfangs, der zeitlichen Belastung, des Vergütungsmodells und der Vertragspartner.
{{#IF HAS_ACADEMIC_ROLES}}
(4) **Akademische Tätigkeiten** (Lehre, Publikationen, Konferenzteilnahme) sind grundsätzlich erwünscht, sofern sie das Renommee der Gesellschaft fördern. Veröffentlichungen mit Bezug zu Geschäftsgeheimnissen, IP der Gesellschaft oder strategischen Plänen bedürfen der vorherigen schriftlichen Zustimmung der übrigen GF.
{{/IF}}
## § {{P_DOC}} Dokumentation
(1) Beschlüsse und wesentliche Entscheidungen sind schriftlich oder in Textform festzuhalten (Protokoll, Beschlussvorlage, E-Mail-Bestätigung).
{{#IF IS_MULTI_GF}}
(2) Protokolle wesentlicher Entscheidungen werden vom Mit-GF gegengezeichnet (bzw. per E-Mail bestätigt).
{{/IF}}
({{P_DOC_NEXT}}) Entscheidungsunterlagen werden zentral im {{DOKUMENTATIONS_SYSTEM}} archiviert. Verantwortlich für die Archivierung ist {{ARCHIV_VERANTWORTLICH}}. Aufbewahrungsdauer: mindestens {{ARCHIVIERUNG_JAHRE}} Jahre, längere gesetzliche Aufbewahrungsfristen (HGB, AO) bleiben unberührt.
({{P_DOC_NEXT_2}}) Zugriffsrechte: Gesellschafter haben jederzeit Einsichtsrecht; Mitarbeiter erhalten Zugriff nach Need-to-know-Prinzip. Vertrauliche Beschlüsse (Personalakten, M&A) werden mit erhöhtem Zugriffsschutz gespeichert.
## § {{P_END}} Inkrafttreten, Änderungen und Schlussbestimmungen
(1) Diese Geschäftsordnung tritt durch Beschluss der Gesellschafterversammlung vom {{RESOLUTION_DATE}} in Kraft.
(2) Änderungen bedürfen eines Gesellschafterbeschlusses unter Beachtung der Mehrheiten gemäß Satzung{{#IF HAS_SHA}} und SHA{{/IF}}.
(3) Die GO-GF ist mindestens jährlich auf Anpassungsbedarf zu überprüfen. Verantwortlich für die Initiative zur Überprüfung ist {{REVIEW_VERANTWORTLICH}}.
(4) **Salvatorische Klausel:** Sollten einzelne Bestimmungen dieser Geschäftsordnung ganz oder teilweise unwirksam oder undurchführbar sein oder werden, bleibt die Wirksamkeit der übrigen Bestimmungen davon unberührt. Anstelle der unwirksamen Bestimmung gilt diejenige wirksame Regelung als vereinbart, die dem Sinn und Zweck der unwirksamen Bestimmung wirtschaftlich am nächsten kommt.
(5) Diese Geschäftsordnung unterliegt deutschem Recht. Gerichtsstand für etwaige Streitigkeiten zwischen den GF aus diesem Innenverhältnis ist - soweit zulässig - der Sitz der Gesellschaft.
---
**Beschlossen durch die Gesellschafterversammlung der {{COMPANY_NAME}} am {{RESOLUTION_DATE}}.**
_Unterschriften: {{SIGNATURES_BLOCK}}_
$template$,
'["COMPANY_NAME","DOCUMENT_VERSION","EFFECTIVE_DATE","RESOLUTION_DATE","NEXT_REVIEW_DATE","NUM_GF","NUM_GF_TEXT","VERTRETUNGSART","IS_SINGLE_GF","IS_MULTI_GF","NUM_GF_IS_2","NUM_GF_GT_2","HAS_SHA","HAS_CEO_DESIGNATION","CEO_NAME","HAS_RESEARCH_FOCUS","HAS_ACADEMIC_ROLES","HAS_RESSORT_3","RESSORT_1_NAME","RESSORT_1_GF","RESSORT_1_AUFGABEN","RESSORT_2_NAME","RESSORT_2_GF","RESSORT_2_AUFGABEN","RESSORT_3_NAME","RESSORT_3_GF","RESSORT_3_AUFGABEN","MEETING_OPERATIVE_FREQ","MEETING_STRATEGIE_FREQ","ERHEBLICH_EUR","ESKALATION_TAGE_INTERN","ESKALATION_TAGE_GESELLSCHAFTER","SCHWELLE_EINZEL_EUR","SCHWELLE_EINZEL_EUR_PLUS_1","SCHWELLE_GEMEINSAM_EUR","SCHWELLE_GESELLSCHAFTER_EUR","BUDGET_ABWEICHUNG_PCT","VERTRAG_LAUFZEIT_MONATE","VERTRAG_WERT_EUR","LIQUIDITAET_MIN_MONATE","FORECAST_HORIZON_MONTHS","SCHLUESSELPERSON_GEHALT_EUR","NEBENTAETIGKEIT_MAX_STUNDEN","DOKUMENTATIONS_SYSTEM","ARCHIV_VERANTWORTLICH","ARCHIVIERUNG_JAHRE","REVIEW_VERANTWORTLICH","SIGNATURES_BLOCK","P_INFO","P_GESELLSCHAFTER","P_AUSSER","P_ENT","P_FIN","P_PERS","P_IK","P_NEB","P_DOC","P_DOC_NEXT","P_DOC_NEXT_2","P_END","LAST_PARA_4"]'::jsonb,
'de',
'DE',
'mit',
'MIT License',
'BreakPilot Compliance',
false,
true,
'1.0.0',
'published',
NOW(), NOW()
;
-- Verifikation
SELECT
document_type,
title,
LENGTH(content) AS content_chars,
status, version
FROM compliance_legal_templates
WHERE document_type = 'geschaeftsordnung_gf'
ORDER BY created_at DESC
LIMIT 1;
@@ -0,0 +1,765 @@
-- Migration 124: SHA (Shareholders' Agreement / Gesellschaftervereinbarung) Template
-- Erstellt nach 3-fach-Check Methode (Absatz-fuer-Absatz Review, 2026-05-19)
-- Skalierbar fuer 2 bis 4+ Gesellschafter; Single-Founder + Investor unterstuetzt
-- Optionale Bloecke: HAS_ACADEMIC_FOUNDER, HAS_BEIRAT, HAS_TEXAS_SHOOTOUT, HAS_GO_GF, HAS_RESEARCH_FOCUS
-- Fixt Cross-Reference-Bugs aus dem Quelltext (Governance war § 10 falsch zugeordnet, sollte § 14 sein)
-- Ergaenzt um: ESOP-Pool, Investor Information Rights, No-Hire/Non-Solicit, Pre-emptive Rights, FMV-Floor bei Drag-Along
INSERT INTO compliance_legal_templates (
id, tenant_id, document_type, title, description, content,
placeholders, language, jurisdiction,
license_id, license_name, source_name,
attribution_required, is_complete_document, version, status,
created_at, updated_at
) SELECT
gen_random_uuid(),
'9282a473-5c95-4b3a-bf78-0ecc0ec71d3e',
'sha',
'Gesellschaftervereinbarung (Shareholders'' Agreement)',
'Gesellschaftervereinbarung (SHA) fuer deutsche GmbH/UG. Regelt das Innenverhaeltnis der Gesellschafter inkl. Vesting (48/12 Monate Cliff), Leaver-Regelungen (Good/Neutral/Bad), Uebertragungsbeschraenkungen, Vorkaufsrechte, Tag-Along und Drag-Along, Deadlock-Mechanismen (optional mit Texas Shoot-Out), Governance mit Reserved Matters (75% Mehrheit), optionaler Beirat, Wettbewerbsverbot mit Safe-Harbor fuer akademische Taetigkeiten, Vertraulichkeit und IP-Bestimmungen. Skalierbar fuer 2 bis 4+ Gesellschafter. Investorenkompatibel und konfliktresistent strukturiert.',
$template$
# Gesellschaftervereinbarung (SHA) der {{COMPANY_NAME}}
---
## Dokumentenkontrolle
| Feld | Wert |
|---|---|
| Dokumenttitel | Gesellschaftervereinbarung (Shareholders' Agreement / SHA) |
| Version | {{DOCUMENT_VERSION}} |
| Stand | {{EFFECTIVE_DATE}} |
| Gesellschaft | {{COMPANY_NAME}}, HRB {{HRB_NUMBER}}, {{COMPANY_REGISTRY_COURT}} |
| Anwendbares Recht | Deutschland |
---
## § 1 Parteien der Vereinbarung
(1) Die vorliegende Gesellschaftervereinbarung (Vereinbarung" oder „SHA") wird geschlossen zwischen den folgenden Gesellschaftern der {{COMPANY_NAME}} (Gesellschaft"):
{{PARTIES_LIST}}
(2) Die in Abs. (1) genannten Personen werden in dieser Vereinbarung jeweils als Gesellschafter" und gemeinsam als „Gesellschafter"{{#IF IS_FOUNDER_GROUP}} oder Gründer"{{/IF}} bezeichnet.
(3) Jeder Gesellschafter hält zum Zeitpunkt des Abschlusses dieser Vereinbarung einen Geschäftsanteil an der Gesellschaft entsprechend der im Handelsregister eingetragenen Satzung (Satzung").
(4) Diese Vereinbarung regelt ausschließlich das Innenverhältnis der Gesellschafter untereinander. Die organschaftlichen Rechte und Pflichten der Geschäftsführer gemäß GmbHG bleiben hiervon unberührt.
(5) Diese Vereinbarung tritt mit Unterzeichnung durch sämtliche in Abs. (1) genannten Gesellschafter in Kraft.
## § 2 Zweck der Vereinbarung
(1) Zweck dieser Vereinbarung ist es, die Zusammenarbeit der Gesellschafter der Gesellschaft auf eine klare, verlässliche und investorenfähige Grundlage zu stellen. Sie regelt ausschließlich das Innenverhältnis der Gesellschafter untereinander sowie deren Rechte und Pflichten gegenüber der Gesellschaft.
(2) Diese Vereinbarung ergänzt die Satzung der Gesellschaft und enthält insbesondere Regelungen zu:
a) Governance-Strukturen und Entscheidungsprozessen (§ 14),
b) Informations- und Transparenzpflichten (§ 7),
c) Vesting- und Leaver-Bestimmungen (§§ 8 und 9),
d) Übertragungsbeschränkungen, Vorkaufsrechten, Tag-Along und Drag-Along (§§ 10-12),
e) Wettbewerbsverboten (§ 16),
f) Vertraulichkeit und IP-Regelungen (§§ 6 und 17),
g) Streitbeilegungsmechanismen und Deadlocks (§ 13).
(3) Bei Widersprüchen zwischen dieser Vereinbarung und der Satzung gilt:
a) im Innenverhältnis zwischen den Gesellschaftern hat diese Vereinbarung Vorrang, soweit gesetzlich zulässig;
b) im Außenverhältnis gegenüber Dritten gilt ausschließlich die Satzung.
(4) Die Gesellschafter verpflichten sich, erforderliche Satzungsänderungen zu beschließen, soweit dies notwendig ist, um wesentliche Regelungen dieser Vereinbarung insbesondere Vesting-, Leaver-, Übertragungs- und Drag-Along-Bestimmungen wirksam abzubilden, sofern diese Änderungen rechtlich zulässig sind. **Hinweis:** Anteilsübertragungen bedürfen gemäß § 15 GmbHG der notariellen Beurkundung.
(5) Ziel dieser Vereinbarung ist es insbesondere, sicherzustellen, dass:
a) die Gesellschaft handlungsfähig, konfliktresistent und langfristig stabil geführt wird,
b) Gesellschafter-Verhältnisse klar geregelt und spätere Finanzierungsrunden nicht beeinträchtigt werden,
c) Rechte und Pflichten aller Gesellschafter transparent gestaltet sind,
d) die Struktur des Unternehmens den Erwartungen professioneller Investoren entspricht.
## § 3 Rollen und Verantwortlichkeiten
(1) Die Gesellschafter verpflichten sich, aktiv und kooperativ an der Führung und Weiterentwicklung der Gesellschaft mitzuwirken und ihre jeweiligen Qualifikationen, Erfahrungen und Ressourcen in angemessenem Umfang einzubringen.
(2) Die interne Verteilung operativer Rollen und Verantwortlichkeiten (Rollenverteilung") richtet sich nach **Anlage A** zu dieser Vereinbarung. Die Rollenverteilung dient ausschließlich der internen Organisation und berührt weder:
a) die gesetzliche Stellung der Geschäftsführer gemäß GmbHG, noch
b) die gesellschaftsrechtliche Gleichbehandlung der Gesellschafter.
(3) Änderungen der Rollenverteilung gemäß Anlage A können jederzeit beschlossen werden, sofern alle {{#IF IS_FOUNDER_GROUP}}Gründer{{/IF}}{{#IF NOT IS_FOUNDER_GROUP}}Gesellschafter{{/IF}} zustimmen. Anpassungen sind insbesondere möglich bei:
a) Wachstum der Gesellschaft,
b) Veränderungen in Personal- oder Führungsstrukturen,
c) Anforderungen aus Finanzierungsrunden oder Investorenrichtlinien,
d) Änderungen individueller Verfügbarkeiten oder Qualifikationen.
(4) Soweit ein Gesellschafter zugleich Geschäftsführer der Gesellschaft ist, gelten für die Ausübung der Geschäftsführungstätigkeit ausschließlich:
a) das GmbHG,
b) die Satzung,
c) der Geschäftsführeranstellungsvertrag,
d) etwaige durch Gesellschafterbeschluss erteilte Weisungen,
e) {{#IF HAS_GO_GF}}die Geschäftsordnung der Geschäftsführung (GO-GF){{/IF}}.
Diese Vereinbarung begründet keine organschaftlichen Rechte oder Pflichten.
(5) Überschneidungen oder Konflikte zwischen einzelnen Rollen werden im Rahmen der Governance-Regeln (§ 14) behandelt und im gegenseitigen Einvernehmen gelöst.
(6) Jeder Gesellschafter verpflichtet sich, die Erfüllung seiner Rolle transparent zu gestalten und relevante Informationen sowie absehbare Änderungen der Verfügbarkeit den übrigen Gesellschaftern rechtzeitig mitzuteilen.
## § 4 Arbeits- und Beitragspflichten
(1) Die Gesellschafter verpflichten sich, ihre Kenntnisse, Fähigkeiten und Erfahrungen im angemessenen Umfang zur Förderung der Gesellschaft einzubringen und an der Weiterentwicklung des Unternehmens mitzuwirken (Beitragspflicht").
(2) Umfang und Art der individuellen Beiträge orientieren sich an der in Anlage A festgelegten Rollenverteilung sowie den im Rahmen der Governance-Strukturen (§ 14) getroffenen Beschlüssen. Die Beitragspflicht begründet keine arbeitsvertraglichen Pflichten und ersetzt keinen Geschäftsführeranstellungsvertrag.
(3) Die Gesellschafter koordinieren ihre Zusammenarbeit nach den Grundsätzen von:
a) Transparenz,
b) Verlässlichkeit,
c) Eigenverantwortung,
d) angemessener Abstimmung über Rollen und Prioritäten.
(4) Veränderungen in der Verfügbarkeit oder der Fähigkeit eines Gesellschafters, seine Beiträge in vollem Umfang zu leisten, werden den übrigen Gesellschaftern frühzeitig mitgeteilt. Die Gesellschafter bemühen sich, erforderliche Anpassungen einvernehmlich zu lösen.
(5) Eine vorübergehende Einschränkung der Verfügbarkeit eines Gesellschafters stellt keinen Verstoß gegen die Beitragspflichten dar, sofern:
a) sie rechtzeitig kommuniziert wurde, und
b) die Gesellschafter eine angemessene organisatorische Lösung gemäß § 14 vereinbaren.
(6) Die Beitragspflicht der Gesellschafter ist von den Vesting-Regelungen dieser Vereinbarung unabhängig. Insbesondere führt eine Änderung der Verfügbarkeit oder der tatsächlichen Beiträge eines Gesellschafters nicht zu:
a) einer Unterbrechung oder Reduktion des Vestings (§ 8),
b) einer Leaver-Klassifizierung (§ 9),
sofern kein vorsätzliches oder grob pflichtwidriges Verhalten vorliegt.
(7) Die Gesellschafter verpflichten sich, ihre Tätigkeiten so zu organisieren, dass eine effiziente Zusammenarbeit gewährleistet ist. Eine formelle Arbeitszeiterfassung ist nicht erforderlich.
(8) Die Beitragspflichten der Gesellschafter begründen keine Verpflichtung zur persönlichen Leistungserbringung über die gesellschaftsrechtlich geschuldete Mitwirkung hinaus. Soweit ein Gesellschafter zugleich Geschäftsführer ist, gelten für seine organschaftlichen Pflichten ausschließlich das GmbHG, die Satzung und sein Anstellungsvertrag.
## § 5 Treuepflichten
(1) Die Gesellschafter verpflichten sich, die Interessen der Gesellschaft loyal, redlich und kooperativ zu fördern und alles zu unterlassen, was deren wirtschaftliche Entwicklung oder strategische Ausrichtung in erheblicher Weise beeinträchtigen könnte.
(2) Jeder Gesellschafter trägt dazu bei, Entscheidungen und Maßnahmen zu unterstützen, die der Gesellschaft dienen, und verpflichtet sich zu einer offenen, rechtzeitigen und transparenten Kommunikation gegenüber den übrigen Gesellschaftern.
(3) Jeder Gesellschafter unterlässt es, Geschäftschancen, die der Gesellschaft aufgrund ihrer Tätigkeit oder Stellung zustehen könnten (Corporate Opportunities"), ohne Zustimmung der übrigen Gesellschafter für eigene Zwecke zu nutzen.
(4) Nebenbeschäftigungen sind zulässig, sofern sie:
a) nicht in unmittelbarem Wettbewerb zur Gesellschaft stehen,
b) die Beitragspflichten gemäß § 4 nicht wesentlich beeinträchtigen, und
c) keine Nutzung vertraulicher Informationen oder Ressourcen der Gesellschaft erfordern.
Eine gesonderte Genehmigung ist nur erforderlich, wenn die Nebenbeschäftigung geeignet ist, einen Interessenkonflikt zu begründen oder erhebliche zeitliche Kapazitäten zu binden.
(5) Jeder Gesellschafter verpflichtet sich, tatsächliche oder potenzielle Interessenkonflikte unverzüglich offenzulegen, transparent zu machen und gemeinsam mit den übrigen Gesellschaftern eine angemessene Lösung zu erarbeiten.
(6) Im Fall eines Konflikts zwischen den persönlichen Interessen eines Gesellschafters und den Interessen der Gesellschaft haben soweit rechtlich zulässig die Interessen der Gesellschaft Vorrang.
(7) Eine vorübergehende Einschränkung der Verfügbarkeit eines Gesellschafters gilt nicht als Verletzung der Treuepflichten, sofern:
a) sie gemäß § 4 (4) rechtzeitig kommuniziert wurde, und
b) die Gesellschafter eine angemessene organisatorische Lösung vereinbaren.
## § 6 Geistiges Eigentum (IP)
(1) Sämtliche im Rahmen der Tätigkeit eines Gesellschafters für die Gesellschaft geschaffenen oder entwickelten Arbeitsergebnisse, einschließlich technischer Entwicklungen, Software, Datenbanken, Modelle, Dokumentationen, Marken, Designs, Konzepte, Erfindungen sowie sonstiger urheber- oder gewerblich geschützter Inhalte (Unternehmens-IP"), stehen ausschließlich der Gesellschaft zu.
(2) Jeder Gesellschafter tritt hiermit der Gesellschaft soweit rechtlich zulässig sämtliche Rechte an Unternehmens-IP ab, die er im Rahmen seiner Tätigkeit für die Gesellschaft schafft oder an deren Entstehung er mitwirkt. Die Gesellschaft ist berechtigt, diese IP uneingeschränkt zu nutzen, zu verwerten und Schutzrechte anzumelden.
{{#IF HAS_ACADEMIC_FOUNDER}}
(3) Ist ein Gesellschafter zugleich Arbeitnehmer einer Hochschule, Forschungseinrichtung oder eines anderen öffentlichen Rechtsträgers und entsteht IP im Rahmen dieser Tätigkeiten (Akademische IP"), so gilt:
a) Akademische IP gehört nicht automatisch der Gesellschaft;
b) eine Übertragung an die Gesellschaft erfolgt nur, soweit dies:
- rechtlich zulässig,
- vertraglich möglich,
- und von dem betreffenden Gesellschafter ausdrücklich gewollt ist;
c) die Gesellschaft kann akademische Ergebnisse nur nutzen, wenn eine entsprechende Vereinbarung geschlossen wurde.
(4) Der Gesellschafter stellt sicher, dass Unternehmens-IP nicht in akademische Projekte, Drittmittelvorhaben oder Kooperationen mit Dritten eingebracht wird, sofern hierfür keine:
a) vorherige Zustimmung aller Gesellschafter vorliegt, und
b) rechtlichen Anforderungen (insbesondere Datenschutz, Geheimhaltung und Förderbedingungen) entsprochen wird.
(5) Die Gesellschaft kann akademische Infrastruktur, Daten oder Ressourcen nur nutzen, sofern:
a) dies rechtlich zulässig ist,
b) die entsprechende Einrichtung zustimmt, und
c) keine Interessenkonflikte entstehen.
Ein Anspruch der Gesellschaft auf solche Nutzung besteht nicht.
{{/IF}}
({{P_IP_PARA_6}}) Jeder Gesellschafter verpflichtet sich, bei allen im Rahmen der Gesellschaft entstehenden Erfindungen die erforderlichen Erklärungen abzugeben und bei der Anmeldung von Schutzrechten mitzuwirken. Etwaige gesetzliche Vergütungsansprüche (z. B. nach ArbnErfG) bleiben unberührt.
({{P_IP_PARA_7}}) Unternehmens-IP und vertrauliche Informationen der Gesellschaft dürfen vom Gesellschafter nicht genutzt werden für:
a) Tätigkeiten außerhalb der Gesellschaft,
{{#IF HAS_ACADEMIC_FOUNDER}}b) akademische Projekte,
c) Drittmittelvorhaben,
d) {{/IF}}Tätigkeiten im Dienste anderer Unternehmen oder Organisationen,
es sei denn, die Gesellschafter haben zuvor zugestimmt.
({{P_IP_PARA_8}}) Die Regelungen dieses Paragraphen gelten unabhängig davon fort, ob ein Gesellschafter Geschäftsführer ist oder nicht. Sie bestehen über das Ausscheiden eines Gesellschafters hinaus fort, soweit dies rechtlich zulässig ist.
## § 7 Informationspflichten
(1) Die Gesellschafter verpflichten sich zu einer offenen, transparenten und rechtzeitigen Kommunikation über alle wesentlichen geschäftlichen, technischen und finanziellen Entwicklungen der Gesellschaft, soweit diese für die gemeinsame Steuerung des Unternehmens erforderlich sind.
(2) Jeder Gesellschafter stellt den übrigen Gesellschaftern regelmäßig die Informationen zur Verfügung, die erforderlich sind, um:
a) den operativen Fortschritt,
b) die technische Entwicklung,
c) die finanzielle Lage,
d) wesentliche Risiken und Chancen,
angemessen beurteilen zu können. Der Umfang orientiert sich an den im Rahmen der Governance-Strukturen (§ 14) festgelegten Reporting- und Meeting-Formaten.
(3) Jeder Gesellschafter informiert die übrigen Gesellschafter unverzüglich, wenn Ereignisse eintreten oder erkennbar werden, die:
a) erhebliche Auswirkungen auf die wirtschaftliche, technische oder rechtliche Situation der Gesellschaft haben können,
b) eine wesentliche Anpassung von Strategie, Ressourcen oder Prioritäten erfordern, oder
c) zu Verzögerungen, Problemen oder Konflikten führen können, die im Rahmen der Governance gelöst werden müssen.
(4) Jeder Gesellschafter hat jederzeit das Recht auf Einsicht in sämtliche:
a) Geschäftsunterlagen der Gesellschaft,
b) Finanzdaten und Buchführungsunterlagen,
c) Verträge und wesentliche Vereinbarungen,
d) technische Dokumentationen und Entwicklungsstände,
e) Datenräume der Gesellschaft,
soweit die Einsichtnahme zur Wahrnehmung seiner Gesellschafterrechte erforderlich ist. Die Geschäftsführung stellt sicher, dass die Einsichtnahme ohne unangemessene Verzögerung ermöglicht wird.
(5) Soweit ein Gesellschafter zugleich Geschäftsführer ist, erfüllt er die Informationspflichten gegenüber den übrigen Gesellschaftern zusätzlich im Rahmen seiner gesetzlichen und vertraglichen Pflichten als Geschäftsführer. Die organschaftlichen Pflichten bleiben hiervon unberührt.
(6) Beschlüsse, Entscheidungen und wesentliche Absprachen der Gesellschafter werden schriftlich oder in Textform dokumentiert und allen Gesellschaftern zugänglich gemacht.
(7) **Investor Information Rights:** Sofern Investoren (Wandeldarlehen, Beteiligung, etc.) Gesellschafter werden oder Informationsrechte vertraglich vereinbart sind, gelten folgende Mindeststandards:
a) monatlicher Liquiditätsbericht ab Beteiligung > {{INVESTOR_INFO_THRESHOLD_EUR}} EUR,
b) Quartals-Reporting mit P&L, Cashflow und Risk Update,
c) jährlicher (geprüfter) Jahresabschluss innerhalb von {{ANNUAL_REPORT_MONTHS}} Monaten nach Geschäftsjahresende.
(8) Die Bestimmungen dieses Paragraphen lassen die Vertraulichkeitspflichten gemäß § 17 unberührt.
## § 8 Vesting
(1) Alle von den {{#IF IS_FOUNDER_GROUP}}Gründern{{/IF}}{{#IF NOT IS_FOUNDER_GROUP}}Gesellschaftern{{/IF}} gehaltenen Geschäftsanteile unterliegen einem Vesting gemäß diesem § 8, beginnend mit {{VESTING_START_EVENT}} (Vesting-Beginn").
(2) Das Vesting erfolgt über einen Zeitraum von **{{VESTING_MONTHS}} Monaten** ab Vesting-Beginn mit einem **Cliff von {{CLIFF_MONTHS}} Monaten**. Vor Ablauf des Cliff gelten keine Anteile als unverfallbar (vested"). Nach Ablauf des Cliff vesten die Anteile monatlich linear.
(3) Anteile, die zum Zeitpunkt des Ausscheidens eines Gesellschafters nicht unverfallbar sind (unvested"), werden gemäß den Leaver-Regelungen (§ 9) eingezogen oder auf die Gesellschaft bzw. einen benannten Dritten übertragen.
(4) Änderungen der tatsächlichen Verfügbarkeit oder Arbeitsleistung eines Gesellschafters gleich aus welchem Grund haben keinen Einfluss auf das Vesting, solange:
a) die Gesellschafterstellung fortbesteht, und
b) kein vorsätzliches oder grob pflichtwidriges Verhalten vorliegt.
Insbesondere führt eine Veränderung der zeitlichen Verfügbarkeit nicht zu einer Unterbrechung oder Reduktion des Vestings.
(5) Streitigkeiten, Auseinandersetzungen, Deadlocks oder Governance-Verfahren haben keine Auswirkungen auf den Vesting-Fortschritt. Nur ein wirksames Ausscheiden gemäß § 9 führt zur Anwendung der Leaver-Regeln.
(6) Bereits unverfallbar gewordene (vested") Anteile bleiben vested. Eine Rückabwicklung unverfallbarer Anteile ist ausgeschlossen, soweit dies gesetzlich zulässig ist.
(7) **Acceleration bei Exit:** Im Falle eines Change of Control (§ 12 Drag-Along, Verkauf von mehr als {{ACCELERATION_THRESHOLD_PCT}}% der Anteile) wird das Vesting zu {{ACCELERATION_PCT}}% beschleunigt (Single-Trigger Acceleration), sofern nicht im jeweiligen Investor-Term-Sheet abweichend geregelt.
(8) Die {{#IF IS_FOUNDER_GROUP}}Gründer{{/IF}}{{#IF NOT IS_FOUNDER_GROUP}}Gesellschafter{{/IF}} verpflichten sich, bei einer Übertragung ihrer Anteile an Dritte sicherzustellen, dass die Vesting-Regelungen unverändert fortgelten oder durch eine gleichwertige Regelung ersetzt werden, sofern Investoren oder die Gesellschaft dies verlangen.
(9) Dieser § 8 geht im Innenverhältnis widersprechenden Regelungen der Satzung vor, soweit rechtlich zulässig.
## § 9 Leaver-Regelungen
(1) **Leaver-Kategorien.** Ein Gesellschafter, der aus der Gesellschaft ausscheidet (Ausscheidender Gesellschafter"), wird gemäß nachstehenden Kategorien eingestuft:
a) **Good Leaver:** Ein Gesellschafter gilt als Good Leaver, wenn sein Ausscheiden erfolgt aufgrund von:
- Tod,
- dauerhafter Krankheit oder Erwerbsunfähigkeit,
- Elternzeit oder wesentlichen familiären Gründen,
- einvernehmlichem Beschluss aller Gesellschafter,
- sonstigen Gründen, die nicht auf schuldhaftem Verhalten beruhen.
b) **Bad Leaver:** Ein Gesellschafter gilt als Bad Leaver, wenn sein Ausscheiden auf:
- vorsätzlicher oder grob fahrlässiger schwerer Pflichtverletzung,
- Verstoß gegen Wettbewerbsverbot (§ 16),
- vorsätzlicher Offenlegung vertraulicher Informationen (§ 17),
- strafbarem Verhalten zulasten der Gesellschaft,
- Nichterbringung der geschuldeten Einlage
beruht.
c) **Neutral Leaver:** Ein Gesellschafter ist Neutral Leaver, wenn keiner der vorgenannten Fälle erfüllt ist.
(2) **Behandlung von unvested und vested Anteilen.** Beim Ausscheiden des Gesellschafters gilt:
a) Unvested Anteile verfallen und werden durch Einziehung oder Übertragung gemäß Beschluss der Gesellschafter abgewickelt.
b) Vested Anteile werden gemäß Abs. (3) abgefunden.
(3) **Abfindungsmechanik.** Die Abfindung für die gehaltenen Anteile richtet sich nach der Leaver-Kategorie:
a) **Good Leaver:** Erhält 100 % des Fair Market Value (FMV) für seine vested Anteile.
b) **Neutral Leaver:** Erhält den FMV seiner vested Anteile, ggf. angepasst um die Vestingquote, falls eine anteilige Betrachtung vereinbart wurde.
c) **Bad Leaver:** Erhält:
- für unvested Anteile: 0-{{BAD_LEAVER_UNVESTED_PCT}} % des FMV (nach Wahl der Gesellschaft),
- für vested Anteile höchstens den Wert der geleisteten Einlage (Nennwert).
(4) **Bestimmung des Fair Market Value (FMV).** Der FMV wird bestimmt durch:
a) einen von den Gesellschaftern einvernehmlich benannten unabhängigen Sachverständigen, oder
b) falls keine Einigung binnen {{FMV_AGREEMENT_DAYS}} Tagen erfolgt: durch einen von der Industrie- und Handelskammer (IHK) zu benennenden Sachverständigen.
Der FMV ist verbindlich.
(5) **Zahlungsmodalitäten.** Die Abfindung kann von der Gesellschaft oder einem erwerbenden Gesellschafter:
a) sofort oder
b) in bis zu {{ABFINDUNG_RATEN_MAX}} gleichen monatlichen Raten
ausbezahlt werden. Eine vorzeitige Ablösung ist jederzeit möglich.
(6) **Rechtsfolgen des Ausscheidens.** Mit Wirksamwerden des Ausscheidens:
a) verliert der ausscheidende Gesellschafter sämtliche gesellschaftsrechtlichen Rechte,
b) bleiben Vertraulichkeits- und IP-Pflichten (§§ 6 und 17) uneingeschränkt bestehen,
c) bleibt das Wettbewerbsverbot (§ 16) bestehen, soweit anwendbar.
(7) **No-Hire / Non-Solicit.** Ein ausscheidender Gesellschafter darf für einen Zeitraum von {{NON_SOLICIT_MONTHS}} Monaten nach Ausscheiden keine Mitarbeiter, Berater oder Geschäftspartner der Gesellschaft direkt oder indirekt abwerben.
## § 10 Übertragungsbeschränkungen und Vorkaufsrechte
(1) **Zustimmungserfordernis.** Die Übertragung, Verpfändung oder sonstige Belastung von Geschäftsanteilen eines Gesellschafters (Übertragung") bedarf der vorherigen Zustimmung der Gesellschafterversammlung, soweit gesetzlich zulässig und in der Satzung vorgesehen. Ein Anspruch auf Zustimmung besteht nicht.
(2) **Informationspflicht bei beabsichtigter Übertragung.** Beabsichtigt ein Gesellschafter (Veräußernder Gesellschafter") eine Übertragung an einen Dritten, hat er dies den übrigen Gesellschaftern in Textform mitzuteilen und dabei offenzulegen:
a) Identität des vorgesehenen Erwerbers,
b) den angebotenen Kaufpreis oder die sonstige Gegenleistung,
c) sämtliche wesentlichen Bedingungen des geplanten Geschäfts.
Die Mitteilung gilt als Voranzeige im Sinne dieses Paragraphen.
(3) **Vorkaufsrecht der Gesellschafter.** Die übrigen Gesellschafter haben nach Zugang der Voranzeige ein Vorkaufsrecht zu den gleichen Bedingungen. Die Ausübungsfrist beträgt **{{VORKAUFSRECHT_TAGE}} Kalendertage** ab Zugang der Voranzeige.
(4) **Anteilszuteilung bei mehreren ausübenden Gesellschaftern.** Üben mehrere Gesellschafter das Vorkaufsrecht aus, werden die angebotenen Anteile anteilig im Verhältnis ihrer bestehenden Beteiligungsquoten zugeteilt, sofern sie nicht einvernehmlich eine abweichende Verteilung beschließen.
(5) **Nachrangiges Erwerbsrecht der Gesellschaft oder eines Dritten.** Wird das Vorkaufsrecht nicht oder nicht vollständig ausgeübt, kann:
a) die Gesellschaft selbst oder
b) ein von ihr benannter Erwerber
die nicht übernommenen Anteile zu denselben Bedingungen erwerben.
(6) **Freigabe zur Übertragung an Dritte.** Nur wenn:
a) kein Gesellschafter sein Vorkaufsrecht ausübt,
b) die Gesellschaft oder ein benannter Erwerber keinen Erwerb vornimmt,
ist der Veräußernde Gesellschafter berechtigt, die Anteile zu den in der Voranzeige genannten Bedingungen an den vorgesehenen Dritten zu übertragen. Ändern sich die Bedingungen, ist ein erneutes Vorkaufsverfahren durchzuführen.
(7) **Verhältnis zu Tag-Along und Drag-Along.** Dieses Vorkaufsrecht findet keine Anwendung auf:
a) Übertragungen im Rahmen von Tag-Along-Rechten gemäß § 11,
b) Übertragungen im Rahmen von Drag-Along-Pflichten gemäß § 12,
c) Übertragungen aufgrund der Leaver-Regelungen (§ 9).
(8) **Übertragung an verbundene Personen.** Die Übertragung von Anteilen an verbundene Personen des Gesellschafters (z. B. Holdinggesellschaften oder unmittelbare Familienmitglieder) kann durch Gesellschafterbeschluss allgemein oder im Einzelfall freigegeben werden, sofern dadurch keine Wettbewerbs- oder Kontrollrisiken entstehen.
(9) **Pre-emptive Rights (Bezugsrechte bei Kapitalerhöhungen).** Bei jeder Kapitalerhöhung steht den Gesellschaftern ein Bezugsrecht zu, das ihrer bisherigen Beteiligungsquote entspricht. Das Bezugsrecht kann durch einstimmigen Gesellschafterbeschluss ausgeschlossen werden (insbesondere für ESOP, Strategische Investoren oder Akquisitionen).
(10) **Innenverhältnis - Vorrang dieses SHA.** Dieser § 10 geht im Innenverhältnis der Gesellschafter widersprechenden Regelungen der Satzung vor, soweit rechtlich zulässig.
## § 11 Tag-Along-Rechte (Mitverkaufsrechte)
(1) **Entstehung des Tag-Along-Rechts.** Beabsichtigt ein Gesellschafter (Veräußernder Gesellschafter") den Verkauf von mehr als **{{TAG_ALONG_THRESHOLD_PCT}} %** seiner Geschäftsanteile an einen oder mehrere Dritte („Dritter Erwerber"), so sind die übrigen Gesellschafter (Mitverkaufsberechtigte Gesellschafter") berechtigt, einen proportionalen Teil ihrer Geschäftsanteile zu gleichen Bedingungen an den Dritten Erwerber mitzuerwerben („Tag-Along-Recht").
(2) **Mitteilungspflichten.** Der Veräußernde Gesellschafter hat den übrigen Gesellschaftern in Textform mitzuteilen:
a) den geplanten Umfang der Übertragung,
b) die Identität des Dritten Erwerbers,
c) den vereinbarten Kaufpreis und sämtliche wesentliche Bedingungen,
d) den vorgesehenen Zeitpunkt des Vollzugs.
(3) **Ausübung des Tag-Along-Rechts.** Die Mitverkaufsberechtigten Gesellschafter können ihr Tag-Along-Recht innerhalb von {{TAG_ALONG_FRIST_TAGE}} Kalendertagen nach Zugang der Mitteilung gemäß Abs. (2) ausüben. Das Tag-Along umfasst:
a) einen Anteil ihrer Beteiligung, der sich proportional zum Anteil des Veräußernden Gesellschafters verhält (Pro-Rata-Anteil"),
b) oder sofern der Dritte Erwerber zustimmt einen höheren Anteil.
(4) **Pflicht des Veräußernden Gesellschafters.** Der Veräußernde Gesellschafter ist verpflichtet:
a) das Tag-Along-Recht der Mitverkaufsberechtigten Gesellschafter dem Dritten Erwerber anzubieten,
b) sicherzustellen, dass der Dritte Erwerber die Anteile der Mitverkaufsberechtigten Gesellschafter zu exakt denselben Bedingungen erwirbt,
c) einen Verkauf nicht abzuschließen, bevor über Tag-Along-Rechte entschieden wurde oder sie abgewickelt sind.
(5) **Rechtsfolgen der Ausübung.** Üben Mitverkaufsberechtigte Gesellschafter ihr Tag-Along-Recht aus:
a) sind sie berechtigt, ihre Anteile zu denselben wirtschaftlichen Bedingungen zu verkaufen, insbesondere
- gleicher Preis pro Anteil,
- gleiche Garantien und Freistellungen (anteilig),
- gleiche Zahlungsbedingungen;
b) darf der Veräußernde Gesellschafter den Verkauf nur durchführen, wenn der Erwerber die Anteile der Mitverkaufsberechtigten Gesellschafter erwirbt.
(6) **Keine Anwendung des Vorkaufsrechts.** Das Vorkaufsrecht gemäß § 10 findet keine Anwendung auf Verkäufe, die einem Tag-Along unterliegen.
(7) **Ausnahmen.** Die Tag-Along-Rechte gelten nicht bei:
a) Übertragungen im Rahmen eines Drag-Along gemäß § 12,
b) Übertragungen an verbundene Unternehmen oder Familienmitglieder des Gesellschafters, sofern diese durch Beschluss freigegeben wurden,
c) Übertragungen im Rahmen der Leaver-Regelungen (§ 9).
(8) **Rangverhältnis.** Im Innenverhältnis geht dieser § 11 widersprechenden Regelungen der Satzung vor, soweit rechtlich zulässig.
## § 12 Drag-Along (Mitverkaufspflichten)
(1) **Entstehung der Drag-Along-Pflicht.** Beschließen Gesellschafter, die zusammen mindestens **{{DRAG_ALONG_THRESHOLD_PCT}} %** der Geschäftsanteile halten (Veräußernde Mehrheit"), den Verkauf von 100 % der Geschäftsanteile oder einer für die Kontrolle maßgeblichen Beteiligung an einen oder mehrere Dritte („Drag-Along-Erwerber"), sind alle übrigen Gesellschafter (Mitverkaufspflichtige Gesellschafter") verpflichtet, ihre Geschäftsanteile zu denselben Bedingungen zu veräußern („Drag-Along-Pflicht").
(2) **Mitteilungspflichten der Veräußernden Mehrheit.** Die Veräußernde Mehrheit hat den übrigen Gesellschaftern in Textform mitzuteilen:
a) die Identität des Drag-Along-Erwerbers,
b) den vereinbarten Kaufpreis und die wesentlichen Bedingungen,
c) den vorgesehenen Zeitpunkt des Vollzugs.
Die Mitteilung setzt die Drag-Along-Pflicht in Kraft.
(3) **Pflicht zur Veräußerung.** Jeder Mitverkaufspflichtige Gesellschafter ist verpflichtet:
a) die eigenen Geschäftsanteile zu den gleichen wirtschaftlichen Konditionen wie die Veräußernde Mehrheit zu verkaufen,
b) sämtliche notwendigen Erklärungen abzugeben,
c) alle erforderlichen Maßnahmen vorzunehmen, um den Vollzug des Drag-Along sicherzustellen.
(4) **Gleichbehandlung ("Same Terms").** Die Mitverkaufspflichtigen Gesellschafter erhalten insbesondere:
a) den identischen Preis pro Anteil,
b) gleiche Zahlungsbedingungen,
c) anteilige Garantien, Gewährleistungen und Freistellungen (keine höhere Haftung als die Veräußernde Mehrheit).
Weitergehende Verpflichtungen dürfen ihnen nicht auferlegt werden.
(5) **Mindest-Kaufpreis (FMV-Floor).** Die Drag-Along-Pflicht greift nur, wenn der angebotene Kaufpreis pro Anteil nicht unter dem Fair Market Value gemäß § 9 (4) liegt, es sei denn, alle Gesellschafter stimmen ausdrücklich zu.
(6) **Keine Blockade des Exits.** Kein Gesellschafter, einschließlich der Mitverkaufspflichtigen, darf:
a) Handlungen vornehmen, die den Vollzug des Drag-Along verhindern oder verzögern,
b) zusätzliche Bedingungen verlangen,
c) die Verhandlung des Veräußernden Mehrheit mit dem Erwerber beeinträchtigen.
(7) **Verhältnis zu Tag-Along und Vorkaufsrechten.**
a) Tag-Along-Rechte (§ 11) finden keine Anwendung, wenn ein Drag-Along ausgelöst wurde.
b) Vorkaufsrechte gemäß § 10 sind ausgeschlossen.
(8) **Ausnahmen.** Die Drag-Along-Pflicht gilt nicht bei Übertragungen:
a) an verbundene Unternehmen der Veräußernden Mehrheit,
b) ohne Mindest-Kaufpreis gemäß Abs. (5).
(9) **Durchführung und Vollzug.** Die Veräußernde Mehrheit ist berechtigt:
a) den Verkaufsvertrag für alle Gesellschafter zu verhandeln,
b) den Vollzug zu koordinieren,
c) technische oder administrative Schritte für alle Beteiligten vorzunehmen,
sofern die wirtschaftlichen Bedingungen für alle Gesellschafter identisch sind.
(10) **Innenverhältnis - Vorrang.** Dieser § 12 geht im Innenverhältnis widersprechenden Bestimmungen der Satzung vor.
## § 13 Deadlock und Streitbeilegung
(1) **Definition des Deadlocks.** Ein Deadlock liegt vor, wenn die Gesellschafter oder Geschäftsführer bei einer Entscheidung von wesentlicher Bedeutung für die Gesellschaft trotz zweier ordnungsgemäß einberufener Entscheidungsversuche innerhalb von {{DEADLOCK_FRIST_TAGE}} Tagen keine Einigung erzielen und die Handlungsfähigkeit der Gesellschaft dadurch erheblich beeinträchtigt wird. Ein Deadlock besteht insbesondere bei Entscheidungen über:
a) wesentliche Änderungen der Unternehmensstrategie,
b) Finanzierungsrunden,
c) Budget- oder Personalentscheidungen von erheblicher Tragweite,
d) Strukturmaßnahmen oder Exit-Szenarien,
e) wesentliche technische oder produktbezogene Entscheidungen.
(2) **Pflicht zur Mediation.** Im Falle eines Deadlocks verpflichten sich die Gesellschafter, unverzüglich, spätestens innerhalb von {{MEDIATION_INIT_TAGE}} Tagen nach Feststellung des Deadlocks, eine Mediation einzuleiten:
a) Der Mediator wird einvernehmlich benannt; gelingt dies nicht binnen {{MEDIATOR_FRIST_TAGE}} Tagen, wird er durch die örtlich zuständige IHK benannt.
b) Die Mediation endet spätestens nach {{MEDIATION_MAX_TAGE}} Kalendertagen, sofern die Gesellschafter nicht einvernehmlich eine Verlängerung beschließen.
c) Die Kosten der Mediation tragen die Gesellschafter zu gleichen Teilen.
(3) **Stellungnahme eines externen Experten (optional).** Wenn die strittige Frage überwiegend technischer, wissenschaftlicher oder fachlicher Natur ist, kann jeder Gesellschafter vorschlagen, einen externen Sachverständigen hinzuzuziehen:
a) Die Stellungnahme ist nicht bindend,
b) dient aber als Entscheidungsgrundlage, um eine sachgerechte Lösung zu fördern.
{{#IF HAS_TEXAS_SHOOTOUT}}
(4) **Ultimatives Deadlock-Instrument: Texas Shoot-Out.** Scheitert die Mediation, wird der Deadlock durch das folgende Verfahren endgültig gelöst:
a) **Abgabe eines Angebots.** Jeder Gesellschafter (Anbietender Gesellschafter") kann ein unwiderrufliches Angebot abgeben, die Geschäftsanteile eines oder mehrerer anderer Gesellschafter zu einem bestimmten Preis pro Anteil zu kaufen.
b) **Alternativrecht der anderen Gesellschafter (Reverse Shoot-Out").** Der Empfänger des Angebots kann binnen {{SHOOTOUT_FRIST_TAGE}} Tagen wählen, ob er:
- das Angebot annimmt und seine Anteile an den Anbietenden verkauft, oder
- die Anteile des Anbietenden zu denselben Konditionen kauft.
c) **Schweigen = Annahme des Verkaufs.** Erfolgt keine Antwort innerhalb der Frist, gilt das Angebot als angenommen (Verkauf der Anteile der Empfänger).
d) **Abwicklung.** Die Abwicklung erfolgt innerhalb von {{SHOOTOUT_ABWICKLUNG_TAGE}} Tagen nach Annahme oder Reverse Shoot-Out.
{{/IF}}
{{#IF NOT HAS_TEXAS_SHOOTOUT}}
(4) **Eskalation bei Scheitern der Mediation.** Scheitert die Mediation, ist binnen {{ESKALATION_TAGE}} Tagen eine außerordentliche Gesellschafterversammlung einzuberufen. Beschlüsse der Gesellschafterversammlung erfolgen mit der Mehrheit gemäß Satzung. Im Falle einer fortbestehenden Patt-Situation steht jedem Gesellschafter das Recht zu, die Auflösung der Gesellschaft gemäß § 60 GmbHG zu beantragen.
{{/IF}}
({{P_DEADLOCK_FINAL}}) **Zweck des Deadlock-Verfahrens.** Das Deadlock-Verfahren dient der Sicherstellung, dass:
a) die Entscheidungsfähigkeit der Gesellschaft in kritischen Situationen erhalten bleibt,
b) Blockadesituationen nicht zu einer Gefährdung des Unternehmens führen,
c) die Gesellschafter einen fairen und transparenten Mechanismus zur Konfliktlösung haben,
d) eine Liquidation der Gesellschaft nur als letzte Option in Betracht kommt.
({{P_DEADLOCK_LAST}}) **Innenverhältnis - Vorrang.** Dieser § 13 geht im Innenverhältnis widersprechenden Regelungen der Satzung vor.
## § 14 Governance und Entscheidungsprozesse
(1) **Grundsätze der Zusammenarbeit.** Die Gesellschafter und Geschäftsführer arbeiten auf Grundlage von Transparenz, Professionalität und kooperativer Entscheidungsfindung zusammen. Alle Gremien wirken darauf hin, dass die Gesellschaft handlungsfähig, konfliktresistent und langfristig erfolgreich geführt wird.
(2) **Geschäftsführung und Zuständigkeiten.**
a) Die Geschäftsführung (GF) führt die Geschäfte der Gesellschaft unter eigener Verantwortung gemäß GmbHG, Satzung, diesem SHA{{#IF HAS_GO_GF}}, der GO-GF{{/IF}} und den Geschäftsführeranstellungsverträgen.
b) Die Geschäftsführer entscheiden eigenständig über operative Angelegenheiten, soweit keine Reserved Matters nach Abs. (3) betroffen sind.
c) Die interne Rollenverteilung richtet sich nach Anlage A; diese berührt nicht die gesetzliche Gleichstellung der Geschäftsführer.
(3) **Reserved Matters (Zustimmungsvorbehalte).** Die folgenden Entscheidungen bedürfen eines Gesellschafterbeschlusses mit mindestens **{{RESERVED_MATTERS_MAJORITY_PCT}} %** der abgegebenen Stimmen, soweit gesetzlich zulässig:
a) Änderungen der Satzung oder des Stammkapitals,
b) Aufnahme neuer Gesellschafter, Ausgabe neuer Anteile oder Wandelrechte,
c) wesentliche Finanzierungsrunden,
d) Aufnahme von Krediten oder finanziellen Verpflichtungen außerhalb des genehmigten Budgets,
e) Erwerb, Veräußerung oder Belastung von wesentlichen Vermögenswerten über {{ASSET_THRESHOLD_EUR}} EUR,
f) Abschluss oder Änderung von Geschäftsführeranstellungsverträgen,
g) wesentliche Änderungen der Unternehmensstrategie,
h) Budgetfreigaben oder Abweichungen über {{BUDGET_ABWEICHUNG_PCT}} % vom genehmigten Budget,
i) Eintritt in neue Geschäftsfelder,
j) strategische Kooperationen größeren Umfangs,
k) Erwerb oder Veräußerung von IP-Rechten von erheblicher Bedeutung,
l) Gewährung von Lizenzen, die zentrale Wettbewerbsposition betreffen,
m) Einstellungen oder Entlassungen leitender Mitarbeiter (C-Level/Führungskräfte),
n) Entscheidungen im Zusammenhang mit Tag-Along/Drag-Along oder Exit-Verhandlungen,
o) Maßnahmen gemäß Leaver-Regelungen (§ 9),
p) Liquidation, Insolvenz oder Strukturmaßnahmen,
q) Einrichtung oder wesentliche Änderung eines Mitarbeiterbeteiligungsprogramms (ESOP).
(4) **ESOP-Pool.** Die Gesellschafter sind sich einig, einen ESOP-Pool in Höhe von bis zu **{{ESOP_POOL_PCT}} %** des Stammkapitals zu reservieren bzw. einzurichten, vorzugsweise vor der nächsten Finanzierungsrunde. Die konkrete Ausgestaltung erfolgt durch separaten Gesellschafterbeschluss.
(5) **Gesellschafterbeschlüsse.** Beschlüsse der Gesellschafter können gefasst werden:
a) in Versammlungen (präsenz oder digital), oder
b) im schriftlichen Verfahren, sofern kein Gesellschafter widerspricht.
(6) **Meeting-Struktur (Lean Governance).** Um eine effiziente Unternehmenssteuerung zu gewährleisten, halten die Geschäftsführer:
a) {{MEETING_OPERATIVE_FREQ}} operative Abstimmungen,
b) {{MEETING_STRATEGIE_FREQ}} Strategie-Meetings,
c) quartalsweise Überprüfung der Rollen- und Ressourcenverteilung (Anlage A).
Die Ergebnisse werden in geeigneter Form dokumentiert.
(7) **Informationsfluss.** Die GF stellt sicher, dass alle Gesellschafter rechtzeitig die Informationen erhalten, die zur Ausübung ihrer Rechte notwendig sind, insbesondere:
a) Finanzstatus,
b) Cashflow-Projektionen,
c) Produkt- und Technologie-Roadmap,
d) wesentliche Vertriebs-{{#IF HAS_RESEARCH_FOCUS}} oder Forschungs-{{/IF}}Vorhaben,
e) Risiken und Abweichungen vom Budget.
(8) **Abwesenheiten und Vertretung.** Bei vorübergehender Abwesenheit eines Geschäftsführers:
a) informiert dieser die übrigen Geschäftsführer rechtzeitig,
b) bestimmen die Geschäftsführer in gegenseitigem Einvernehmen eine Vertretung.
Eine Abwesenheit führt nicht zu einer Anpassung von Stimm- oder Vestingrechten.
(9) **Konfliktlösung und Deadlock-Verweis.** Bei unlösbaren Streitigkeiten wenden die Geschäftsführer und Gesellschafter zunächst:
a) die internen Abstimmungsmechanismen an,
b) sodann Mediation gemäß § 13 (2),
c) im Deadlock-Fall das Verfahren gemäß § 13 (4){{#IF HAS_TEXAS_SHOOTOUT}}-(5){{/IF}}.
(10) **Innenverhältnis - Vorrangregel.** Dieser § 14 geht im Innenverhältnis widersprechenden Bestimmungen der Satzung vor, soweit gesetzlich zulässig.
{{#IF HAS_BEIRAT}}
## § 15 Beirat
(1) **Einrichtung des Beirats.** Die Gesellschafter können einen Beirat als beratendes Gremium der Gesellschaft einrichten. Der Beirat hat keine organschaftlichen Befugnisse und keine Geschäftsführungs- oder Vertretungsmacht.
(2) **Aufgaben des Beirats.** Der Beirat unterstützt die Gesellschafter und die Geschäftsführung bei strategischen Fragestellungen. Seine Aufgaben umfassen insbesondere:
a) Beratung zu Unternehmensstrategie, Produktentwicklung und Markteintritt,
b) Feedback zu Finanzierungsrunden und Investorenansprache,
c) Begleitung bei Wachstum, Skalierung und Organisationsstrukturen,
d) Unterstützung bei Netzwerk, Partnerschaften und Talentrekrutierung,
e) Einschätzungen zu Technologie- oder Markttrends.
Der Beirat trifft keine verbindlichen Entscheidungen für die Gesellschaft.
(3) **Zusammensetzung und Bestellung.**
a) Der Beirat besteht aus bis zu {{BEIRAT_MAX_MITGLIEDER}} Mitgliedern, sofern die Gesellschafter nichts Abweichendes beschließen.
b) Die Mitglieder werden durch Beschluss der Gesellschafter bestellt und abberufen.
c) Die Gesellschafter können externe Experten berufen, auch wenn diese keine Gesellschafter sind.
(4) **Vorsitz und Arbeitsweise.**
a) Die Beiratsmitglieder wählen aus ihrer Mitte einen Vorsitzenden.
b) Der Beirat gibt sich eine einfache Geschäftsordnung, sofern erforderlich.
c) Sitzungen können persönlich, digital oder hybrid stattfinden.
(5) **Einberufung und Teilnahme.**
a) Der Beirat tagt in der Regel {{BEIRAT_FREQ}}, oder bei Bedarf häufiger.
b) Die Geschäftsführung nimmt an den Sitzungen teil, sofern der Beirat dies wünscht.
c) Jeder Geschäftsführer kann Themen auf die Agenda setzen.
(6) **Vergütung.** Die Mitglieder des Beirats können eine angemessene Vergütung erhalten, sofern die Gesellschafter dies beschließen. Eine Vergütung darf erst dann erfolgen, wenn dies wirtschaftlich vertretbar ist und im Budget berücksichtigt wurde.
(7) **Informationsrechte des Beirats.**
a) Der Beirat erhält die Informationen, die er zur Wahrnehmung seiner Beratungsfunktion benötigt.
b) Die Vertraulichkeitspflichten gemäß § 17 gelten entsprechend.
(8) **Keine Entscheidungs- oder Vetorechte.** Der Beirat hat keinerlei:
a) Vetorechte,
b) Weisungsrechte gegenüber der Geschäftsführung,
c) Rechte zur Genehmigung oder Ablehnung von Transaktionen,
d) Mitspracherechte bei Reserved Matters.
Alle Entscheidungsbefugnisse verbleiben bei der Geschäftsführung und den Gesellschaftern gemäß § 14.
(9) **Innenverhältnis - Vorrangregel.** Dieser § 15 geht im Innenverhältnis widersprechenden Bestimmungen der Satzung vor, soweit rechtlich zulässig.
{{/IF}}
## § {{P_NONCOMPETE}} Wettbewerbsverbot
(1) **Wettbewerbsverbot während der Gesellschafterstellung.** Solange ein Gesellschafter an der Gesellschaft beteiligt ist oder als Geschäftsführer tätig ist, darf er keine Tätigkeit ausüben, die in unmittelbarem Wettbewerb zur Gesellschaft steht. Dies umfasst insbesondere:
a) die Gründung, Beteiligung oder Mitarbeit in Unternehmen, die Produkte oder Dienstleistungen anbieten, welche unmittelbar mit den Angeboten der Gesellschaft konkurrieren,
b) die Beratung solcher Unternehmen,
c) das Bereitstellen von Know-how, Technologie oder Ressourcen an Wettbewerber.
(2) **Zulässige Tätigkeiten / Safe Harbor.** Keine verbotene Wettbewerbshandlung liegt vor bei:
{{#IF HAS_ACADEMIC_FOUNDER}}a) akademischen Tätigkeiten, einschließlich Lehre, Forschung, Publikationen und Drittmittelprojekten, sofern keine Unternehmens-IP (§ 6) oder vertrauliche Informationen (§ 17) genutzt werden und die Tätigkeit
- nicht unmittelbar mit dem Geschäftsmodell der Gesellschaft konkurriert und
- die Beitragspflichten gemäß § 4 nicht wesentlich beeinträchtigt;
b) {{/IF}}Tätigkeiten im Rahmen privater Investitionen oder passiver Minderheitsbeteiligungen (< {{PASSIVE_INVEST_PCT}} %), sofern kein aktives Mitwirken und kein Know-how-Transfer erfolgt;
{{#IF HAS_ACADEMIC_FOUNDER}}c){{/IF}}{{#IF NOT HAS_ACADEMIC_FOUNDER}}b){{/IF}} Nebenbeschäftigungen, die gemäß § 5 (4) zulässig sind und keinen Wettbewerb darstellen.
(3) **Wettbewerbsverbot nach Ausscheiden (Post-Exit Non-Compete).** Nach dem Ausscheiden eines Gesellschafters gilt ein Wettbewerbsverbot von:
a) **{{POST_EXIT_GOOD_MONTHS}} Monaten** für Good- oder Neutral Leaver,
b) **{{POST_EXIT_BAD_MONTHS}} Monaten** für Bad Leaver.
Das Verbot gilt nur soweit rechtlich zulässig (§ 138 BGB, Kartellrecht).
(4) **Reichweite des Wettbewerbsverbots.** Das Wettbewerbsverbot bezieht sich ausschließlich auf:
a) das konkrete Geschäftsmodell der Gesellschaft während der Gesellschafterstellung,
b) unmittelbar substituierbare oder konkurrierende Produkte und Dienstleistungen,
c) Tätigkeiten, die geeignet sind, die wirtschaftlichen Interessen der Gesellschaft erheblich zu beeinträchtigen.
Es umfasst nicht jegliche Tätigkeit im allgemeinen Tätigkeitsfeld der Gesellschaft eine Überdehnung des Begriffes soll ausdrücklich vermieden werden.
(5) **Schutz von Geschäftsgeheimnissen und IP.** Unabhängig vom Wettbewerbsverbot ist es dem Gesellschafter untersagt:
a) Geschäftsgeheimnisse, Daten oder interne Strategien der Gesellschaft für eigene oder fremde Zwecke zu nutzen,
b) Unternehmens-IP (§ 6) außerhalb der Gesellschaft einzusetzen,
c) technologische, strategische oder wirtschaftliche Informationen an Dritte weiterzugeben.
Diese Pflichten gelten zeitlich unbegrenzt, soweit rechtlich zulässig.
(6) **Ausnahmen und Genehmigungen.** Die Gesellschafter können Wettbewerbshandlungen oder Kooperationen, die unter Abs. (1) fallen würden, durch einstimmigen Beschluss genehmigen. Genehmigungen müssen in Textform erfolgen und können mit Bedingungen versehen werden.
(7) **Sanktionen bei Verstößen.** Bei Verstößen gegen dieses Wettbewerbsverbot:
a) ist die Gesellschaft berechtigt, Unterlassung und Schadensersatz zu verlangen,
b) kann die Leaver-Klassifizierung gemäß § 9 angepasst werden (insbesondere Bad Leaver),
c) können weitere Maßnahmen zur Sicherung der Gesellschaft beschlossen werden.
(8) **Salvatorische Grenze.** Sollte Teil dieses Wettbewerbsverbotes wegen kartellrechtlicher oder zivilrechtlicher Beschränkungen unwirksam sein, gilt die Regelung im zulässigen Mindestumfang weiter.
## § {{P_CONFIDENTIAL}} Vertraulichkeit
(1) **Grundsatz der Vertraulichkeit.** Jeder Gesellschafter verpflichtet sich, alle ihm im Zusammenhang mit der Gesellschaft bekannt werdenden vertraulichen Informationen (Vertrauliche Informationen") streng vertraulich zu behandeln und weder während der Gesellschafterstellung noch nach deren Beendigung an Dritte weiterzugeben oder für andere Zwecke zu nutzen. Vertrauliche Informationen umfassen insbesondere:
a) technische, wissenschaftliche und geschäftliche Informationen,
b) Software, Daten, Modelle, Quellcode, Algorithmen,
c) Finanzinformationen, Geschäftspläne, strategische Dokumente,
d) Kunden-, Markt- und Wettbewerbsdaten,
e) interne Entscheidungsprozesse und Unterlagen der Gesellschafter oder Geschäftsführung,
f) Unternehmens-IP gemäß § 6.
(2) **Zulässige Offenlegungen.** Keine Verletzung der Vertraulichkeit liegt vor, wenn eine Offenlegung erfolgt:
a) aufgrund zwingender gesetzlicher Vorschriften, behördlicher Anordnungen oder gerichtlicher Entscheidungen,
b) gegenüber beruflichen Beratern (z. B. Anwälten, Steuerberatern), sofern diese der Verschwiegenheit unterliegen,
{{#IF HAS_ACADEMIC_FOUNDER}}c) im Rahmen akademischer Tätigkeiten, sofern dabei
- keine Unternehmens-IP (§ 6) genutzt wird,
- keine internen Daten, Technologien oder vertraulichen Informationen offenbart werden, und
- Veröffentlichungen vorab darauf geprüft werden, dass sie keine Interessen der Gesellschaft beeinträchtigen,
d) {{/IF}}gegenüber Investoren im Rahmen üblicher Due-Diligence-Prüfungen, sofern diese zur Vertraulichkeit verpflichtet sind.
(3) **Schutzmaßnahmen.** Die Gesellschafter verpflichten sich:
a) Vertrauliche Informationen vor unbefugtem Zugriff zu schützen,
b) technische und organisatorische Maßnahmen anzuwenden,
c) nur solchen Personen Zugang zu gewähren, die diesen zur Ausübung ihrer Funktion benötigen.
(4) **Rückgabepflicht.** Bei Beendigung der Gesellschafterstellung oder auf Verlangen der Gesellschaft hat der betreffende Gesellschafter sämtliche vertraulichen Unterlagen, Dokumente, Daten und Datenträger:
a) zurückzugeben oder
b) dauerhaft zu löschen (einschließlich Kopien und Backups),
soweit keine gesetzlichen Aufbewahrungspflichten entgegenstehen.
(5) **Dauer der Vertraulichkeit.** Die Vertraulichkeitspflichten bestehen zeitlich unbegrenzt über das Ausscheiden eines Gesellschafters hinaus fort, soweit dies rechtlich zulässig ist.
(6) **Verhältnis zu anderen Regelungen.** Die Bestimmungen dieses Paragraphen gelten ergänzend zu:
a) § 6 (Geistiges Eigentum) IP darf unabhängig von diesem Paragraphen nicht verwendet werden,
b) § {{P_NONCOMPETE}} (Wettbewerbsverbot),
c) gesetzlichen Geheimhaltungs- und Geschäftsgeheimnisschutzregeln (insb. GeschGehG).
(7) **Rechtsfolgen bei Verstößen.** Bei Verletzung der Vertraulichkeit ist die Gesellschaft berechtigt:
a) Unterlassung zu verlangen,
b) Schadensersatz geltend zu machen,
c) eine Leaver-Neuklassifizierung gemäß § 9 (insbesondere Bad Leaver) vorzunehmen,
d) weitere Schutzmaßnahmen zu ergreifen, die zur Wahrung ihrer Interessen erforderlich sind.
## § {{P_TERM}} Laufzeit und Kündigung
(1) **Laufzeit der Vereinbarung.** Diese Vereinbarung tritt mit Unterzeichnung durch alle Gesellschafter in Kraft und gilt für die gesamte Dauer der Gesellschafterstellung der Unterzeichner (Laufzeit").
(2) **Keine ordentliche Kündigung.** Eine ordentliche Kündigung dieser Vereinbarung ist ausgeschlossen. Gesellschafter können ihre Rechte und Pflichten aus dieser Vereinbarung nur durch Ausscheiden aus der Gesellschaft gemäß gesetzlichen Vorschriften oder gemäß den Bestimmungen dieses SHA (insbesondere §§ 8-12) beenden.
(3) **Beendigung der Vereinbarung.** Diese Vereinbarung endet automatisch:
a) mit dem Ausscheiden des jeweiligen Gesellschafters für diesen Gesellschafter,
b) für alle Gesellschafter, sobald alle Geschäftsanteile der Gesellschaft von einem einzigen Gesellschafter oder Erwerber gehalten werden,
c) mit Auflösung oder Liquidation der Gesellschaft,
d) im Falle eines Exits, sofern diese Vereinbarung durch eine neue Gesellschaftervereinbarung ersetzt wird.
Die Beendigung gegenüber einem Gesellschafter lässt die Bestimmungen, die nachwirken sollen (z. B. § 6 IP, § {{P_NONCOMPETE}} Wettbewerb, § {{P_CONFIDENTIAL}} Vertraulichkeit), unberührt.
(4) **Außerordentliche Kündigung.** Eine außerordentliche Kündigung ist nur ausnahmsweise möglich bei:
a) schwerwiegender, nachhaltiger Pflichtverletzung eines Gesellschafters,
b) grobem oder vorsätzlichem Verstoß gegen Vertraulichkeit, IP-Rechte oder Wettbewerbsverbot,
c) strafbarem Verhalten zulasten der Gesellschaft,
d) sonstigen Fällen, in denen der Fortbestand der Vereinbarung für die übrigen Gesellschafter unzumutbar ist.
Die außerordentliche Kündigung richtet sich ausschließlich gegen den betroffenen Gesellschafter und bewirkt kein Ende der Vereinbarung im Verhältnis der übrigen Gesellschafter. Eine außerordentliche Kündigung ersetzt nicht die Anwendung der Leaver-Regelungen gemäß § 9 beide Mechanismen können parallel greifen.
(5) **Änderungen der Vereinbarung.** Änderungen dieser Vereinbarung bedürfen der Schriftform und der Zustimmung aller Gesellschafter, soweit nicht ausdrücklich etwas anderes bestimmt ist. Änderungen, die einzelne Gesellschafter benachteiligen oder die wirtschaftlichen Grundmechanismen dieser Vereinbarung betreffen (insbesondere Vesting, Leaver, Drag-Along, Tag-Along), bedürfen stets der Einstimmigkeit.
(6) **Fortgeltende Bestimmungen.** Die folgenden Bestimmungen gelten im Innenverhältnis auch nach Beendigung der Vereinbarung fort, soweit rechtlich zulässig:
a) § 6 (Geistiges Eigentum),
b) § {{P_NONCOMPETE}} (Wettbewerbsverbot) soweit Post-Exit-Regeln gelten,
c) § {{P_CONFIDENTIAL}} (Vertraulichkeit),
d) § {{P_FINAL}} (Schlussbestimmungen).
## § {{P_FINAL}} Schlussbestimmungen
(1) **Anwendbares Recht.** Diese Vereinbarung unterliegt ausschließlich dem Recht der Bundesrepublik Deutschland.
(2) **Gerichtsstand.** Für alle Streitigkeiten aus oder im Zusammenhang mit dieser Vereinbarung ist, soweit gesetzlich zulässig, der Sitz der Gesellschaft ausschließlicher Gerichtsstand.
(3) **Schriftform.** Änderungen und Ergänzungen dieser Vereinbarung bedürfen der Schriftform, sofern nicht gesetzlich eine strengere Form (z. B. notarielle Beurkundung gemäß § 15 GmbHG für Anteilsübertragungen) vorgeschrieben ist. Dies gilt auch für eine Änderung dieses Schriftformerfordernisses.
(4) **Elektronische Signaturen.** Diese Vereinbarung kann unter Verwendung von qualifizierten elektronischen Signaturen (QES), fortgeschrittenen elektronischen Signaturen (FES) oder im Wege einer mehrseitigen Signaturfassung (PDF-Scans) wirksam abgeschlossen werden, soweit keine notarielle Form erforderlich ist.
(5) **Vollständigkeitsklausel.** Diese Vereinbarung regelt abschließend das Innenverhältnis der Gesellschafter und ersetzt sämtliche früheren Abreden oder Vereinbarungen, soweit diese denselben Regelungsgegenstand betreffen.
(6) **Verhältnis zur Satzung.** Soweit Bestimmungen dieser Vereinbarung mit der Satzung der Gesellschaft in Widerspruch stehen, gilt:
a) im Innenverhältnis der Gesellschafter untereinander hat diese Vereinbarung Vorrang, soweit rechtlich zulässig;
b) im Außenverhältnis gegenüber Dritten gilt ausschließlich die Satzung.
Die Gesellschafter verpflichten sich, erforderliche Satzungsanpassungen vorzunehmen, sofern dies zur Umsetzung wesentlicher Bestimmungen dieses SHA erforderlich ist.
(7) **Salvatorische Klausel.** Sollte eine Bestimmung dieser Vereinbarung ganz oder teilweise unwirksam sein oder werden, bleibt die Wirksamkeit der übrigen Bestimmungen unberührt. Die unwirksame Bestimmung gilt als durch eine solche ersetzt, die dem wirtschaftlich gewollten Zweck am nächsten kommt und rechtlich wirksam ist.
(8) **Sprache.** Diese Vereinbarung ist in deutscher Sprache abgefasst. Bei Mehrsprachigkeit ist ausschließlich die deutsche Fassung maßgeblich.
(9) **Ausfertigungen.** Diese Vereinbarung kann in mehreren gleichlautenden Exemplaren unterzeichnet werden; jede Ausfertigung gilt als Original, alle zusammen bilden ein Dokument.
(10) **Compliance.** Die Gesellschafter verpflichten sich zur Einhaltung aller anwendbaren Gesetze und Regelungen, einschließlich Anti-Korruption (z. B. UWG, OECD-Konvention), Geldwäscheprävention (GwG) und Sanktionsregimen (EU/USA).
---
## Anlage A Rollen und Verantwortlichkeiten
(1) **Allgemeines.** Diese Anlage definiert die interne Rollenverteilung zwischen den Gesellschaftern. Sie dient ausschließlich der organisatorischen Strukturierung und berührt weder:
a) die gesetzlichen Aufgaben und Verantwortlichkeiten der Geschäftsführer gemäß GmbHG,
b) die gesellschaftsrechtliche Stellung der Gesellschafter,
c) Vesting-, Stimm- oder Vermögensrechte.
Änderungen dieser Anlage können gemäß § 3 (3) jederzeit einvernehmlich beschlossen werden, ohne dass eine notarielle Beurkundung erforderlich ist.
{{ROLES_DESCRIPTION}}
({{LAST_ROLE_PARA}}) **Gemeinsame Verantwortlichkeiten.** Unabhängig von der Rollenverteilung arbeiten alle Gesellschafter zusammen bei:
a) der strategischen Weiterentwicklung der Gesellschaft,
b) der Entscheidungsvorbereitung im Rahmen der Governance-Strukturen (§ 14),
c) der Abstimmung über Prioritäten, Ressourcenplanung und Zielsetzungen,
d) der Einhaltung gesetzlicher, finanzieller und regulatorischer Anforderungen,
e) der Sicherstellung transparenter interner Kommunikation.
({{LAST_ROLE_PARA_PLUS_1}}) **Anpassungsmechanismus.** Die Rollenverteilung wird mindestens quartalsweise im Rahmen der strategischen Abstimmung gemäß § 14 überprüft und kann im Einvernehmen angepasst werden, insbesondere bei:
a) Wachstum der Gesellschaft,
b) strukturellen Änderungen in Teams oder Management,
c) neuen Geschäftsbereichen,
d) erforderlichen Anpassungen im Rahmen von Finanzierungsrunden.
---
**Unterzeichnet von den Gesellschaftern am {{SIGNATURE_DATE}}.**
_{{SIGNATURES_BLOCK}}_
$template$,
'["COMPANY_NAME","DOCUMENT_VERSION","EFFECTIVE_DATE","HRB_NUMBER","COMPANY_REGISTRY_COURT","PARTIES_LIST","IS_FOUNDER_GROUP","HAS_ACADEMIC_FOUNDER","HAS_BEIRAT","HAS_TEXAS_SHOOTOUT","HAS_GO_GF","HAS_RESEARCH_FOCUS","VESTING_START_EVENT","VESTING_MONTHS","CLIFF_MONTHS","ACCELERATION_THRESHOLD_PCT","ACCELERATION_PCT","BAD_LEAVER_UNVESTED_PCT","FMV_AGREEMENT_DAYS","ABFINDUNG_RATEN_MAX","NON_SOLICIT_MONTHS","VORKAUFSRECHT_TAGE","TAG_ALONG_THRESHOLD_PCT","TAG_ALONG_FRIST_TAGE","DRAG_ALONG_THRESHOLD_PCT","DEADLOCK_FRIST_TAGE","MEDIATION_INIT_TAGE","MEDIATOR_FRIST_TAGE","MEDIATION_MAX_TAGE","SHOOTOUT_FRIST_TAGE","SHOOTOUT_ABWICKLUNG_TAGE","ESKALATION_TAGE","RESERVED_MATTERS_MAJORITY_PCT","ASSET_THRESHOLD_EUR","BUDGET_ABWEICHUNG_PCT","ESOP_POOL_PCT","MEETING_OPERATIVE_FREQ","MEETING_STRATEGIE_FREQ","INVESTOR_INFO_THRESHOLD_EUR","ANNUAL_REPORT_MONTHS","BEIRAT_MAX_MITGLIEDER","BEIRAT_FREQ","PASSIVE_INVEST_PCT","POST_EXIT_GOOD_MONTHS","POST_EXIT_BAD_MONTHS","ROLES_DESCRIPTION","SIGNATURE_DATE","SIGNATURES_BLOCK","P_IP_PARA_6","P_IP_PARA_7","P_IP_PARA_8","P_DEADLOCK_FINAL","P_DEADLOCK_LAST","P_NONCOMPETE","P_CONFIDENTIAL","P_TERM","P_FINAL","LAST_ROLE_PARA","LAST_ROLE_PARA_PLUS_1"]'::jsonb,
'de',
'DE',
'mit',
'MIT License',
'BreakPilot Compliance',
false,
true,
'1.0.0',
'published',
NOW(), NOW()
;
-- Verifikation
SELECT
document_type,
title,
LENGTH(content) AS content_chars,
status, version
FROM compliance_legal_templates
WHERE document_type = 'sha'
ORDER BY created_at DESC
LIMIT 1;
@@ -0,0 +1,468 @@
-- Migration 125: Satzung (Gesellschaftsvertrag) Template fuer GmbH/UG
-- Erstellt nach 3-fach-Check Methode (Absatz-fuer-Absatz Review, 2026-05-19)
-- Skalierbar fuer 1-Mann (UG/GmbH) bis Multi-Founder
-- Optionale Bloecke: HAS_SHA, HAS_GO_GF, HAS_ACADEMIC_FOUNDER, HAS_SACHEINLAGE, IS_UG, IS_MULTI_GESELLSCHAFTER
-- Fixt Strukturprobleme aus Quelltext (doppelte Nummerierung in § 3, Cross-Refs auf SHA)
-- Ergaenzungen: Geschaeftsjahr (§ 1), Erbfall (§ X), Bekanntmachungen, Gruendungskosten-Klausel
-- Generalisiert § 2 Gegenstand: Wizard befuellt COMPANY_PURPOSE_DESCRIPTION + COMPANY_PURPOSE_BULLETS
INSERT INTO compliance_legal_templates (
id, tenant_id, document_type, title, description, content,
placeholders, language, jurisdiction,
license_id, license_name, source_name,
attribution_required, is_complete_document, version, status,
created_at, updated_at
) SELECT
gen_random_uuid(),
'9282a473-5c95-4b3a-bf78-0ecc0ec71d3e',
'articles_of_association',
'Satzung (Gesellschaftsvertrag)',
'Satzung (Gesellschaftsvertrag) fuer deutsche GmbH oder UG (haftungsbeschraenkt). Enthaelt alle Pflichtangaben (Firma, Sitz, Gegenstand, Stammkapital) gemaess § 3 GmbHG plus operative Bestimmungen: Geschaeftsfuehrung mit Vertretungsregelung, Vesting/Leaver-Verweis ins SHA, Einziehung, Vorkaufsrechte, Tag-Along, Drag-Along, Gesellschafterversammlung, Jahresabschluss, Erbfall, Aufloesung. Skalierbar 1-Mann-Startup bis Multi-Founder. UG-Variante mit Pflichtruecklage § 5a Abs. 3 GmbHG. Wizard-faehig: Gesellschaftszweck wird projektspezifisch befuellt.',
$template$
# Satzung der {{COMPANY_NAME}}
---
## Dokumentenkontrolle
| Feld | Wert |
|---|---|
| Dokumenttitel | Gesellschaftsvertrag (Satzung) |
| Gesellschaft | {{COMPANY_NAME}} |
| Rechtsform | {{COMPANY_LEGAL_FORM}} |
| Sitz | {{COMPANY_SEAT}} |
| Version | {{DOCUMENT_VERSION}} |
| Datum | {{EFFECTIVE_DATE}} |
| Beurkundender Notar | {{NOTARY_NAME}} ({{NOTARY_PLACE}}), URNr. {{NOTARY_URNR}} |
**Hinweis:** Diese Satzung ist gemäß § 2 GmbHG notariell zu beurkunden. Spätere Änderungen bedürfen nach § 53 GmbHG ebenfalls notarieller Beurkundung.
---
## § 1 Firma, Sitz, Dauer, Geschäftsjahr
(1) Die Gesellschaft führt die Firma **{{COMPANY_NAME}}**.
(2) Sitz der Gesellschaft ist **{{COMPANY_SEAT}}**.
(3) Die Dauer der Gesellschaft ist unbestimmt. Sie beginnt mit der Eintragung in das Handelsregister.
(4) Das Geschäftsjahr der Gesellschaft ist {{BUSINESS_YEAR}}. Das erste Geschäftsjahr beginnt mit der Eintragung im Handelsregister und endet am darauffolgenden {{FIRST_YEAR_END}} (Rumpfgeschäftsjahr).
(5) Bekanntmachungen der Gesellschaft erfolgen, soweit gesetzlich vorgeschrieben, im {{PUBLICATION_VENUE}}.
## § 2 Gegenstand des Unternehmens
(1) Gegenstand des Unternehmens ist {{COMPANY_PURPOSE_DESCRIPTION}}.
Hierzu gehören insbesondere:
{{COMPANY_PURPOSE_BULLETS}}
(2) Die Gesellschaft ist berechtigt, alle Geschäfte vorzunehmen und Maßnahmen zu ergreifen, die dem Gesellschaftszweck unmittelbar oder mittelbar zu dienen geeignet sind. Sie darf insbesondere gleichartige oder verwandte Unternehmen gründen, erwerben oder sich daran beteiligen sowie Zweigniederlassungen im In- und Ausland errichten.
(3) Genehmigungsbedürftige Tätigkeiten werden erst nach Erteilung der erforderlichen behördlichen oder berufsrechtlichen Genehmigungen aufgenommen.
## § 3 Stammkapital und Stammeinlagen
### 3.1 Stammkapital
Das Stammkapital der Gesellschaft beträgt **{{STAMMKAPITAL_EUR}} Euro**.
### 3.2 Geschäftsanteile
Das Stammkapital ist in die folgenden Geschäftsanteile eingeteilt:
{{PARTIES_LIST_WITH_SHARES}}
Die Geschäftsanteile sind voneinander unabhängig und können einzeln übertragen, belastet oder eingezogen werden{{#IF HAS_SHA}}, insbesondere im Zusammenhang mit Vesting- oder Leaver-Regelungen gemäß §§ 5-7 dieser Satzung und dem Shareholders' Agreement („SHA"){{/IF}}.
### 3.3 Einlageverpflichtung
(a) Die Einlagen sind {{EINLAGE_METHOD}} zu leisten.
(b) **{{EINLAGE_QUOTE_INITIAL_PCT}} %** jeder Stammeinlage sind sofort mit Übernahme des Geschäftsanteils fällig.
{{#IF EINLAGE_QUOTE_INITIAL_LESS_THAN_100}}
(c) Die übrigen {{EINLAGE_QUOTE_REMAINING_PCT}} % sind auf Anforderung der Geschäftsführung zu leisten. Die Anforderung bedarf eines vorherigen Beschlusses der Gesellschafterversammlung.
(d) Die Geschäftsführer haben die ausstehenden Einlagen unverzüglich einzufordern, soweit dies zur Sicherstellung der Liquidität oder Durchführung des Geschäftsbetriebs erforderlich ist.
{{/IF}}
{{#IF HAS_SACHEINLAGE}}
(e) Sacheinlagen werden in einem gesonderten Sachgründungsbericht gemäß § 5 Abs. 4 GmbHG dokumentiert und mit dem im Sachgründungsbericht festgestellten Wert auf die Einlageverpflichtung angerechnet.
{{/IF}}
### 3.4 Verzug und Rechtsfolgen
(a) Befindet sich ein Gesellschafter mit der Einlage im Verzug, so hat er Verzugszinsen in gesetzlicher Höhe zu leisten.
(b) Erfolgt die Zahlung trotz schriftlicher Mahnung nicht innerhalb von **{{VERZUGSFRIST_TAGE}} Tagen**, kann:
- die Gesellschaft die Einziehung des Geschäftsanteils gemäß § 7 beschließen, oder
- der Geschäftsanteil an die übrigen Gesellschafter oder an einen Dritten übertragen werden, sofern diese bereit sind, die ausstehende Einlage zu übernehmen.
(c) Ein solcher Fall kann{{#IF HAS_SHA}} als Bad-Leaver-Tatbestand gemäß SHA{{/IF}} gewertet werden{{#IF NOT HAS_SHA}}; die übrigen Gesellschafter können weitergehende Ansprüche geltend machen{{/IF}}.
{{#IF HAS_SHA}}
### 3.5 Verhältnis zum SHA
Ergänzende oder detailliertere Bestimmungen zur Einlageleistung und Kapitalstruktur im Shareholders' Agreement (SHA) gelten im Innenverhältnis zwischen den Gesellschaftern vorrangig.
{{/IF}}
## § 4 Geschäftsführung und Vertretung
### 4.1 Bestellung der Geschäftsführer
Die Gesellschaft hat einen oder mehrere Geschäftsführer. Sie werden durch Gesellschafterbeschluss bestellt und abberufen. Einzelheiten ihrer Aufgabenverteilung regelt{{#IF HAS_GO_GF}} eine durch die Gesellschafterversammlung zu beschließende Geschäftsordnung für die Geschäftsführung (GO-GF){{/IF}}{{#IF NOT HAS_GO_GF}} eine durch die Gesellschafterversammlung zu beschließende Geschäftsordnung{{/IF}}.
### 4.2 Vertretung der Gesellschaft
(a) Ist nur ein Geschäftsführer bestellt, vertritt dieser die Gesellschaft allein.
(b) Sind mehrere Geschäftsführer bestellt, vertreten **zwei Geschäftsführer gemeinsam** oder ein Geschäftsführer gemeinsam mit einem Prokuristen.
(c) Die Gesellschafterversammlung kann einem oder mehreren Geschäftsführern Einzelvertretungsbefugnis und/oder Befreiung von § 181 BGB erteilen.
### 4.3 Bindung an Gesellschafterbeschlüsse
(a) Die Geschäftsführer sind an die Beschlüsse der Gesellschafterversammlung gebunden.
(b) Sie leiten die Gesellschaft nach Maßgabe des GmbHG, dieser Satzung{{#IF HAS_SHA}}, des SHA{{/IF}}{{#IF HAS_GO_GF}} und der GO-GF{{/IF}}.
(c) Beschränkungen der Vertretungsbefugnis wirken nur im Innenverhältnis.
### 4.4 Aufgabenverteilung / Funktionstitel
(a) Die Zuweisung von Ressorts und Funktionstiteln erfolgt durch Gesellschafterbeschluss{{#IF HAS_SHA}} und/oder gemäß Anlage A des SHA{{/IF}}{{#IF HAS_GO_GF}} bzw. der GO-GF{{/IF}}.
(b) Die Ressortverteilung hat keine Außenwirkung und berührt nicht die gesetzliche Verantwortlichkeit aller Geschäftsführer.
### 4.5 Zustimmungskatalog (Reserved Matters)
{{#IF HAS_SHA}}Die Geschäftsführer benötigen für Maßnahmen, die als Reserved Matters" im SHA definiert sind, einen zustimmenden Gesellschafterbeschluss mit der dort vorgesehenen Mehrheit. Die GO-GF kann ergänzende Kataloge vorsehen.{{/IF}}{{#IF NOT HAS_SHA}}Maßnahmen außerhalb des gewöhnlichen Geschäftsbetriebs sowie solche von erheblicher Bedeutung bedürfen eines zustimmenden Gesellschafterbeschlusses. Die Gesellschafterversammlung kann einen Katalog zustimmungspflichtiger Geschäfte festlegen.{{/IF}}
### 4.6 Wettbewerbsverbot und Interessenkonflikte
(a) Geschäftsführer unterliegen während ihrer Amtszeit einem Wettbewerbsverbot{{#IF HAS_SHA}} entsprechend den Regelungen im SHA{{/IF}}.
(b) Mögliche Interessenkonflikte sind unverzüglich offenzulegen.
{{#IF HAS_ACADEMIC_FOUNDER}}
(c) Akademische Tätigkeiten gelten nicht automatisch als Interessenkonflikt; sie sind nach Maßgabe des SHA zulässig, sofern keine Geschäftsgeheimnisse offengelegt werden oder ein unmittelbarer Wettbewerb entsteht.
{{/IF}}
### 4.7 Berichterstattung und Vergütung
(a) Die Geschäftsführer berichten der Gesellschafterversammlung regelmäßig über die finanzielle Lage, Geschäftsentwicklung, Produktentwicklung und wesentliche Risiken.
(b) Die Vergütung wird in den jeweiligen Geschäftsführerdienstverträgen geregelt; diese bedürfen der Zustimmung der Gesellschafterversammlung.
### 4.8 Abberufung aus wichtigem Grund
Ein Geschäftsführer kann aus wichtigem Grund abberufen werden, insbesondere bei grober Pflichtverletzung, schwerwiegendem Wettbewerbsverstoß oder Unfähigkeit zur ordnungsgemäßen Geschäftsführung.
{{#IF HAS_ACADEMIC_FOUNDER}}
Eine akademische Tätigkeit ist für sich genommen kein wichtiger Grund.
{{/IF}}
{{#IF HAS_SHA}}
## § 5 Founder Vesting
(1) Die von den Gründern gehaltenen Geschäftsanteile unterliegen einem Vesting. Die Einzelheiten des Vestings einschließlich Bedingungen, Dauer, Cliff, vesting schedule, Behandlung von unvested Anteilen und Verfahren bei Ausscheiden ergeben sich aus dem jeweils gültigen Shareholders' Agreement („SHA").
(2) Die Folgen eines Ausscheidens eines Gesellschafters und dessen Einstufung als Good Leaver, Neutral Leaver oder Bad Leaver richten sich ausschließlich nach den Regelungen des SHA.
(3) Die Einziehung oder Übertragung von im Rahmen des Vestings oder der Leaver-Regelungen betroffenen Anteilen erfolgt auf Grundlage eines Gesellschafterbeschlusses gemäß den Bestimmungen dieser Satzung und des SHA.
(4) Soweit die Satzung vesting- oder leaverbezogene Regelungen enthält, gehen im Innenverhältnis der Gesellschafter die Bestimmungen des SHA vor.
(5) Das Vesting ist im Innenverhältnis nicht von Umfang, Art oder zeitlicher Verfügbarkeit der Tätigkeit eines Gesellschafters abhängig.{{#IF HAS_ACADEMIC_FOUNDER}} Akademische Tätigkeiten, Nebenbeschäftigungen oder veränderte Verfügbarkeiten berühren das Vesting nicht, sofern die Pflichten gemäß SHA eingehalten werden.{{/IF}}
## § 6 Leaver
(1) Die Einstufung eines Gesellschafters als Good Leaver, Neutral Leaver oder Bad Leaver sowie die Konsequenzen eines Ausscheidens bestimmen sich ausschließlich nach den Bestimmungen des jeweils gültigen SHA.
(2) Scheidet ein Gesellschafter als Leaver im Sinne des SHA aus, kann die Gesellschafterversammlung die Einziehung oder die Übertragung der von dem Ausscheidenden gehaltenen Geschäftsanteile beschließen. Einziehungs- oder Übertragungsbeschlüsse erfolgen gemäß den hierfür in der Satzung vorgesehenen Mehrheiten.
(3) Die Höhe der Abfindung für eingezogene oder zu übertragende Geschäftsanteile richtet sich nach den im SHA festgelegten Regelungen zu:
- Fair Market Value,
- Abfindungsmechanik für Good / Neutral / Bad Leaver,
- Zahlungsmodalitäten.
Diese Regelungen gelten im Innenverhältnis zwischen den Gesellschaftern und sind bei Einziehungsbeschlüssen entsprechend zu beachten.
(4) Die Satzung enthält keine eigenständigen Leaver-Tatbestände. Insbesondere gilt:
(a) Eine Veränderung der Arbeitszeit, Verfügbarkeit oder Nebenbeschäftigungen{{#IF HAS_ACADEMIC_FOUNDER}} (einschließlich akademischer Tätigkeiten){{/IF}} begründet keinen Leaver-Tatbestand.
{{#IF HAS_ACADEMIC_FOUNDER}}
(b) Eine Professur oder sonstige berufliche akademische Tätigkeit gilt nicht als Pflichtverletzung und ist kein Grund für eine Bad-Leaver-Einstufung.
{{/IF}}
(c) Nur das SHA ist maßgeblich für Leaver-Tatbestände.
(5) Soweit die Satzung Regelungen enthält, die Leaver-Fälle oder deren Folgen betreffen, gelten im Innenverhältnis der Gesellschafter ausschließlich die Bestimmungen des SHA. Die Satzung dient nur der Umsetzung der durch das SHA ausgelösten Maßnahmen.
(6) Einziehungs- und Übertragungsbeschlüsse wirken im Außenverhältnis nach Maßgabe der §§ 34-35 GmbHG. Der interne Rechtsgrund ergibt sich aus dem SHA.
{{/IF}}
## § {{P_EINZIEHUNG}} Einziehung von Geschäftsanteilen
(1) Die Gesellschafterversammlung kann die Einziehung von Geschäftsanteilen eines Gesellschafters beschließen, wenn:
(a) {{#IF HAS_SHA}}dies nach den Regelungen des SHA vorgesehen ist (insbesondere im Zusammenhang mit Vesting- oder Leaver-Fällen), oder{{/IF}}
(b) die gesetzlichen Voraussetzungen für eine Einziehung (§ 34 GmbHG) vorliegen,
(c) der betroffene Gesellschafter mit der Einlage in Verzug ist (§ 3.4),
(d) der Geschäftsanteil gepfändet wird und die Pfändung nicht binnen drei Monaten aufgehoben wird,
(e) über das Vermögen eines Gesellschafters das Insolvenzverfahren eröffnet oder die Eröffnung mangels Masse abgelehnt wird.
(2) Einziehungsbeschlüsse erfolgen mit **{{EINZIEHUNG_MEHRHEIT_PCT}} %** der abgegebenen Stimmen, soweit nicht gesetzlich eine höhere Mehrheit zwingend vorgeschrieben ist. Der betroffene Gesellschafter ist nicht stimmberechtigt. Die Einziehung wird mit Beschlussfassung wirksam, sofern nicht ausdrücklich ein späterer Zeitpunkt bestimmt wird.
(3) Die Abfindung für eingezogene Geschäftsanteile richtet sich {{#IF HAS_SHA}}im Innenverhältnis ausschließlich nach den Bestimmungen des SHA, insbesondere:
- den Leaver-Regelungen,
- den Vesting-Regelungen,
- der Bestimmung des Fair Market Value und
- den dort festgelegten Zahlungsmodalitäten.
Die Satzung enthält keine eigenständigen Bewertungs- oder Abfindungsregeln.{{/IF}}{{#IF NOT HAS_SHA}}nach dem Verkehrswert (Fair Market Value), zu ermitteln durch einen einvernehmlich bestellten Wirtschaftsprüfer oder bei Nicht-Einigung durch einen von der IHK bestellten Sachverständigen. Bei Einziehung aufgrund Pflichtverletzung kann eine angemessene Reduktion erfolgen.{{/IF}}
(4) Anstelle der Einziehung kann die Gesellschafterversammlung beschließen, dass der betroffene Gesellschafter seine Geschäftsanteile an die Gesellschaft, an die übrigen Gesellschafter oder an einen Dritten zu übertragen hat{{#IF HAS_SHA}} entsprechend den Bestimmungen des SHA{{/IF}}.
{{#IF HAS_SHA}}
(5) Im Innenverhältnis zwischen den Gesellschaftern sind bei Einziehung und Übertragung von Geschäftsanteilen ausschließlich die Regelungen des SHA maßgeblich. Diese Satzungsbestimmung dient lediglich der Umsetzung und Ausführung der im SHA vorgesehenen Maßnahmen.
{{/IF}}
(6) Die Einziehung und deren Rechtsfolgen wirken gegenüber Dritten gemäß den Bestimmungen des GmbHG und werden mit Eintragung der Änderung in die Gesellschafterliste rechtswirksam.
{{#IF IS_MULTI_GESELLSCHAFTER}}
## § {{P_VORKAUF}} Vorkaufsrechte
(1) Beabsichtigt ein Gesellschafter (Veräußernder Gesellschafter") die Übertragung eines oder mehrerer Geschäftsanteile an einen Dritten, stehen den übrigen Gesellschaftern („Vorkaufsberechtigte") Vorkaufsrechte zu den Bedingungen dieses Paragraphen zu.{{#IF HAS_SHA}} Die Einzelheiten des Vorkaufsverfahrens richten sich ergänzend nach den Bestimmungen des SHA.{{/IF}}
(2) Der Veräußernde Gesellschafter hat den übrigen Gesellschaftern den beabsichtigten Verkauf in Textform anzuzeigen und dabei anzugeben:
(a) den vorgesehenen Erwerber,
(b) den Kaufpreis oder die sonstige Gegenleistung,
(c) die wesentlichen Bedingungen der Übertragung.
Die Anzeige löst das Vorkaufsverfahren aus.
(3) Die Vorkaufsberechtigten können ihr Vorkaufsrecht innerhalb von **{{VORKAUFSRECHT_TAGE}} Tagen** ab Zugang der Anzeige ausüben.{{#IF HAS_SHA}} Soweit das SHA eine abweichende Frist vorsieht, gilt diese vorrangig.{{/IF}}
(4) Üben mehrere Gesellschafter das Vorkaufsrecht aus, werden die angebotenen Geschäftsanteile im Verhältnis ihrer bisherigen Beteiligungsquoten zugeteilt, sofern die Gesellschafter nicht einvernehmlich etwas anderes beschließen.
(5) Werden die Vorkaufsrechte nicht oder nicht vollständig ausgeübt, darf der Veräußernde Gesellschafter seine Geschäftsanteile zu den angezeigten Bedingungen an den vorgesehenen Erwerber übertragen. Ändern sich die Bedingungen, ist das Vorkaufsverfahren erneut durchzuführen.
(6) Das Vorkaufsrecht gilt nicht, wenn die Übertragung:
(a) im Rahmen eines Tag-Along{{#IF HAS_SHA}} gemäß SHA{{/IF}} erfolgt,
(b) im Rahmen eines Drag-Along{{#IF HAS_SHA}} gemäß SHA{{/IF}} erfolgt,
(c) {{#IF HAS_SHA}}im Rahmen der Leaver-Regelungen gemäß SHA{{/IF}}{{#IF NOT HAS_SHA}}aufgrund Einziehung gemäß § 7{{/IF}} umgesetzt wird.
{{#IF HAS_SHA}}
(7) Für Ablauf, Fristen, Mechanik, Informationspflichten und die Zuordnung von Anteilen sind im Innenverhältnis ausschließlich die Bestimmungen des SHA maßgeblich. Diese Satzungsregelung dient der Umsetzung im Außenverhältnis.
{{/IF}}
## § {{P_TAGALONG}} Mitverkaufsrechte (Tag-Along)
(1) Beabsichtigt ein Gesellschafter (Veräußernder Gesellschafter"), Geschäftsanteile an einen Dritten zu übertragen, können die übrigen Gesellschafter im Innenverhältnis{{#IF HAS_SHA}} nach Maßgabe des SHA{{/IF}} verlangen, dass der Dritte ihre Geschäftsanteile zu gleichen Bedingungen mit erwirbt („Tag-Along-Recht").
(2) Ein Gesellschafter, der einen Verkauf beabsichtigt, hat der Gesellschafterversammlung den Verkauf in Textform anzuzeigen. Die Anzeige dient der Umsetzung der{{#IF HAS_SHA}} im SHA vorgesehenen{{/IF}} Tag-Along-Rechte.
(3) Der Veräußernde Gesellschafter ist verpflichtet, im Außenverhältnis sicherzustellen, dass der Erwerber die vom Tag-Along erfassten Geschäftsanteile der übrigen Gesellschafter mit erwirbt, sofern diese ihre Rechte{{#IF HAS_SHA}} nach dem SHA{{/IF}} fristgerecht ausüben.
(4) Das Vorkaufsrecht gemäß § {{P_VORKAUF}} dieser Satzung findet keine Anwendung, wenn ein Tag-Along ausgelöst wurde.
{{#IF HAS_SHA}}
(5) Ablauf, Fristen, Quoten, Gleichbehandlungsgrundsatz (Same Terms"), anzubietende Beteiligungsumfänge sowie sämtliche Modalitäten des Tag-Along ergeben sich ausschließlich aus dem SHA. Die Satzung regelt lediglich die zur Umsetzung erforderlichen externen Schritte.
{{/IF}}
(6) Die Übertragung der Geschäftsanteile erfolgt nach Maßgabe des GmbHG und wird mit notarieller Beurkundung und Eintragung in die Gesellschafterliste wirksam.
## § {{P_DRAGALONG}} Mitziehpflichten (Drag-Along)
(1) Werden die{{#IF HAS_SHA}} im SHA{{/IF}} geregelten Voraussetzungen für eine Mitziehpflicht (Drag-Along") erfüllt, sind die Gesellschafter verpflichtet, ihre Geschäftsanteile ganz oder teilweise zu den dort bestimmten Bedingungen an einen Dritten zu übertragen.
(2) Ein Gesellschafter, der eine Drag-Along-Transaktion veranlasst oder initiiert, hat die übrigen Gesellschafter hierüber in Textform zu informieren und alle zur Umsetzung des Drag-Along im Außenverhältnis notwendigen Erklärungen abzugeben. Die Gesellschafterversammlung kann zur Umsetzung der Drag-Along-Verpflichtungen sämtliche erforderlichen Beschlüsse fassen, einschließlich solcher zur Einziehung, Übertragung oder Neustrukturierung von Geschäftsanteilen.
(3) Im Falle eines Drag-Along{{#IF HAS_SHA}} gemäß den Bestimmungen des SHA{{/IF}}:
(a) finden die Vorkaufsrechte gemäß § {{P_VORKAUF}} dieser Satzung keine Anwendung,
(b) finden die Mitverkaufsrechte gemäß § {{P_TAGALONG}} dieser Satzung keine Anwendung.
{{#IF HAS_SHA}}
(4) Art, Umfang, Schwellenwerte (z. B. erforderliche Mehrheit), Bedingungen, Preis, Garantiestruktur, Fristen und sämtliche weiteren Modalitäten des Drag-Along bestimmen sich ausschließlich nach den Regelungen des SHA. Diese Satzungsregelung dient ausschließlich der gesellschaftsrechtlichen Umsetzung nach außen.
{{/IF}}
(5) Die Übertragung der Geschäftsanteile wird gegenüber der Gesellschaft und Dritten erst mit notarieller Beurkundung und Eintragung in die Gesellschafterliste wirksam (§ 15 GmbHG, § 40 GmbHG).
{{/IF}}
## § {{P_VERSAMMLUNG}} Gesellschafterversammlung
(1) Die Gesellschafterversammlung wird durch die Geschäftsführer gemäß den gesetzlichen Vorgaben (§ 51 GmbHG) einberufen. Die Einberufung erfolgt in Textform unter Angabe der Tagesordnung mit einer Frist von mindestens **{{EINBERUFUNGSFRIST_TAGE}} Tagen**, soweit nicht zwingend gesetzlich eine andere Frist vorgeschrieben ist.
(2) Die Gesellschafterversammlung kann stattfinden:
(a) als Präsenzversammlung,
(b) als Video- oder Telefonkonferenz,
(c) in hybrider Form,
(d) oder im schriftlichen Verfahren, sofern kein Gesellschafter widerspricht.
(3) Beschlüsse der Gesellschafter werden mit einfacher Mehrheit der abgegebenen Stimmen gefasst, soweit nicht:
(a) gesetzlich eine qualifizierte Mehrheit vorgeschrieben ist (z. B. Satzungsänderungen, Kapitalmaßnahmen), oder
(b) {{#IF HAS_SHA}}im SHA für bestimmte Angelegenheiten (Reserved Matters) eine höhere Mehrheit vorgesehen ist,{{/IF}}
(c) diese Satzung eine abweichende Mehrheit vorsieht.
(4) Jeder Gesellschafter hat je **{{VOTING_UNIT_EUR}} Euro** seines Geschäftsanteils eine Stimme, soweit das Gesetz nichts anderes bestimmt.
(5) Die Gesellschafterversammlung wird durch einen von den anwesenden Gesellschaftern gewählten Vorsitzenden geleitet. Der Vorsitzende bestimmt die Reihenfolge der Tagesordnungspunkte und die Art der Abstimmung.
(6) Über die Beschlüsse der Gesellschafterversammlung ist ein Protokoll anzufertigen, das vom Vorsitzenden und einem Geschäftsführer zu unterzeichnen ist. Bei schriftlichen Beschlüssen genügt die Dokumentation des Beschlusstextes.
{{#IF HAS_SHA}}
(7) Das SHA regelt im Innenverhältnis der Gesellschafter insbesondere:
- Reserved Matters (zustimmungspflichtige Angelegenheiten),
- Schwellenwerte und Quoren,
- Governance- und Informationsprozesse.
Diese Bestimmungen gelten im Innenverhältnis vorrangig vor der Satzung, soweit rechtlich zulässig. Die Satzung regelt ausschließlich die gesetzlich erforderlichen Rahmenbedingungen.
{{/IF}}
## § {{P_JA}} Jahresabschluss
(1) Der Jahresabschluss (Bilanz, Gewinn- und Verlustrechnung{{#IF HAS_LAGEBERICHT}} sowie Lagebericht{{/IF}}{{#IF NOT HAS_LAGEBERICHT}} und Anhang{{/IF}}) wird von den Geschäftsführern nach Maßgabe der gesetzlichen Vorschriften (§§ 242 ff., 264 ff. HGB) aufgestellt.
(2) Der Jahresabschluss ist innerhalb der gesetzlichen Frist (für kleine Gesellschaften: 6 Monate; für mittelgroße/große: 3 Monate) nach Ende des Geschäftsjahres aufzustellen.
(3) Der Jahresabschluss ist den Gesellschaftern unverzüglich nach Fertigstellung zur Einsichtnahme vorzulegen.
(4) Die Feststellung des Jahresabschlusses erfolgt durch Gesellschafterbeschluss.
(5) Gesetzliche Offenlegungspflichten (Bundesanzeiger, § 325 HGB) bleiben unberührt.
## § {{P_ERGEBNIS}} Verwendung des Ergebnisses
(1) Über die Verwendung des sich aus dem festgestellten Jahresabschluss ergebenden Ergebnisses beschließt die Gesellschafterversammlung gemäß den gesetzlichen Vorschriften (§ 29 GmbHG).
(2) Die Gesellschafterversammlung kann insbesondere beschließen:
(a) die Ausschüttung eines Gewinns,
(b) die Einstellung in Gewinnrücklagen,
(c) den Vortrag des Gewinns auf neue Rechnung.
(3) Die Ausschüttung an die Gesellschafter erfolgt im Verhältnis ihrer Geschäftsanteile, sofern die Gesellschafterversammlung nicht auf Grundlage gesetzlicher Vorgaben oder einstimmiger Zustimmung der Gesellschafter etwas anderes beschließt.
{{#IF IS_UG}}
(4) Solange die Gesellschaft als Unternehmergesellschaft (haftungsbeschränkt) firmiert, ist die gesetzlich vorgeschriebene Rücklage gemäß § 5a Abs. 3 GmbHG (ein Viertel des um den Verlustvortrag aus dem Vorjahr geminderten Jahresüberschusses) zu bilden. Die Rücklage kann erst aufgelöst werden, wenn das Stammkapital auf mindestens 25.000 EUR erhöht und die Gesellschaft in eine GmbH umgewandelt wird.
{{/IF}}
{{#IF IS_MULTI_GESELLSCHAFTER}}
## § {{P_AUFGRIFF}} Aufgriffsrechte bei Beendigung der Geschäftsführerstellung
(1) Die Abberufung eines Gesellschafter-Geschäftsführers oder die Beendigung seines Geschäftsführerdienstvertrags führt nicht automatisch zur Einziehung oder Übertragung seiner Geschäftsanteile.
(2) {{#IF HAS_SHA}}Soweit die Beendigung der Geschäftsführerstellung nach den Bestimmungen des SHA einen Vesting- oder Leaver-Tatbestand auslöst, können Einziehungs- oder Übertragungsbeschlüsse ausschließlich gemäß den Regelungen des SHA gefasst werden.{{/IF}}
(3) Ein Beschluss über:
(a) die Einziehung von Geschäftsanteilen oder
(b) die Verpflichtung des betroffenen Gesellschafters zur Übertragung seiner Geschäftsanteile
kann nur gefasst werden, wenn und soweit dies{{#IF HAS_SHA}} im SHA vorgesehen ist{{/IF}}{{#IF NOT HAS_SHA}} eine ausdrückliche gesellschaftsvertragliche Grundlage hat{{/IF}}. Die Durchführung richtet sich nach den Bestimmungen dieser Satzung und den §§ 34-35 GmbHG.
(4) {{#IF HAS_SHA}}Diese Satzung begründet kein eigenes Aufgriffsrecht. Die Ausgestaltung, Trigger und wirtschaftlichen Bedingungen eines Aufgriffs richten sich ausschließlich nach den Regelungen des SHA.{{/IF}}{{#IF NOT HAS_SHA}}Aufgriffsrechte bestehen nur, soweit gesetzlich vorgesehen.{{/IF}}
{{#IF HAS_ACADEMIC_FOUNDER}}
(5) Die Beendigung der Geschäftsführerstellung aus Gründen, die auf:
- einer Professur,
- sonstigen beruflichen Tätigkeiten,
- Änderungen der Verfügbarkeit,
- oder anderen Nebenbeschäftigungen
beruhen, stellt keinen eigenständigen Grund für ein Aufgriffsrecht dar und begründet insbesondere keine Bad-Leaver-Konsequenzen, sofern die Bestimmungen des SHA eingehalten werden.
{{/IF}}
(6) {{#IF HAS_SHA}}Im Innenverhältnis sind ausschließlich die Regelungen des SHA maßgeblich. Diese Satzungsregelung dient der gesellschaftsrechtlichen Umsetzung der dort vereinbarten Mechaniken.{{/IF}}
## § {{P_ABTRETUNG}} Abtretungsbeschränkungen
(1) Die Abtretung oder Verpfändung von Geschäftsanteilen oder Teilen davon bedarf, soweit nicht gesetzlich etwas anderes bestimmt ist, der Zustimmung der Gesellschafterversammlung.
(2) Die Zustimmung ist zu erteilen, wenn und soweit die Übertragung:
(a) {{#IF HAS_SHA}}nach den Bestimmungen des SHA zulässig oder vorgeschrieben ist, insbesondere im Zusammenhang mit Vorkaufsrechten, Mitverkaufsrechten (Tag-Along), Mitziehpflichten (Drag-Along), Leaver-Regelungen, Vesting-Regelungen oder Einziehung/Übertragung gemäß den vorstehenden Paragraphen dieser Satzung;{{/IF}}
(b) {{#IF HAS_SHA}}oder wenn der Erwerber nach Maßgabe des SHA zur Aufnahme in den Gesellschafterkreis berechtigt ist.{{/IF}}{{#IF NOT HAS_SHA}}im Einklang mit dem Gesellschaftsinteresse erfolgt und der Erwerber die für die Mitgliedschaft erforderlichen Voraussetzungen erfüllt.{{/IF}}
(3) Die Zustimmung kann nur aus wichtigem Grund verweigert werden, insbesondere wenn:
(a) {{#IF HAS_SHA}}der Erwerber offensichtlich nicht die Voraussetzungen gemäß SHA erfüllt,
(b) die Übertragung gegen die Regelungen des SHA verstößt,
(c) {{/IF}}die Einhaltung gesetzlicher Vorschriften nicht gewährleistet ist,
(d) der Erwerber ein Wettbewerber der Gesellschaft ist (sofern nicht die Gesellschafterversammlung einstimmig zustimmt).
(4) Die Abtretung bedarf der notariellen Beurkundung (§ 15 GmbHG). Sie wird gegenüber der Gesellschaft erst mit Zugang der Abtretungsurkunde und Eintragung der Änderung in die Gesellschafterliste wirksam.
{{#IF HAS_SHA}}
(5) Diese Satzungsregelung begründet keine eigenständigen Übertragungsrechte oder -pflichten. Im Innenverhältnis richten sich sämtliche Übertragungen ausschließlich nach den Bestimmungen des SHA.
{{/IF}}
(6) Eine ohne Zustimmung vorgenommene Übertragung ist im Innenverhältnis unwirksam.{{#IF HAS_SHA}} Die übrigen Gesellschafter können in diesem Fall Maßnahmen gemäß SHA (inkl. Aufgriffs-, Einziehungs- oder Abtretungsmechanismen) verlangen.{{/IF}}
{{/IF}}
## § {{P_ERBE}} Erbfall
(1) Beim Tod eines Gesellschafters geht der Geschäftsanteil auf die Erben gemäß den gesetzlichen Vorschriften über. Mehrere Erben können das Stimmrecht und Einsichtsrechte nur einheitlich durch einen Bevollmächtigten ausüben.
(2) Innerhalb von **{{ERBFALL_AUFGRIFFSFRIST_MONATE}} Monaten** nach Kenntnis vom Erbfall kann die Gesellschafterversammlung mit **{{ERBFALL_MEHRHEIT_PCT}} %** der abgegebenen Stimmen (der Erben-Geschäftsanteil zählt nicht mit) beschließen, dass der ererbte Geschäftsanteil:
(a) eingezogen wird, oder
(b) auf die übrigen Gesellschafter, die Gesellschaft oder einen Dritten gegen Abfindung übertragen wird.
(3) Die Abfindung richtet sich {{#IF HAS_SHA}}nach den Good-Leaver-Bestimmungen des SHA{{/IF}}{{#IF NOT HAS_SHA}}nach dem Verkehrswert (Fair Market Value){{/IF}}, sofern nicht die Erben fristgerecht ihre vollständige Aufnahme als Gesellschafter unter Anerkennung dieser Satzung{{#IF HAS_SHA}} und des SHA{{/IF}} erklären.
(4) Bis zur Beschlussfassung über die Einziehung/Übertragung ruhen die Stimmrechte des ererbten Anteils.
## § {{P_AUFL}} Auflösung und Liquidation der Gesellschaft
(1) Die Gesellschaft kann durch Gesellschafterbeschluss aufgelöst werden. Soweit nicht gesetzlich zwingend eine andere Mehrheit vorgeschrieben ist, bedarf der Auflösungsbeschluss einer Mehrheit von **{{AUFLOESUNG_MEHRHEIT_PCT}} %** der abgegebenen Stimmen.
(2) Im Übrigen richtet sich die Auflösung nach den gesetzlichen Vorschriften (§ 60 GmbHG).
(3) Die Liquidation erfolgt durch die Geschäftsführer, sofern die Gesellschafterversammlung keine anderen Personen zu Liquidatoren bestellt.
(4) Für die Liquidation gelten die gesetzlichen Vorschriften (§§ 61-74 GmbHG).
(5) Das nach Abwicklung verbleibende Vermögen wird nach Tilgung der Verbindlichkeiten an die Gesellschafter im Verhältnis ihrer Geschäftsanteile verteilt, soweit nicht{{#IF HAS_SHA}} im SHA{{/IF}} eine abweichende Verteilung (z. B. Liquidation Preference) vereinbart wurde.
## § {{P_SCHLUSS}} Schlussbestimmungen
(1) **Form der Gesellschafterbeschlüsse.** Beschlüsse der Gesellschafterversammlung sind schriftlich niederzulegen. Schriftliche und elektronische Umlaufbeschlüsse sind zulässig, sofern kein Gesellschafter dem Verfahren widerspricht und keine gesetzliche strengere Form (insbesondere notarielle Beurkundung) vorgeschrieben ist.
(2) **Satzungsänderungen.** Änderungen dieser Satzung sowie Maßnahmen, für die das Gesetz eine qualifizierte Mehrheit oder notarielle Beurkundung vorsieht, bedürfen eines entsprechenden Gesellschafterbeschlusses (mindestens 3/4 der abgegebenen Stimmen, § 53 GmbHG) und der notariellen Beurkundung.
(3) **Salvatorische Klausel.** Sollte eine Bestimmung dieser Satzung unwirksam oder undurchführbar sein oder werden, bleibt die Wirksamkeit der übrigen Bestimmungen unberührt. Die unwirksame Bestimmung ist durch eine solche zu ersetzen, die dem wirtschaftlichen Zweck der unwirksamen Bestimmung in rechtlich zulässiger Weise am nächsten kommt.
(4) **Vorrang gesetzlicher Vorschriften.** Zwingende gesetzliche Bestimmungen des GmbHG bleiben unberührt und gehen im Zweifel den Regelungen dieser Satzung vor.
(5) **Gesellschafterliste.** Die Geschäftsführer haben jede Veränderung im Gesellschafterbestand unverzüglich zur Eintragung in die Gesellschafterliste beim Handelsregister zu veranlassen (§ 40 GmbHG).
(6) **Gründungskosten.** Die Gesellschaft trägt die mit ihrer Gründung verbundenen Kosten (Notar, Handelsregister, Veröffentlichung) bis zu einem Höchstbetrag von **{{GRUENDUNGSKOSTEN_MAX_EUR}} EUR**. Übersteigende Kosten tragen die Gesellschafter im Verhältnis ihrer Geschäftsanteile.
(7) **Sprache.** Diese Satzung ist in deutscher Sprache abgefasst. Bei Mehrsprachigkeit ist ausschließlich die deutsche Fassung maßgeblich.
---
**Notariell beurkundet am {{NOTARIAL_DATE}} durch {{NOTARY_NAME}} in {{NOTARY_PLACE}} (URNr. {{NOTARY_URNR}}).**
_{{SIGNATURES_BLOCK}}_
$template$,
'["COMPANY_NAME","COMPANY_LEGAL_FORM","COMPANY_SEAT","DOCUMENT_VERSION","EFFECTIVE_DATE","NOTARY_NAME","NOTARY_PLACE","NOTARY_URNR","NOTARIAL_DATE","BUSINESS_YEAR","FIRST_YEAR_END","PUBLICATION_VENUE","COMPANY_PURPOSE_DESCRIPTION","COMPANY_PURPOSE_BULLETS","STAMMKAPITAL_EUR","PARTIES_LIST_WITH_SHARES","HAS_SHA","HAS_GO_GF","HAS_ACADEMIC_FOUNDER","HAS_SACHEINLAGE","IS_UG","IS_MULTI_GESELLSCHAFTER","HAS_LAGEBERICHT","EINLAGE_METHOD","EINLAGE_QUOTE_INITIAL_PCT","EINLAGE_QUOTE_INITIAL_LESS_THAN_100","EINLAGE_QUOTE_REMAINING_PCT","VERZUGSFRIST_TAGE","EINZIEHUNG_MEHRHEIT_PCT","VORKAUFSRECHT_TAGE","EINBERUFUNGSFRIST_TAGE","VOTING_UNIT_EUR","ERBFALL_AUFGRIFFSFRIST_MONATE","ERBFALL_MEHRHEIT_PCT","AUFLOESUNG_MEHRHEIT_PCT","GRUENDUNGSKOSTEN_MAX_EUR","SIGNATURES_BLOCK","P_EINZIEHUNG","P_VORKAUF","P_TAGALONG","P_DRAGALONG","P_VERSAMMLUNG","P_JA","P_ERGEBNIS","P_AUFGRIFF","P_ABTRETUNG","P_ERBE","P_AUFL","P_SCHLUSS"]'::jsonb,
'de',
'DE',
'mit',
'MIT License',
'BreakPilot Compliance',
false,
true,
'1.0.0',
'published',
NOW(), NOW()
;
-- Verifikation
SELECT
document_type,
title,
LENGTH(content) AS content_chars,
status, version
FROM compliance_legal_templates
WHERE document_type = 'articles_of_association'
ORDER BY created_at DESC
LIMIT 1;
@@ -0,0 +1,265 @@
-- Migration 126: Geschaeftsfuehrerdienstvertrag (GF-Dienstvertrag) Template
-- Erstellt nach 3-fach-Check Methode (Absatz-fuer-Absatz Review, 2026-05-19)
-- Verhaeltnis: Trennungsprinzip Organstellung vs. Anstellungsvertrag (mit optionaler Kopplung)
-- Optionale Bloecke: HAS_SHA, HAS_GO_GF, HAS_ACADEMIC_FOUNDER, HAS_RESEARCH_FOCUS, HAS_BONUS,
-- HAS_TANTIEME, HAS_COMPANY_CAR, HAS_BAV, HAS_HINTERBLIEBENEN_VERSORGUNG, IS_MULTI_GF,
-- HAS_PARA_181_RELEASE, HAS_KOPPLUNG_BESTELLUNG_VERTRAG, HAS_NONCOMPETE_COMPENSATION
-- Ergaenzungen ggue. Quelltext: Sozialversicherungsstatus (§ 7a SGB IV), Lohnfortzahlung,
-- Hinterbliebenenversorgung, Post-Exit-Wettbewerbsverbot mit Karenzentschaedigung (§ 74 HGB),
-- D&O mit Mindest-Deckungssumme, Business Judgment Rule, InsO § 15a-Hinweis, GeschGehG § 17
INSERT INTO compliance_legal_templates (
id, tenant_id, document_type, title, description, content,
placeholders, language, jurisdiction,
license_id, license_name, source_name,
attribution_required, is_complete_document, version, status,
created_at, updated_at
) SELECT
gen_random_uuid(),
'9282a473-5c95-4b3a-bf78-0ecc0ec71d3e',
'managing_director_employment_contract',
'Geschäftsführerdienstvertrag (GmbH/UG)',
'Anstellungsvertrag fuer Organgeschaeftsfuehrer einer GmbH/UG nach Trennungsprinzip. Regelt Bestellung, Vertretung (inkl. § 181 BGB-Befreiung), Aufgaben, Vertraulichkeit/IP (mit ArbnErfG-Abgrenzung), Wettbewerbsverbot (laufend und nachvertraglich mit Karenzentschaedigung gemaess § 74 HGB), Verguetung (Fix, Bonus, Tantieme, Firmenwagen, bAV), Sozialversicherungsstatus mit Statusfeststellungsverfahren, Krankheits- und Hinterbliebenenversorgung, D&O-Versicherung mit Business Judgment Rule, Reporting, Trennungsprinzip Organstellung vs. Anstellung, optionale Kopplung. Konform §§ 35 ff. GmbHG, § 43 GmbHG, § 626 BGB, § 7a SGB IV, § 15a InsO, GeschGehG.',
$template$
# Geschäftsführerdienstvertrag
zwischen
**{{COMPANY_NAME}}**, {{COMPANY_ADDRESS}}, vertreten durch {{COMPANY_REPRESENTATIVE}} aufgrund Beschluss der Gesellschafterversammlung vom {{RESOLUTION_DATE}}
- nachfolgend Gesellschaft" -
und
**{{GF_NAME}}**, geboren am {{GF_BIRTHDATE}}, wohnhaft in {{GF_ADDRESS}}
- nachfolgend Geschäftsführer" -
---
## Dokumentenkontrolle
| Feld | Wert |
|---|---|
| Vertragstyp | Geschäftsführerdienstvertrag |
| Gesellschaft | {{COMPANY_NAME}} ({{COMPANY_LEGAL_FORM}}) |
| Beginn | {{CONTRACT_START_DATE}} |
| Version | {{DOCUMENT_VERSION}} |
---
## § 1 Bestellung, Aufgaben, Trennungsprinzip
(1) Der Geschäftsführer wird gemäß Beschluss der Gesellschafterversammlung vom {{APPOINTMENT_DATE}} zum Organ der Gesellschaft bestellt. Die Bestellung als Geschäftsführer (Organstellung) und dieser Anstellungsvertrag (schuldrechtliches Verhältnis) sind rechtlich getrennt zu betrachten (Trennungsprinzip"). Die Abberufung als Geschäftsführer führt nicht automatisch zur Beendigung dieses Vertrages, es sei denn, eine ausdrückliche Koppelung wurde vereinbart (§ 14).
(2) Der Geschäftsführer führt die Geschäfte der Gesellschaft selbstständig, eigenverantwortlich und mit der Sorgfalt eines ordentlichen Geschäftsleiters (§ 43 Abs. 1 GmbHG).
(3) Aufgaben sowie die interne Rollenverteilung ergeben sich aus:
(a) der Satzung,
{{#IF HAS_SHA}}(b) dem Shareholders' Agreement (SHA),{{/IF}}
{{#IF HAS_GO_GF}}(c) der Geschäftsordnung für die Geschäftsführung (GO-GF),{{/IF}}
(d) den Beschlüssen der Gesellschafterversammlung.
(4) Interne Rollen und Funktionstitel (z. B. {{GF_INTERNAL_TITLE}}) haben keine Außenwirkung und beschränken nicht die gesetzliche Gesamtverantwortung gemäß §§ 35 ff. GmbHG.
## § 2 Vertretung
(1) Der Geschäftsführer vertritt die Gesellschaft entsprechend der Satzung und des Handelsregistereintrags.
(2) Eine Befreiung von den Beschränkungen des § 181 BGB gilt nur, sofern und soweit sie durch Gesellschafterbeschluss ausdrücklich erteilt wurde{{#IF HAS_PARA_181_RELEASE}}; sie wurde erteilt durch Beschluss vom {{PARA_181_RELEASE_DATE}}{{/IF}}.
## § 3 Arbeitszeit, Verfügbarkeit
(1) Der Geschäftsführer ist nicht an feste Arbeitszeiten gebunden. Er widmet seine Arbeitszeit und Arbeitskraft der Gesellschaft in dem Umfang, der zur ordnungsgemäßen Wahrnehmung seiner Aufgaben erforderlich ist.
(2) Über die Lage von Urlaub, Abwesenheit und längeren Reisezeiten stimmt er sich mit den Mitgesellschaftern{{#IF IS_MULTI_GF}} und Mitgeschäftsführern{{/IF}} ab.
(3) Mobiles Arbeiten ist zulässig, soweit die ordnungsgemäße Wahrnehmung der Geschäftsführungsaufgaben gewährleistet bleibt.
## § 4 Nebentätigkeiten und Wettbewerbsverbot
(1) Nebentätigkeiten (entgeltlich oder unentgeltlich) bedürfen der vorherigen schriftlichen Anzeige und Zustimmung der Gesellschafterversammlung. Die Zustimmung ist zu erteilen, wenn die Nebentätigkeit:
(a) nicht in unmittelbarem Wettbewerb zur Gesellschaft steht,
(b) keine vertraulichen Informationen oder geistigen Eigentumsrechte der Gesellschaft nutzt,
(c) die Pflichten aus diesem Vertrag nicht wesentlich beeinträchtigt.
{{#IF HAS_ACADEMIC_FOUNDER}}
(2) Akademische Tätigkeiten, Lehrtätigkeiten und wissenschaftliche Publikationen sind zulässig, sofern sie die in Abs. (1) genannten Voraussetzungen erfüllen. Die Aufnahme einer Professur oder vergleichbaren akademischen Tätigkeit ist anzeigepflichtig, gilt jedoch nicht automatisch als Pflichtverletzung.
{{/IF}}
(3) Der Geschäftsführer unterliegt während seiner Amtszeit einem Wettbewerbsverbot{{#IF HAS_SHA}} entsprechend den Regelungen im SHA{{/IF}}. Er darf insbesondere keine Unternehmen gründen oder unterstützen, die in direktem Wettbewerb zur Gesellschaft stehen.
(4) **Post-Exit-Wettbewerbsverbot.** Für die Zeit nach Beendigung dieses Vertrages gilt ein nachvertragliches Wettbewerbsverbot von **{{POST_CONTRACT_NONCOMPETE_MONTHS}} Monaten**, sofern und soweit{{#IF HAS_NONCOMPETE_COMPENSATION}} der Geschäftsführer eine Karenzentschädigung in Höhe von mindestens 50 % der zuletzt bezogenen Vergütung erhält{{/IF}}{{#IF NOT HAS_NONCOMPETE_COMPENSATION}} dies im Einzelfall durch Zusatzvereinbarung mit Karenzentschädigung geregelt wurde{{/IF}}. § 74 ff. HGB analog.
## § 5 Vergütung
(1) **Fixum.** Der Geschäftsführer erhält ein Jahresgrundgehalt in Höhe von **{{GROSS_ANNUAL_SALARY_EUR}} EUR brutto**, zahlbar in 12 gleichen Monatsraten jeweils zum Monatsende.
{{#IF HAS_BONUS}}
(2) **Variable Vergütung (Bonus).** Zusätzlich kann eine variable Vergütung nach Maßgabe eines durch Gesellschafterbeschluss festgelegten Bonusplans gewährt werden. Bonusziele und -höhe werden jährlich vereinbart. Bonusplan ist Anlage 1 zu diesem Vertrag.
{{/IF}}
{{#IF HAS_TANTIEME}}
(3) **Tantieme.** Bei Erreichen bestimmter Kennzahlen kann eine Tantieme gemäß separater Tantiemevereinbarung gewährt werden.
{{/IF}}
(4) **Reisekosten und Auslagen.** Reisekosten, Spesen und weitere notwendige Aufwendungen werden gegen Nachweis nach Maßgabe der gesetzlichen und unternehmensinternen Reisekostenrichtlinien erstattet.
{{#IF HAS_COMPANY_CAR}}
(5) **Firmenfahrzeug.** Dem Geschäftsführer wird ein Firmenfahrzeug der Klasse {{COMPANY_CAR_CLASS}} zur dienstlichen und privaten Nutzung überlassen. Die geldwerte Vorteilsversteuerung trägt der Geschäftsführer.
{{/IF}}
{{#IF HAS_BAV}}
(6) **Betriebliche Altersvorsorge (bAV).** Die Gesellschaft trägt einen Arbeitgeberbeitrag in Höhe von {{BAV_EMPLOYER_PCT}} % der Vergütung zur betrieblichen Altersvorsorge.
{{/IF}}
(7) **Überprüfung.** Die Vergütung unterliegt der jährlichen Überprüfung durch die Gesellschafterversammlung. Eine Anpassung ist bei wesentlich veränderten Umständen vorzunehmen.
## § 6 Sozialversicherungsstatus
(1) Die Parteien gehen davon aus, dass der Geschäftsführer **{{SV_STATUS}}** ist. Maßgeblich ist die Beurteilung durch die zuständige Sozialversicherungsbehörde (Statusfeststellungsverfahren gemäß § 7a SGB IV).
(2) Sollte sich der Status nach Statusfeststellung ändern, werden die Parteien diesen Vertrag entsprechend anpassen. Etwaige Nachforderungen oder Erstattungen werden anteilig getragen.
## § 7 Urlaub
(1) Der Geschäftsführer hat Anspruch auf **{{VACATION_DAYS}} Urlaubstage** pro Kalenderjahr (bei 5-Tage-Woche).
(2) Die Urlaubsplanung erfolgt im gegenseitigen Einvernehmen mit den Mitgesellschaftern{{#IF IS_MULTI_GF}} und Mitgeschäftsführern{{/IF}}. Eine Urlaubsnahme ist so zu organisieren, dass die Handlungsfähigkeit der Gesellschaft jederzeit gewährleistet bleibt.
(3) Nicht genommener Urlaub kann mit Zustimmung der Gesellschafterversammlung in das Folgejahr übertragen werden, ist aber bis spätestens 31. März des Folgejahres zu nehmen.
## § 8 Arbeitsunfähigkeit, Hinterbliebenenversorgung
(1) **Lohnfortzahlung.** Im Krankheitsfall wird die Vergütung für die Dauer von **{{KRANKHEIT_FORTZAHLUNG_WOCHEN}} Wochen** fortgezahlt. Danach besteht Anspruch auf Krankentagegeld nach Maßgabe der ggf. abgeschlossenen Krankentagegeldversicherung.
(2) Der Geschäftsführer ist verpflichtet, eine Arbeitsunfähigkeit unverzüglich anzuzeigen und ab dem **{{AU_BESCHEINIGUNG_TAG}}.** Tag eine ärztliche Bescheinigung vorzulegen.
{{#IF HAS_HINTERBLIEBENEN_VERSORGUNG}}
(3) **Hinterbliebenenversorgung.** Im Todesfall erhalten die Hinterbliebenen (Ehegatte, eingetragener Lebenspartner oder unterhaltsberechtigte Kinder) eine Hinterbliebenenleistung in Höhe von {{HINTERBLIEBENEN_VERSORGUNG_MONATE}} Monatsgehältern.
{{/IF}}
## § 9 Haftung, D&O-Versicherung
(1) Die Haftung des Geschäftsführers richtet sich nach § 43 GmbHG. Bei Verletzung der Sorgfaltspflicht haftet er der Gesellschaft auf Schadensersatz.
(2) Die Gesellschaft verpflichtet sich, eine **D&O-Versicherung (Directors & Officers Liability Insurance)** mit einer Deckungssumme von mindestens **{{DO_INSURANCE_EUR}} EUR** abzuschließen und während der gesamten Dauer dieses Vertrages aufrechtzuerhalten. Ein angemessener Selbstbehalt gemäß § 93 Abs. 2 Satz 3 AktG analog wird einbezogen.
(3) Die Gesellschaft verzichtet auf Schadensersatzansprüche aus leichter Fahrlässigkeit, soweit dies gesetzlich zulässig ist.
(4) Bei Pflichtverletzungen, die durch das Geschäftsmodell oder strategische Entscheidungen der Gesellschafter veranlasst sind, wird der Geschäftsführer von Ansprüchen Dritter freigestellt, soweit er nicht grob fahrlässig oder vorsätzlich gehandelt hat (Business Judgment Rule").
## § 10 Reporting und Informationspflichten
(1) Der Geschäftsführer berichtet der Gesellschafterversammlung{{#IF HAS_GO_GF}} gemäß den Vorgaben der GO-GF{{/IF}}{{#IF HAS_SHA}}{{#IF HAS_GO_GF}} und{{/IF}}{{#IF NOT HAS_GO_GF}} gemäß den Vorgaben{{/IF}} des SHA{{/IF}}, insbesondere hinsichtlich:
(a) finanzieller Lage (Liquidität, P&L, Cashflow),
(b) Produktentwicklung und technischer Roadmap,
{{#IF HAS_RESEARCH_FOCUS}}(c) Forschung & Technologie,
(d) {{/IF}}operativer Kennzahlen,
{{#IF HAS_RESEARCH_FOCUS}}(e){{/IF}}{{#IF NOT HAS_RESEARCH_FOCUS}}(c){{/IF}} Risiken und rechtlicher Entwicklungen.
(2) Er ist verpflichtet, alle Umstände unverzüglich zu melden, die:
(a) die Liquidität der Gesellschaft gefährden,
(b) erhebliche rechtliche Risiken begründen (insbesondere drohende Insolvenz, Massengläubiger),
(c) wesentlichen Einfluss auf Geschäft, Compliance oder Reputation haben.
(3) Bei drohender Insolvenz hat der Geschäftsführer die Pflichten gemäß § 15a InsO zu beachten und unverzüglich die Gesellschafter zu informieren.
## § 11 Vertraulichkeit und geistiges Eigentum (IP)
(1) **Vertraulichkeit.** Der Geschäftsführer verpflichtet sich zur strikten Vertraulichkeit über alle Geschäfts- und Betriebsgeheimnisse der Gesellschaft. Diese Verpflichtung besteht zeitlich unbegrenzt über das Vertragsende hinaus fort, soweit rechtlich zulässig (insb. § 17 GeschGehG).
(2) **IP-Übergang.** Sämtliche vom Geschäftsführer während seiner Tätigkeit geschaffenen oder mitentwickelten Werke, Erfindungen, Konzepte, Modelle, Software, Daten, Designs, Marken oder sonstigen Schutzrechtsfähigen Ergebnisse gehen vollständig und exklusiv auf die Gesellschaft über. Die hierfür erforderlichen Übertragungserklärungen werden hiermit abgegeben.
(3) **Arbeitnehmererfindergesetz.** Das Arbeitnehmererfindergesetz (ArbnErfG) findet keine Anwendung auf den Geschäftsführer (Organstellung). Etwaige Erfindungen sind unverzüglich der Gesellschaft zu melden; eine Vergütung kann durch Zusatzvereinbarung geregelt werden.
(4) **Veröffentlichungen.** Veröffentlichungen, Vorträge und Beiträge, die Bezug zur Tätigkeit haben, bedürfen der vorherigen schriftlichen Zustimmung der Gesellschaft:
(a) sie dürfen die Interessen der Gesellschaft nicht beeinträchtigen,
(b) keine vertraulichen Informationen oder Geschäftsgeheimnisse nutzen,
(c) im Zweifel ist eine interne Abstimmung erforderlich.
(5) **Open Source.** Beiträge zu Open-Source-Projekten sind zulässig, sofern keine Unternehmens-IP genutzt wird und die Gesellschaft vorab zustimmt.
## § 12 Innenverhältnis zu Gesellschaftern, Rangfolge
(1) Dieser Vertrag wirkt im Innenverhältnis ergänzend zu Satzung{{#IF HAS_SHA}} und SHA{{/IF}}.
(2) Bei Widersprüchen gilt folgende Rangfolge:
1. zwingendes Recht (GmbHG, BGB, HGB, etc.)
2. Satzung
{{#IF HAS_SHA}}3. Shareholders' Agreement (SHA){{/IF}}
{{#IF HAS_SHA}}4.{{/IF}}{{#IF NOT HAS_SHA}}3.{{/IF}} Geschäftsführerdienstvertrag
{{#IF HAS_GO_GF}}{{#IF HAS_SHA}}5.{{/IF}}{{#IF NOT HAS_SHA}}4.{{/IF}} Geschäftsordnung für die Geschäftsführung (GO-GF){{/IF}}
## § 13 Vertragsdauer und Kündigung
(1) Der Vertrag beginnt am **{{CONTRACT_START_DATE}}** und läuft auf unbestimmte Zeit.
(2) **Ordentliche Kündigung.** Beide Parteien können den Vertrag mit folgenden Fristen ordentlich kündigen:
(a) durch die Gesellschaft: **{{KUENDIGUNGSFRIST_GESELLSCHAFT_MONATE}} Monate** zum Monatsende,
(b) durch den Geschäftsführer: **{{KUENDIGUNGSFRIST_GF_MONATE}} Monate** zum Monatsende.
(3) **Außerordentliche Kündigung.** Das Recht zur außerordentlichen Kündigung aus wichtigem Grund (§ 626 BGB) bleibt unberührt.
(4) **Schriftform.** Kündigungen bedürfen der Schriftform.
{{#IF HAS_KOPPLUNG_BESTELLUNG_VERTRAG}}
(5) **Kopplung.** Abweichend vom Trennungsprinzip (§ 1 Abs. 1) endet dieser Vertrag automatisch mit der Beendigung der Bestellung als Geschäftsführer, sofern die Beendigung der Bestellung durch die Gesellschaft erfolgte und kein wichtiger Grund für die Aufrechterhaltung des Anstellungsverhältnisses besteht.
{{/IF}}
## § 14 Beendigung der Geschäftsführerstellung
(1) Mit der Abberufung als Geschäftsführer endet die Organstellung, nicht jedoch automatisch dieser Dienstvertrag{{#IF HAS_KOPPLUNG_BESTELLUNG_VERTRAG}} (vorbehaltlich § 13 Abs. 5){{/IF}}.
(2) Etwaige Regelungen zu:
(a) Vesting,
(b) Leaver-Kategorien (Good / Neutral / Bad),
(c) Anteilsübertragungen,
(d) Einziehung von Geschäftsanteilen
richten sich ausschließlich nach{{#IF HAS_SHA}} dem SHA und{{/IF}} der Satzung, nicht nach diesem Vertrag.
## § 15 Rückgabe von Unterlagen und Arbeitsmitteln
Bei Vertragsende sind sämtliche Unterlagen, Geräte (Laptop, Mobiltelefon, Firmenfahrzeug etc.), Daten, Datenträger, Schlüssel und sonstigen Arbeitsmittel unverzüglich und vollständig an die Gesellschaft zurückzugeben. Digitale Kopien sind nachweislich zu löschen.
## § 16 Datenschutz
Der Geschäftsführer wurde über die Verarbeitung seiner personenbezogenen Daten gemäß Art. 13 DSGVO informiert. Eine entsprechende Datenschutzerklärung wird ihm zum Vertragsbeginn ausgehändigt.
## § 17 Schlussbestimmungen
(1) **Schriftform.** Änderungen und Ergänzungen dieses Vertrages bedürfen der Schriftform. Dies gilt auch für die Aufhebung des Schriftformerfordernisses.
(2) **Salvatorische Klausel.** Sollten einzelne Bestimmungen dieses Vertrages unwirksam sein oder werden, bleibt der Vertrag im Übrigen wirksam. An die Stelle der unwirksamen Bestimmung tritt eine solche, die dem wirtschaftlichen Zweck am nächsten kommt.
(3) **Anwendbares Recht und Gerichtsstand.** Dieser Vertrag unterliegt deutschem Recht. Gerichtsstand ist der Sitz der Gesellschaft, soweit gesetzlich zulässig.
(4) **Anlagen.** Folgende Anlagen sind Bestandteil dieses Vertrages:
{{ANNEX_LIST}}
---
**{{COMPANY_SEAT}}, {{SIGNATURE_DATE}}**
___________________________
Für die Gesellschaft
___________________________
{{GF_NAME}} (Geschäftsführer)
$template$,
'["COMPANY_NAME","COMPANY_LEGAL_FORM","COMPANY_ADDRESS","COMPANY_SEAT","COMPANY_REPRESENTATIVE","RESOLUTION_DATE","APPOINTMENT_DATE","GF_NAME","GF_BIRTHDATE","GF_ADDRESS","GF_INTERNAL_TITLE","CONTRACT_START_DATE","DOCUMENT_VERSION","HAS_SHA","HAS_GO_GF","HAS_ACADEMIC_FOUNDER","HAS_RESEARCH_FOCUS","HAS_BONUS","HAS_TANTIEME","HAS_COMPANY_CAR","HAS_BAV","HAS_HINTERBLIEBENEN_VERSORGUNG","IS_MULTI_GF","HAS_PARA_181_RELEASE","HAS_KOPPLUNG_BESTELLUNG_VERTRAG","HAS_NONCOMPETE_COMPENSATION","PARA_181_RELEASE_DATE","POST_CONTRACT_NONCOMPETE_MONTHS","GROSS_ANNUAL_SALARY_EUR","COMPANY_CAR_CLASS","BAV_EMPLOYER_PCT","SV_STATUS","VACATION_DAYS","KRANKHEIT_FORTZAHLUNG_WOCHEN","AU_BESCHEINIGUNG_TAG","HINTERBLIEBENEN_VERSORGUNG_MONATE","DO_INSURANCE_EUR","KUENDIGUNGSFRIST_GESELLSCHAFT_MONATE","KUENDIGUNGSFRIST_GF_MONATE","ANNEX_LIST","SIGNATURE_DATE"]'::jsonb,
'de',
'DE',
'mit',
'MIT License',
'BreakPilot Compliance',
false,
true,
'1.0.0',
'published',
NOW(), NOW()
;
SELECT document_type, title, LENGTH(content) AS content_chars, status, version
FROM compliance_legal_templates WHERE document_type = 'managing_director_employment_contract' ORDER BY created_at DESC LIMIT 1;
@@ -0,0 +1,262 @@
-- Migration 127: Standard-Arbeitsvertrag Template (deutsches Arbeitsrecht)
-- Erstellt nach 3-fach-Check Methode (Absatz-fuer-Absatz Review, 2026-05-20)
-- Rollenneutral: Position, Aufgaben, Vertraulichkeitsthemen, IP-Scope sind Wizard-befuellt
-- Optionale Bloecke: IS_FIXED_TERM, IS_PART_TIME, HAS_OVERTIME_LUMPSUM, HAS_REMOTE_WORK,
-- HAS_BONUS, HAS_BAV_INFO, HAS_BAV_EMPLOYER, HAS_PRIVATE_USE_ALLOWED, HAS_OPEN_SOURCE_CONTRIBUTIONS,
-- HAS_POST_EMPLOYMENT_NONCOMPETE, HAS_EXTENDED_NOTICE, HAS_BONUS_RECHTSANSPRUCH
-- Ergaenzungen ggue. Quelltext: AGG-neutrale Anrede, NachwG-Hinweis, eAU-Pflicht,
-- Mutterschutz/Elternzeit/Pflegezeit, Mindestlohn-Schutz vor Anrechnung, Schriftform § 623 BGB,
-- doppelte Schriftformklausel, Datenschutzinformation Art. 13 DSGVO, Verpflichtungserklaerung BDSG
INSERT INTO compliance_legal_templates (
id, tenant_id, document_type, title, description, content,
placeholders, language, jurisdiction,
license_id, license_name, source_name,
attribution_required, is_complete_document, version, status,
created_at, updated_at
) SELECT
gen_random_uuid(),
'9282a473-5c95-4b3a-bf78-0ecc0ec71d3e',
'employment_contract_de',
'Arbeitsvertrag (Deutsches Arbeitsrecht)',
'Standard-Arbeitsvertrag fuer Beschaeftigte (m/w/d) nach deutschem Arbeitsrecht. Rollenneutral durch Wizard-befuellbare Position, Aufgaben, Arbeitsmittel, Vertraulichkeitsthemen und IP-Scope. Optionen: Befristung (mit Sachgrund), Voll-/Teilzeit, Probezeit, Ueberstundenpauschale, Mobiles Arbeiten, Bonus (mit/ohne Freiwilligkeitsvorbehalt), bAV mit Arbeitgeberzuschuss, Open-Source-Beitraege, nachvertragliches Wettbewerbsverbot mit Karenzentschaedigung, verlaengerte Kuendigungsfristen. Konform §§ 611a, 622, 623, 626 BGB, NachwG, KSchG, EFZG, BUrlG, MuSchG, BEEG, ArbZG, MiLoG, ArbnErfG, GeschGehG, BDSG, DSGVO.',
$template$
# Arbeitsvertrag
zwischen
**{{COMPANY_NAME}}**, {{COMPANY_ADDRESS}}, vertreten durch die Geschäftsführung
- nachfolgend Arbeitgeber" -
und
**{{EMPLOYEE_NAME}}**, geboren am {{EMPLOYEE_BIRTHDATE}}, wohnhaft in {{EMPLOYEE_ADDRESS}}
- nachfolgend Arbeitnehmerin/Arbeitnehmer" oder „Beschäftigte/Beschäftigter" -
---
## Dokumentenkontrolle
| Feld | Wert |
|---|---|
| Vertragstyp | Arbeitsvertrag |
| Arbeitgeber | {{COMPANY_NAME}} ({{COMPANY_LEGAL_FORM}}) |
| Position | {{POSITION_TITLE}} |
| Beginn | {{CONTRACT_START_DATE}} |
| Beschäftigungsart | {{EMPLOYMENT_TYPE}} |
| Version | {{DOCUMENT_VERSION}} |
---
## § 1 Beginn des Arbeitsverhältnisses, Probezeit
(1) Das Arbeitsverhältnis beginnt am **{{CONTRACT_START_DATE}}**.{{#IF IS_FIXED_TERM}} Es ist befristet bis zum {{CONTRACT_END_DATE}}. Der Sachgrund der Befristung ist: {{FIXED_TERM_REASON}}.{{/IF}}{{#IF NOT IS_FIXED_TERM}} Es wird auf unbestimmte Zeit geschlossen.{{/IF}}
(2) **Probezeit.** Die ersten **{{PROBATION_MONTHS}} Monate** gelten als Probezeit. Während der Probezeit kann das Arbeitsverhältnis von beiden Seiten mit einer Frist von **{{PROBATION_NOTICE_WEEKS}} Wochen** zum Ablauf eines Kalendertages gekündigt werden (§ 622 Abs. 3 BGB).
(3) **Arbeitsort.** Der Arbeitsplatz befindet sich am Sitz der Gesellschaft ({{WORKPLACE_LOCATION}}). Vorübergehende Einsätze an anderen Orten innerhalb Deutschlands sind im zumutbaren Umfang möglich.
## § 2 Tätigkeit und Aufgaben
(1) Die/der Beschäftigte wird als **{{POSITION_TITLE}}** beschäftigt.
(2) Aufgaben umfassen insbesondere:
{{POSITION_TASKS}}
(3) Der Arbeitgeber kann der/dem Beschäftigten andere, gleichwertige Aufgaben übertragen, sofern berechtigte betriebliche Interessen vorliegen und die Aufgaben der Qualifikation und Erfahrung entsprechen.
(4) Eine Versetzung an einen anderen Arbeitsort innerhalb des Bundesgebiets ist mit angemessener Ankündigungsfrist möglich.
## § 3 Arbeitszeit
(1) Die regelmäßige wöchentliche Arbeitszeit beträgt **{{WORK_HOURS_WEEK}} Stunden** (Vollzeit{{#IF IS_PART_TIME}}, Teilzeit{{/IF}}).
(2) Beginn und Ende der täglichen Arbeitszeit können flexibel im Rahmen betrieblicher Erfordernisse gestaltet werden. Kernarbeitszeit: {{CORE_HOURS}}.
(3) **Überstunden.** {{#IF HAS_OVERTIME_LUMPSUM}}Bis zu **{{OVERTIME_LUMPSUM_PCT}} %** der monatlichen Arbeitszeit (entspricht ca. {{OVERTIME_LUMPSUM_HOURS}} Stunden) sind mit dem Gehalt abgegolten. Darüber hinausgehende Überstunden werden im Verhältnis 1:1 durch Freizeit ausgeglichen oder mit dem anteiligen Stundengehalt vergütet. **Hinweis:** Pauschalabgeltung gilt nur, soweit die durchschnittliche Wochenarbeitszeit 48 Stunden nicht übersteigt und die Vergütung über dem Mindestlohnniveau bleibt.{{/IF}}{{#IF NOT HAS_OVERTIME_LUMPSUM}}Überstunden werden grundsätzlich nicht angeordnet. Falls in Ausnahmefällen erforderlich, werden sie durch Freizeit oder anteilige Vergütung ausgeglichen. Eine Pauschalabgeltung erfolgt nicht.{{/IF}}
(4) **Mobiles Arbeiten / Home-Office.** {{#IF HAS_REMOTE_WORK}}Mobiles Arbeiten ist nach Abstimmung mit der/dem direkten Vorgesetzten möglich. Die genauen Bedingungen ergeben sich aus der separaten Remote-Work-Vereinbarung (Anlage). Die/der Beschäftigte hat sicherzustellen, dass IT-Sicherheit und Datenschutz gewährleistet sind.{{/IF}}{{#IF NOT HAS_REMOTE_WORK}}Die Tätigkeit erfolgt grundsätzlich am Sitz der Gesellschaft. Mobiles Arbeiten ist nur in Ausnahmefällen nach Zustimmung des Arbeitgebers möglich.{{/IF}}
(5) Die Arbeitszeitvorschriften des Arbeitszeitgesetzes (ArbZG) sind einzuhalten.
## § 4 Vergütung
(1) Die/der Beschäftigte erhält ein monatliches Bruttogehalt von **{{GROSS_MONTHLY_SALARY_EUR}} EUR**, zahlbar zum {{SALARY_PAYMENT_DAY}}. des Folgemonats auf das von der/dem Beschäftigten benannte Konto.
(2) Das Gehalt deckt die vereinbarte regelmäßige Arbeitszeit{{#IF HAS_OVERTIME_LUMPSUM}} sowie die Pauschalabgeltung gemäß § 3 Abs. (3){{/IF}} ab. Eine Anrechnung etwaiger Bonuszahlungen auf den Mindestlohn (§ 1 MiLoG) erfolgt nicht.
{{#IF HAS_BONUS}}
(3) **Variable Vergütung (Bonus).** Über eine Bonuszahlung wird jährlich auf Basis individueller und unternehmensbezogener Zielerreichung entschieden. Die Auszahlung erfolgt freiwillig und begründet auch bei wiederholter Zahlung keinen Rechtsanspruch für künftige Jahre (Freiwilligkeitsvorbehalt"){{#IF HAS_BONUS_RECHTSANSPRUCH}} - ABGEÄNDERT: Auszahlung ist Anspruch bei Zielerreichung gemäß separatem Bonusplan (Anlage){{/IF}}.
{{/IF}}
{{#IF HAS_BAV_INFO}}
(4) **Betriebliche Altersvorsorge.** Die/der Beschäftigte wurde auf den Anspruch auf Entgeltumwandlung gemäß § 1a BetrAVG hingewiesen. Auf Wunsch kann eine Entgeltumwandlung vereinbart werden.{{#IF HAS_BAV_EMPLOYER}} Der Arbeitgeber leistet einen Arbeitgeberzuschuss in Höhe von {{BAV_EMPLOYER_PCT}} % der umgewandelten Beträge.{{/IF}}
{{/IF}}
(5) **Sachleistungen.** Etwaige Sachleistungen (z. B. Jobticket, Sachbezüge) werden separat geregelt.
## § 5 Arbeitsmittel und IT
(1) Der Arbeitgeber stellt alle für die Tätigkeit erforderlichen Arbeitsmittel zur Verfügung, insbesondere:
{{WORK_EQUIPMENT_LIST}}
(2) Die Arbeitsmittel sind sorgfältig zu behandeln und ausschließlich für dienstliche Zwecke zu nutzen.{{#IF HAS_PRIVATE_USE_ALLOWED}} Eine eingeschränkte private Nutzung ist im Rahmen der IT-Nutzungsrichtlinie zulässig.{{/IF}}
(3) IT-Sicherheit: Die/der Beschäftigte verpflichtet sich zur Einhaltung der IT-Sicherheitsrichtlinien des Arbeitgebers (insbesondere starke Passwörter, MFA, Endgeräteverschlüsselung, Verbot unautorisierter Software).
(4) **Cloud- und Datenträgernutzung.** Die Nutzung privater Cloud-Speicher (Dropbox, Google Drive, etc.) oder externer Datenträger für dienstliche Daten ist nur mit ausdrücklicher Genehmigung gestattet.
## § 6 Urlaub
(1) Der Urlaubsanspruch beträgt **{{VACATION_DAYS}} Arbeitstage** pro Kalenderjahr (bei 5-Tage-Woche, ggf. anteilig im Eintritts- oder Austrittsjahr).
(2) Urlaub ist mit der/dem direkten Vorgesetzten frühzeitig abzustimmen.
(3) Nicht genommener Urlaub kann bis zum 31. März des Folgejahres übertragen werden. Bei dringenden betrieblichen oder persönlichen Gründen ist eine längere Übertragung möglich (§ 7 Abs. 3 BUrlG).
(4) Bei Beendigung des Arbeitsverhältnisses wird Resturlaub gewährt oder in Geld abgegolten (§ 7 Abs. 4 BUrlG).
## § 7 Vertraulichkeit und Geschäftsgeheimnisse
(1) Die/der Beschäftigte verpflichtet sich, über alle Geschäfts- und Betriebsgeheimnisse des Arbeitgebers während und **zeitlich unbegrenzt nach** Beendigung des Arbeitsverhältnisses strikte Vertraulichkeit zu wahren. Diese Pflicht ergibt sich auch aus § 17 GeschGehG.
(2) **Geschäftsgeheimnisse** sind insbesondere (nicht abschließend):
{{CONFIDENTIALITY_TOPICS}}
(3) Veröffentlichungen, Vorträge, Beiträge in sozialen Netzwerken und sonstige öffentliche Äußerungen, die Bezug zur Tätigkeit haben, bedürfen der vorherigen schriftlichen Zustimmung des Arbeitgebers.
(4) Bei Verstößen können Schadensersatz, Unterlassung und arbeitsrechtliche Konsequenzen geltend gemacht werden.
## § 8 Geistiges Eigentum (IP)
(1) Sämtliche während der Anstellung von der/dem Beschäftigten geschaffenen oder mitentwickelten Arbeitsergebnisse - einschließlich:
{{IP_SCOPE_LIST}}
gehen automatisch in das ausschließliche geistige Eigentum des Arbeitgebers über. Alle erforderlichen Übertragungs- und Verwertungsrechte werden hiermit vorab abgetreten, soweit das gesetzlich zulässig ist.
(2) **Arbeitnehmererfindergesetz (ArbnErfG).** Das Arbeitnehmererfindergesetz bleibt unberührt. Erfindungsmeldungen sind unverzüglich nach Entstehung der Erfindung schriftlich an den Arbeitgeber zu richten. Die Vergütung erfolgt nach den Vorschriften des ArbnErfG.
(3) **Urheberrechte und Nutzungsrechte.** Der Arbeitgeber erhält an allen urheberrechtlich geschützten Werken die ausschließlichen, übertragbaren, zeitlich, räumlich und inhaltlich unbeschränkten Nutzungsrechte für alle bekannten und unbekannten Nutzungsarten.
(4) **Private Nutzung.** Eine private Nutzung der im Rahmen der Tätigkeit geschaffenen Arbeitsergebnisse ist ausgeschlossen, soweit es sich um Unternehmens-IP handelt.
{{#IF HAS_OPEN_SOURCE_CONTRIBUTIONS}}
(5) **Open-Source-Beiträge.** Beiträge zu Open-Source-Projekten sind grundsätzlich zulässig, jedoch:
(a) nur außerhalb der Arbeitszeit (Kennzeichnung durch die/den Beschäftigte/Beschäftigten),
(b) ohne Verwendung von Unternehmens-IP, vertraulichen Informationen oder Code-Bestandteilen des Arbeitgebers,
(c) bei Bezug zur Tätigkeit: vorherige Anzeige beim Arbeitgeber und Zustimmung im Einzelfall.
{{/IF}}
## § 9 Wettbewerbsverbot und Nebentätigkeiten
(1) **Während der Beschäftigung** ist jede konkurrierende Tätigkeit für ein anderes Unternehmen, insbesondere für Wettbewerber, verboten.
(2) **Nebentätigkeiten** (z. B. Lehraufträge, Freelancing, Beratung) bedürfen der vorherigen schriftlichen Genehmigung. Die Genehmigung ist zu erteilen, sofern:
(a) keine Beeinträchtigung der Tätigkeit beim Arbeitgeber entsteht,
(b) keine konkurrierende Tätigkeit vorliegt,
(c) keine Betriebsgeheimnisse genutzt werden,
(d) die wöchentliche Höchstarbeitszeit gemäß ArbZG eingehalten wird.
{{#IF HAS_POST_EMPLOYMENT_NONCOMPETE}}
(3) **Nachvertragliches Wettbewerbsverbot.** Für die Zeit nach Beendigung des Arbeitsverhältnisses gilt ein nachvertragliches Wettbewerbsverbot von {{POST_NONCOMPETE_MONTHS}} Monaten gemäß §§ 74 ff. HGB. Der Arbeitgeber zahlt eine Karenzentschädigung in Höhe von 50 % der zuletzt bezogenen Gesamtvergütung.
{{/IF}}
## § 10 Datenschutz
(1) Die/der Beschäftigte verpflichtet sich zur Einhaltung der DSGVO, des BDSG und aller internen Datenschutzrichtlinien.
(2) Personenbezogene Daten dürfen nur verarbeitet werden, soweit dies zur Aufgabenerfüllung notwendig und vom Arbeitgeber autorisiert ist.
(3) **Verpflichtung auf das Datengeheimnis** gemäß § 26 BDSG erfolgt separat (Anlage Verpflichtungserklärung).
(4) Eine **Datenschutzinformation gemäß Art. 13 DSGVO** für Beschäftigte wird zum Vertragsbeginn ausgehändigt.
## § 11 Krankheit und Arbeitsverhinderung
(1) **Anzeige der Arbeitsunfähigkeit.** Eine Arbeitsunfähigkeit ist dem Arbeitgeber unverzüglich, spätestens vor Arbeitsbeginn am ersten Tag, anzuzeigen.
(2) **Ärztliche Bescheinigung.** Die ärztliche Arbeitsunfähigkeitsbescheinigung (AU) ist spätestens am **{{AU_BESCHEINIGUNG_TAG}}. Krankheitstag** vorzulegen (in digitaler Form gemäß eAU).
(3) **Lohnfortzahlung.** Die Lohnfortzahlung im Krankheitsfall erfolgt für bis zu 6 Wochen gemäß Entgeltfortzahlungsgesetz (EFZG).
(4) Ärztliche Untersuchungen während der Arbeitszeit sind möglichst außerhalb der Kernarbeitszeit zu planen.
## § 12 Sonstige Abwesenheiten
(1) **Mutterschutz / Elternzeit.** Anspruch auf Mutterschutz und Elternzeit besteht nach den gesetzlichen Vorschriften (MuSchG, BEEG).
(2) **Pflegezeit.** Anspruch auf Pflegezeit besteht nach dem Pflegezeitgesetz und dem Familienpflegezeitgesetz.
(3) **Sonderurlaub.** Bei besonderen Anlässen (Hochzeit, Todesfall in der engen Familie, Geburt eines eigenen Kindes) wird bezahlter Sonderurlaub gewährt: {{SONDERURLAUB_TAGE}} Tag(e) pro Anlass.
## § 13 Kündigung
(1) Nach der Probezeit gelten die gesetzlichen Kündigungsfristen nach § 622 BGB. Diese verlängern sich mit zunehmender Betriebszugehörigkeit zugunsten der/des Beschäftigten gemäß § 622 Abs. 2 BGB.
(2) {{#IF HAS_EXTENDED_NOTICE}}Die Kündigungsfristen werden zugunsten beider Parteien einheitlich auf {{EXTENDED_NOTICE_MONTHS}} Monate zum Monatsende verlängert.{{/IF}}
(3) **Schriftform.** Die Kündigung bedarf der Schriftform gemäß § 623 BGB. Eine elektronische Form (auch E-Mail) genügt nicht.
(4) **Außerordentliche Kündigung.** Das Recht zur außerordentlichen fristlosen Kündigung aus wichtigem Grund (§ 626 BGB) bleibt unberührt.
(5) **Wirksamkeitsvoraussetzung KSchG.** Ab Anwendbarkeit des Kündigungsschutzgesetzes (in der Regel nach 6 Monaten Betriebszugehörigkeit und bei Betrieben mit mehr als 10 Beschäftigten) gilt das KSchG.
(6) **Freistellung.** Der Arbeitgeber kann die/den Beschäftigte/Beschäftigten während der Kündigungsfrist unter Fortzahlung der Bezüge freistellen.
## § 14 Rückgabe von Arbeitsmitteln und Daten
Bei Beendigung des Arbeitsverhältnisses sind unverzüglich und vollständig zurückzugeben:
- alle Geräte (Laptop, Smartphone, Headset, etc.)
- alle Datenträger (USB-Sticks, externe Festplatten)
- alle Unterlagen, Dokumente und Notizen
- Zugangskarten, Schlüssel
- Firmenfahrzeug (falls zugewiesen)
Digitale Daten auf privaten Geräten sind nachweislich zu löschen. Eine Bestätigung kann verlangt werden.
## § 15 Versicherungspflichten
Die/der Beschäftigte ist sozialversicherungspflichtig nach SGB IV. Der Arbeitgeber meldet die/den Beschäftigte/Beschäftigten ordnungsgemäß zur Sozialversicherung an und führt die gesetzlichen Beiträge ab.
## § 16 Schlussbestimmungen
(1) **Schriftform.** Änderungen und Ergänzungen dieses Vertrages bedürfen der Schriftform. Dies gilt auch für die Aufhebung des Schriftformerfordernisses (doppelte Schriftformklausel). Individuelle Vertragsabreden im Sinne des § 305b BGB bleiben unberührt.
(2) **Salvatorische Klausel.** Sollten einzelne Bestimmungen unwirksam sein oder werden, bleibt der Vertrag im Übrigen wirksam. Anstelle der unwirksamen Bestimmung gilt eine solche, die dem wirtschaftlichen Zweck am nächsten kommt und rechtlich zulässig ist.
(3) **Anwendbares Recht und Gerichtsstand.** Es gilt deutsches Recht. Gerichtsstand ist - soweit gesetzlich zulässig - der Sitz der Gesellschaft. Bei Streitigkeiten aus dem Arbeitsverhältnis ist das Arbeitsgericht zuständig.
(4) **Anlagen.** Folgende Anlagen sind Bestandteil dieses Vertrages:
{{ANNEX_LIST}}
(5) **Hinweis nach Nachweisgesetz (NachwG).** Mit Unterzeichnung dieses Vertrages werden alle wesentlichen Vertragsbedingungen schriftlich nachgewiesen.
---
**{{WORKPLACE_LOCATION}}, {{SIGNATURE_DATE}}**
___________________________
Für den Arbeitgeber
___________________________
{{EMPLOYEE_NAME}}
$template$,
'["COMPANY_NAME","COMPANY_LEGAL_FORM","COMPANY_ADDRESS","EMPLOYEE_NAME","EMPLOYEE_BIRTHDATE","EMPLOYEE_ADDRESS","POSITION_TITLE","POSITION_TASKS","EMPLOYMENT_TYPE","CONTRACT_START_DATE","CONTRACT_END_DATE","FIXED_TERM_REASON","IS_FIXED_TERM","IS_PART_TIME","HAS_OVERTIME_LUMPSUM","HAS_REMOTE_WORK","HAS_BONUS","HAS_BONUS_RECHTSANSPRUCH","HAS_BAV_INFO","HAS_BAV_EMPLOYER","HAS_PRIVATE_USE_ALLOWED","HAS_OPEN_SOURCE_CONTRIBUTIONS","HAS_POST_EMPLOYMENT_NONCOMPETE","HAS_EXTENDED_NOTICE","PROBATION_MONTHS","PROBATION_NOTICE_WEEKS","WORKPLACE_LOCATION","WORK_HOURS_WEEK","CORE_HOURS","OVERTIME_LUMPSUM_PCT","OVERTIME_LUMPSUM_HOURS","GROSS_MONTHLY_SALARY_EUR","SALARY_PAYMENT_DAY","BAV_EMPLOYER_PCT","WORK_EQUIPMENT_LIST","VACATION_DAYS","CONFIDENTIALITY_TOPICS","IP_SCOPE_LIST","POST_NONCOMPETE_MONTHS","AU_BESCHEINIGUNG_TAG","SONDERURLAUB_TAGE","EXTENDED_NOTICE_MONTHS","ANNEX_LIST","SIGNATURE_DATE","DOCUMENT_VERSION"]'::jsonb,
'de',
'DE',
'mit',
'MIT License',
'BreakPilot Compliance',
false,
true,
'1.0.0',
'published',
NOW(), NOW()
;
SELECT document_type, title, LENGTH(content) AS content_chars, status, version
FROM compliance_legal_templates WHERE document_type = 'employment_contract_de' ORDER BY created_at DESC LIMIT 1;
@@ -0,0 +1,101 @@
-- Migration 128: Gesellschafterliste Template (§ 40 GmbHG)
-- Pflichtdokument fuer Handelsregister-Anmeldung und bei jeder Aenderung der Gesellschafterstruktur
-- Skalierbar fuer 1 bis N Gesellschafter (Tabellen-Befuellung via GESELLSCHAFTER_TABELLE)
-- Optionale Bloecke: HAS_VERANDERUNGEN, HAS_NOTARY_LIST, MULTI_SIGNATORY
INSERT INTO compliance_legal_templates (
id, tenant_id, document_type, title, description, content,
placeholders, language, jurisdiction,
license_id, license_name, source_name,
attribution_required, is_complete_document, version, status,
created_at, updated_at
) SELECT
gen_random_uuid(),
'9282a473-5c95-4b3a-bf78-0ecc0ec71d3e',
'gesellschafterliste',
'Gesellschafterliste (§ 40 GmbHG)',
'Pflichtdokument einer deutschen GmbH/UG nach § 40 GmbHG. Wird zum Handelsregister eingereicht und bei jeder Aenderung (Anteilsuebertragung, Einziehung, Teilung, Zusammenlegung) aktualisiert. Skalierbar fuer beliebige Anzahl Gesellschafter via Tabellen-Platzhalter. Optionale Bloecke fuer notariell bestaetigte Listen und Aenderungs-Hinweise.',
$template$
# Gesellschafterliste
der **{{COMPANY_NAME}}**
mit Sitz in {{COMPANY_SEAT}}
eingetragen im Handelsregister des Amtsgerichts {{COMPANY_REGISTRY_COURT}} unter **HRB {{HRB_NUMBER}}**
---
## Dokumentenkontrolle
| Feld | Wert |
|---|---|
| Dokument | Gesellschafterliste nach § 40 GmbHG |
| Stand | {{LIST_DATE}} |
| Aufgestellt von | {{LIST_AUTHOR}} ({{LIST_AUTHOR_ROLE}}) |
| Anlass | {{LIST_REASON}} |
| Version | {{DOCUMENT_VERSION}} |
---
## Gesellschafter und Geschäftsanteile
| Lfd. Nr. | Name / Firma | Geburtsdatum / Sitz | Anschrift | Geschäftsanteil Nr. | Nennbetrag (EUR) | Anteil am Stammkapital (%) |
|---:|---|---|---|---:|---:|---:|
{{GESELLSCHAFTER_TABELLE}}
**Stammkapital gesamt: {{STAMMKAPITAL_EUR}} EUR**
---
## Erklärung des Geschäftsführers
Ich/Wir, die Unterzeichnenden, als Geschäftsführer der {{COMPANY_NAME}} versichere/n, dass die vorstehende Liste den tatsächlichen Verhältnissen der Geschäftsanteile entspricht und dass alle Eintragungen vollständig und richtig sind (§ 40 Abs. 1 GmbHG).
{{#IF HAS_VERANDERUNGEN}}
## Veränderungen seit der letzten Liste
{{VERANDERUNGEN_BESCHREIBUNG}}
Grundlage: {{VERANDERUNG_GRUNDLAGE}} (z. B. notarielle Urkunde des Notars {{NOTARY_NAME}} vom {{NOTARIAL_DATE}}, URNr. {{NOTARY_URNR}})
{{/IF}}
{{#IF HAS_NOTARY_LIST}}
## Bestätigung durch Notar
Diese Liste wurde gemäß § 40 Abs. 2 GmbHG durch den unterzeichnenden Notar erstellt.
**Notar:** {{NOTARY_NAME}}
**Anschrift:** {{NOTARY_ADDRESS}}
**URNr.:** {{NOTARY_URNR}}
**Datum:** {{NOTARIAL_DATE}}
{{/IF}}
---
## Hinweise
(1) Diese Gesellschafterliste wird gemäß § 40 GmbHG zum Handelsregister eingereicht. Maßgeblich für das Verhältnis zur Gesellschaft ist die jeweils aktuell zum Handelsregister aufgenommene Liste (§ 16 GmbHG).
(2) Jede Veränderung in der Person der Gesellschafter oder im Umfang ihrer Geschäftsanteile (Übertragung, Einziehung, Teilung, Zusammenlegung) ist durch unverzüglich aktualisierte Liste anzuzeigen.
(3) Erwerber von Geschäftsanteilen gelten erst dann als Gesellschafter, wenn sie in die im Handelsregister aufgenommene Gesellschafterliste eingetragen sind.
---
**{{COMPANY_SEAT}}, {{LIST_DATE}}**
___________________________
{{SIGNATORY_NAME}}
{{SIGNATORY_ROLE}}
{{#IF MULTI_SIGNATORY}}
___________________________
{{SIGNATORY_2_NAME}}
{{SIGNATORY_2_ROLE}}
{{/IF}}
$template$,
'["COMPANY_NAME","COMPANY_SEAT","COMPANY_REGISTRY_COURT","HRB_NUMBER","LIST_DATE","LIST_AUTHOR","LIST_AUTHOR_ROLE","LIST_REASON","DOCUMENT_VERSION","GESELLSCHAFTER_TABELLE","STAMMKAPITAL_EUR","HAS_VERANDERUNGEN","VERANDERUNGEN_BESCHREIBUNG","VERANDERUNG_GRUNDLAGE","HAS_NOTARY_LIST","NOTARY_NAME","NOTARY_ADDRESS","NOTARY_URNR","NOTARIAL_DATE","SIGNATORY_NAME","SIGNATORY_ROLE","MULTI_SIGNATORY","SIGNATORY_2_NAME","SIGNATORY_2_ROLE"]'::jsonb,
'de','DE','mit','MIT License','BreakPilot Compliance',false,true,'1.0.0','published',NOW(),NOW()
;
SELECT document_type, title, LENGTH(content) AS content_chars FROM compliance_legal_templates WHERE document_type = 'gesellschafterliste' ORDER BY created_at DESC LIMIT 1;
@@ -0,0 +1,128 @@
-- Migration 129: Geschaeftsfuehrer-Bestellungsbeschluss Template
-- Pflichtdokument zur Anmeldung beim Handelsregister (§ 39 GmbHG)
-- Skalierbar fuer 1 GF oder mehrere; mit/ohne Praesenzversammlung; einmaliger Beschluss oder Nachbestellung
-- Enthaelt Versicherung nach § 6 Abs. 2 GmbHG und Belehrung nach § 53 BZRG
-- Optionale Bloecke: HAS_HRB (Neugruendung vs. Bestand), IS_PRESENCE_MEETING / IS_WRITTEN_RESOLUTION / IS_VIDEO_MEETING,
-- IS_SINGLE/MULTI_APPOINTMENT, HAS_DELAYED_START, GF_PARA_181_RELEASE, HAS_RESSORT_ZUWEISUNG,
-- HAS_DIENSTVERTRAG, HAS_VERSICHERUNG_BESTELLT, IS_FIRST_APPOINTMENT
INSERT INTO compliance_legal_templates (
id, tenant_id, document_type, title, description, content,
placeholders, language, jurisdiction,
license_id, license_name, source_name,
attribution_required, is_complete_document, version, status,
created_at, updated_at
) SELECT
gen_random_uuid(),
'9282a473-5c95-4b3a-bf78-0ecc0ec71d3e',
'gf_bestellungsbeschluss',
'Gesellschafterbeschluss zur Bestellung Geschäftsführer',
'Beschluss der Gesellschafterversammlung zur Bestellung eines oder mehrerer Geschaeftsfuehrer einer GmbH/UG. Pflichtdokument fuer die Anmeldung im Handelsregister (§ 39 GmbHG). Enthaelt die Versicherung nach § 6 Abs. 2 Satz 2 Nr. 3 GmbHG und Belehrung nach § 53 BZRG. Skalierbar fuer Praesenz-, Video- oder schriftliches Verfahren; einzelne oder Mehrfach-Bestellung; mit/ohne sofortigem Amtsantritt; mit Ressort-Zuweisung; bei Neugruendung oder fuer bestehende Gesellschaft.',
$template$
# Gesellschafterbeschluss
der **{{COMPANY_NAME}}**
mit Sitz in {{COMPANY_SEAT}}
{{#IF HAS_HRB}}eingetragen im Handelsregister des Amtsgerichts {{COMPANY_REGISTRY_COURT}} unter HRB {{HRB_NUMBER}}{{/IF}}
{{#IF NOT HAS_HRB}} in Gründung {{/IF}}
## Bestellung {{#IF IS_FIRST_APPOINTMENT}}der ersten{{/IF}} Geschäftsführer{{#IF IS_PLURAL_GF}}{{/IF}}
---
## Dokumentenkontrolle
| Feld | Wert |
|---|---|
| Beschlussart | Gesellschafterbeschluss zur Bestellung Geschäftsführer |
| Datum der Beschlussfassung | {{RESOLUTION_DATE}} |
| Versammlungsort | {{MEETING_LOCATION}} |
| Beschlussform | {{RESOLUTION_FORM}} |
| Version | {{DOCUMENT_VERSION}} |
---
## § 1 Versammlung und Teilnehmer
(1) Die Gesellschafter der {{COMPANY_NAME}} haben sich am **{{RESOLUTION_DATE}}** in {{MEETING_LOCATION}} {{#IF IS_PRESENCE_MEETING}}zu einer Gesellschafterversammlung versammelt{{/IF}}{{#IF IS_WRITTEN_RESOLUTION}}im schriftlichen Verfahren gemäß § 48 Abs. 2 GmbHG zusammengefunden{{/IF}}{{#IF IS_VIDEO_MEETING}}per Video-/Telefonkonferenz zusammengefunden{{/IF}}.
(2) Anwesend bzw. an der Beschlussfassung beteiligt sind:
{{GESELLSCHAFTER_LISTE}}
(3) Die Beschlussfähigkeit gemäß Satzung ist gegeben. {{ANWESENHEITSQUOTE_PCT}} % der Stimmen sind vertreten.
## § 2 Beschluss zur Bestellung
Die Gesellschafterversammlung beschließt **einstimmig**{{#IF NOT IS_EINSTIMMIG}}mit {{BESCHLUSS_MEHRHEIT_PCT}} % der abgegebenen Stimmen{{/IF}}:
{{#IF IS_SINGLE_APPOINTMENT}}
(1) **{{GF_NAME}}**, geboren am {{GF_BIRTHDATE}}, wohnhaft in {{GF_ADDRESS}}, wird mit sofortiger Wirkung{{#IF HAS_DELAYED_START}} ab {{GF_START_DATE}}{{/IF}} zum/zur Geschäftsführer/in der Gesellschaft bestellt.
(2) {{GF_NAME}} ist {{GF_VERTRETUNG}}{{#IF GF_PARA_181_RELEASE}} und von den Beschränkungen des § 181 BGB befreit{{/IF}}.
{{/IF}}
{{#IF IS_MULTI_APPOINTMENT}}
Folgende Personen werden mit sofortiger Wirkung zu Geschäftsführer/innen der Gesellschaft bestellt:
{{GF_LISTE_MIT_VERTRETUNGSART}}
{{/IF}}
{{#IF HAS_RESSORT_ZUWEISUNG}}
(3) Die interne Ressortverteilung erfolgt gemäß Anlage 1 zu diesem Beschluss bzw. der Geschäftsordnung für die Geschäftsführung (GO-GF).
{{/IF}}
## § 3 Anstellungsverhältnis
{{#IF HAS_DIENSTVERTRAG}}
Die Gesellschafterversammlung genehmigt den Abschluss eines Geschäftsführerdienstvertrages mit jedem der bestellten Geschäftsführer. Die wesentlichen Bedingungen ergeben sich aus dem als Anlage beigefügten Vertrag.
{{/IF}}
{{#IF NOT HAS_DIENSTVERTRAG}}
Über das Anstellungsverhältnis wird ein gesonderter Geschäftsführerdienstvertrag abgeschlossen, der einer separaten Zustimmung der Gesellschafterversammlung bedarf.
{{/IF}}
## § 4 Anmeldung zum Handelsregister
Die Geschäftsführung wird beauftragt und ermächtigt, die Bestellung unverzüglich zum Handelsregister anzumelden und die hierfür erforderlichen Erklärungen abzugeben (§ 39 GmbHG).
## § 5 Versicherung gemäß § 6 Abs. 2 GmbHG
Jede/r bestellte Geschäftsführer/in versichert, dass keine Umstände vorliegen, die ihrer/seiner Bestellung gemäß § 6 Abs. 2 GmbHG entgegenstehen. Insbesondere bestehen:
- keine Verurteilungen wegen einer Straftat gemäß §§ 263 bis 264a oder §§ 265b bis 266a StGB innerhalb der letzten fünf Jahre,
- keine Verurteilungen wegen vergleichbarer Straftaten im Ausland,
- keine berufs- oder gewerberechtlichen Untersagungen, die die Geschäftsführung betreffen würden.
Die/der Geschäftsführer/in wurde über die Pflicht zur Anzeige solcher Umstände belehrt (§ 53 BZRG).
## § 6 Inkrafttreten
Dieser Beschluss tritt mit Beschlussfassung in Kraft. Der/die Geschäftsführer/in ist berechtigt, das Amt unverzüglich auszuüben.
---
**{{MEETING_LOCATION}}, {{RESOLUTION_DATE}}**
**Unterschriften der Gesellschafter:**
{{SIGNATURES_GESELLSCHAFTER}}
{{#IF HAS_VERSICHERUNG_BESTELLT}}
---
## Anlage: Versicherung der/des Bestellten gemäß § 6 Abs. 2 Satz 2 Nr. 3 GmbHG
Ich, **{{GF_NAME}}**, geboren am {{GF_BIRTHDATE}}, wohnhaft in {{GF_ADDRESS}}, versichere hiermit, dass keine Umstände vorliegen, die meiner Bestellung als Geschäftsführer/in der {{COMPANY_NAME}} gemäß § 6 Abs. 2 GmbHG entgegenstehen.
Ich wurde durch {{BELEHRUNG_DURCH}} über meine unbeschränkte Auskunftspflicht gegenüber dem Gericht belehrt (§ 53 Abs. 2 BZRG).
___________________________
{{GF_NAME}}, {{GF_BIRTHDATE_PLACE}}, {{RESOLUTION_DATE}}
{{/IF}}
$template$,
'["COMPANY_NAME","COMPANY_SEAT","COMPANY_REGISTRY_COURT","HRB_NUMBER","HAS_HRB","IS_FIRST_APPOINTMENT","IS_PLURAL_GF","RESOLUTION_DATE","MEETING_LOCATION","RESOLUTION_FORM","DOCUMENT_VERSION","IS_PRESENCE_MEETING","IS_WRITTEN_RESOLUTION","IS_VIDEO_MEETING","GESELLSCHAFTER_LISTE","ANWESENHEITSQUOTE_PCT","IS_EINSTIMMIG","BESCHLUSS_MEHRHEIT_PCT","IS_SINGLE_APPOINTMENT","IS_MULTI_APPOINTMENT","GF_NAME","GF_BIRTHDATE","GF_BIRTHDATE_PLACE","GF_ADDRESS","GF_VERTRETUNG","HAS_DELAYED_START","GF_START_DATE","GF_PARA_181_RELEASE","GF_LISTE_MIT_VERTRETUNGSART","HAS_RESSORT_ZUWEISUNG","HAS_DIENSTVERTRAG","SIGNATURES_GESELLSCHAFTER","HAS_VERSICHERUNG_BESTELLT","BELEHRUNG_DURCH"]'::jsonb,
'de','DE','mit','MIT License','BreakPilot Compliance',false,true,'1.0.0','published',NOW(),NOW()
;
SELECT document_type, title, LENGTH(content) FROM compliance_legal_templates WHERE document_type = 'gf_bestellungsbeschluss' ORDER BY created_at DESC LIMIT 1;
@@ -0,0 +1,169 @@
-- Migration 130: Handelsregister-Anmeldung Template (HRB-Anmeldung)
-- Pflichtdokument fuer Gruendung einer GmbH/UG gemaess §§ 7, 8, 39 GmbHG, § 12 HGB
-- Wird vom Notar in oeffentlich beglaubigter Form eingereicht
-- Optionale Bloecke: HAS_SACHEINLAGE, HAS_GENEHMIGUNG, HAS_EMPFANGSBERECHTIGTER, HAS_PARA_181_RELEASE
-- Versicherungen nach § 8 Abs. 2 GmbHG (Einlageleistung) und § 6 Abs. 2 GmbHG (Bestellung) inklusive
-- Belehrung nach § 53 BZRG
INSERT INTO compliance_legal_templates (
id, tenant_id, document_type, title, description, content,
placeholders, language, jurisdiction,
license_id, license_name, source_name,
attribution_required, is_complete_document, version, status,
created_at, updated_at
) SELECT
gen_random_uuid(),
'9282a473-5c95-4b3a-bf78-0ecc0ec71d3e',
'hrb_anmeldung',
'Anmeldung zur Eintragung im Handelsregister',
'Anmeldung einer GmbH/UG zur Eintragung im Handelsregister gemaess §§ 7, 8, 39 GmbHG und § 12 HGB. Wird vom Notar in oeffentlich beglaubigter Form beim Registergericht eingereicht. Enthaelt alle Pflichtangaben (Firma, Sitz, Gegenstand, Stammkapital, Gesellschafter, Geschaeftsfuehrer, Vertretungsregelung, Geschaeftsjahr), Versicherungen nach §§ 6 Abs. 2 und 8 Abs. 2 GmbHG, Belehrung nach § 53 BZRG sowie Auflistung der beigefuegten Anlagen. Optional fuer Sacheinlage-Gruendung und genehmigungspflichtige Taetigkeiten.',
$template$
# Anmeldung zur Eintragung im Handelsregister
An das Amtsgericht **{{COMPANY_REGISTRY_COURT}}** Registergericht
{{REGISTRY_COURT_ADDRESS}}
---
## Dokumentenkontrolle
| Feld | Wert |
|---|---|
| Dokumenttyp | {{ANMELDUNG_TYP}} |
| Gesellschaft | {{COMPANY_NAME}} |
| Sitz | {{COMPANY_SEAT}} |
| Datum | {{ANMELDUNG_DATE}} |
| Beurkundender Notar | {{NOTARY_NAME}}, {{NOTARY_PLACE}} (URNr. {{NOTARY_URNR}}) |
**Hinweis:** Die Anmeldung erfolgt gemäß §§ 7, 8, 39 GmbHG durch sämtliche Geschäftsführer in öffentlich beglaubigter Form (§ 12 HGB). Die nachfolgenden Erklärungen sind durch den Notar zu beglaubigen.
---
## A. Anmeldung der Gesellschaft
Die unterzeichnenden Geschäftsführer melden hiermit die nachstehende Gesellschaft zur Eintragung in das Handelsregister an:
### 1. Firma und Rechtsform
**Firma:** {{COMPANY_NAME}}
**Rechtsform:** {{COMPANY_LEGAL_FORM}}
### 2. Sitz und Geschäftsanschrift
**Sitz der Gesellschaft:** {{COMPANY_SEAT}}
**Geschäftsanschrift:** {{COMPANY_ADDRESS}}
{{#IF HAS_EMPFANGSBERECHTIGTER}}
**Empfangsberechtigter:** {{EMPFANGSBERECHTIGTER_NAME}}, {{EMPFANGSBERECHTIGTER_ADDRESS}}
{{/IF}}
### 3. Gegenstand des Unternehmens
{{COMPANY_PURPOSE_DESCRIPTION}}
### 4. Stammkapital
Das Stammkapital beträgt **{{STAMMKAPITAL_EUR}} EUR**.
### 5. Geschäftsanteile und Gesellschafter
Die Gesellschaft hat folgende Gesellschafter mit den nachstehenden Geschäftsanteilen:
{{GESELLSCHAFTER_TABELLE}}
### 6. Geschäftsführer
Zu Geschäftsführer/innen sind bestellt:
{{GESCHAEFTSFUEHRER_LISTE}}
### 7. Vertretungsbefugnis
Die Vertretung der Gesellschaft erfolgt wie folgt:
{{VERTRETUNGSREGELUNG}}
{{#IF HAS_PARA_181_RELEASE}}
**Befreiung von § 181 BGB:** {{PARA_181_DETAILS}}
{{/IF}}
### 8. Geschäftsjahr
Das Geschäftsjahr ist {{BUSINESS_YEAR}}. Das erste Geschäftsjahr beginnt mit Eintragung der Gesellschaft im Handelsregister und endet am {{FIRST_YEAR_END}} (Rumpfgeschäftsjahr).
---
## B. Beigefügte Unterlagen
Der Anmeldung sind beigefügt:
1. Notariell beurkundete Satzung der Gesellschaft (Urkunde des Notars {{NOTARY_NAME}}, URNr. {{NOTARY_URNR}} vom {{NOTARIAL_DATE}})
2. Gesellschafterliste gemäß § 40 GmbHG
3. Gesellschafterbeschluss zur Bestellung der Geschäftsführer
4. Versicherungen der Geschäftsführer gemäß § 6 Abs. 2 GmbHG sowie Belehrung nach § 53 BZRG
5. Bescheinigung des kontoführenden Kreditinstituts über die Einzahlung der Stammeinlage(n) (§ 8 Abs. 2 GmbHG)
{{#IF HAS_SACHEINLAGE}}
6. Sachgründungsbericht gemäß § 5 Abs. 4 GmbHG
7. Werthaltigkeitsnachweise für Sacheinlagen
{{/IF}}
{{#IF HAS_GENEHMIGUNG}}
{{NEXT_DOC_NUMBER}}. Behördliche Genehmigungen für genehmigungspflichtige Tätigkeiten: {{GENEHMIGUNG_DETAILS}}
{{/IF}}
---
## C. Versicherungen der Geschäftsführer
### C.1 Einlageleistung (§ 8 Abs. 2 GmbHG)
Wir, die unterzeichnenden Geschäftsführer, versichern, dass auf jede Bareinlage mindestens ein Viertel, insgesamt jedoch mindestens **{{STAMMKAPITAL_HALF_EUR}} EUR**, eingezahlt wurde und sich die Leistungen endgültig in der freien Verfügung der Geschäftsführer befinden.
Genaue Aufstellung:
{{EINZAHLUNGSAUFSTELLUNG}}
### C.2 Fortbestehen des Stammkapitals
Wir versichern, dass das Stammkapital nicht vor der Anmeldung verringert wurde und sich in der freien Verfügung der Gesellschaft befindet.
### C.3 Bestellungsvoraussetzungen (§ 6 Abs. 2 GmbHG)
Wir, die unterzeichnenden Geschäftsführer, versichern, dass keine Umstände vorliegen, die einer Bestellung als Geschäftsführer entgegenstehen würden. Insbesondere bestehen keine Verurteilungen wegen:
- Insolvenzstraftaten (§§ 283 bis 283d StGB),
- Vermögensdelikten (§§ 263 bis 264a, 265b bis 266a StGB) in den letzten fünf Jahren,
- vergleichbarer Straftaten im Ausland.
Es bestehen keine berufs- oder gewerberechtlichen Untersagungen, die die Geschäftsführung dieser Gesellschaft betreffen würden.
Wir wurden durch den unterzeichnenden Notar über die unbeschränkte Auskunftspflicht gegenüber dem Gericht gemäß § 53 BZRG belehrt.
---
## D. Inländische Geschäftsanschrift
Wir versichern, dass die unter Ziffer A.2 angegebene Geschäftsanschrift die tatsächliche und nicht nur eine Zustellungsanschrift ist und unter dieser Anschrift Zustellungen an die Gesellschaft entgegengenommen werden können.
---
**{{COMPANY_SEAT}}, {{ANMELDUNG_DATE}}**
{{GF_SIGNATURES_BEGLAUBIGUNG}}
---
## Beglaubigungsvermerk
(wird durch den beurkundenden Notar erstellt)
Hiermit beglaubige ich die vorstehenden Unterschriften, die in meiner Gegenwart vollzogen wurden durch die jeweiligen Geschäftsführer, die mir persönlich bekannt sind / sich durch Vorlage des Personalausweises ausgewiesen haben.
{{NOTARY_NAME}}, Notar in {{NOTARY_PLACE}}, am {{ANMELDUNG_DATE}}, URNr. {{NOTARY_BEGLAUBIGUNG_URNR}}
$template$,
'["COMPANY_REGISTRY_COURT","REGISTRY_COURT_ADDRESS","ANMELDUNG_TYP","COMPANY_NAME","COMPANY_SEAT","ANMELDUNG_DATE","NOTARY_NAME","NOTARY_PLACE","NOTARY_URNR","NOTARIAL_DATE","NOTARY_BEGLAUBIGUNG_URNR","COMPANY_LEGAL_FORM","COMPANY_ADDRESS","HAS_EMPFANGSBERECHTIGTER","EMPFANGSBERECHTIGTER_NAME","EMPFANGSBERECHTIGTER_ADDRESS","COMPANY_PURPOSE_DESCRIPTION","STAMMKAPITAL_EUR","STAMMKAPITAL_HALF_EUR","GESELLSCHAFTER_TABELLE","GESCHAEFTSFUEHRER_LISTE","VERTRETUNGSREGELUNG","HAS_PARA_181_RELEASE","PARA_181_DETAILS","BUSINESS_YEAR","FIRST_YEAR_END","HAS_SACHEINLAGE","HAS_GENEHMIGUNG","GENEHMIGUNG_DETAILS","NEXT_DOC_NUMBER","EINZAHLUNGSAUFSTELLUNG","GF_SIGNATURES_BEGLAUBIGUNG"]'::jsonb,
'de','DE','mit','MIT License','BreakPilot Compliance',false,true,'1.0.0','published',NOW(),NOW()
;
SELECT document_type, title, LENGTH(content) FROM compliance_legal_templates WHERE document_type = 'hrb_anmeldung' ORDER BY created_at DESC LIMIT 1;
@@ -0,0 +1,178 @@
-- Migration 131: IP-Assignment Agreement Template (Gruender->GmbH IP-Uebertragung)
-- Separates Dokument zur Sicherung von vor-/aussergesellschaftlichem IP
-- Kritisch fuer Due Diligence bei Investoren-Runden
-- Optionale Bloecke: HAS_HRB, HAS_BAR_VERGUETUNG / HAS_SHARES_AS_COMPENSATION / HAS_NO_VERGUETUNG,
-- HAS_ACADEMIC_BACKGROUND, HAS_SHA
-- Inklusive Anlagen-Sektion fuer konkrete IP-Liste und Ausnahmen (Open Source, akademisches IP)
INSERT INTO compliance_legal_templates (
id, tenant_id, document_type, title, description, content,
placeholders, language, jurisdiction,
license_id, license_name, source_name,
attribution_required, is_complete_document, version, status,
created_at, updated_at
) SELECT
gen_random_uuid(),
'9282a473-5c95-4b3a-bf78-0ecc0ec71d3e',
'ip_assignment_agreement',
'IP-Assignment Agreement (Geistiges Eigentum Übertragung)',
'Vertragliche Uebertragung vor-/aussergesellschaftlichen IP von einem Gruender oder Mitarbeiter auf die Gesellschaft. Kritisch fuer Investoren-Due-Diligence: stellt Rechtsklarheit ueber Software, KI-Modelle, Patente, Marken, Designs, Texte und sonstige Schutzrechte her. Mit Abtretung uebertragbarer Rechte plus Einraeumung exklusiver Nutzungsrechte fuer nicht uebertragbare Rechte (Urheberrecht § 29 UrhG). Vergueutungs-Optionen: Bar, Equity-as-Consideration oder unentgeltlich. Spezialklauseln fuer akademische Taetigkeit und Drittmittel-Vorbehalte. Anlagen-Sektion fuer konkrete IP-Liste und Open-Source-Vorbehalte.',
$template$
# IP-Assignment Agreement (Übertragungsvereinbarung Geistiges Eigentum)
zwischen
**{{ASSIGNOR_NAME}}**, geboren am {{ASSIGNOR_BIRTHDATE}}, wohnhaft in {{ASSIGNOR_ADDRESS}}
- nachfolgend Übertragender" / „Assignor" -
und
**{{COMPANY_NAME}}**, {{COMPANY_ADDRESS}}{{#IF HAS_HRB}}, eingetragen im Handelsregister des Amtsgerichts {{COMPANY_REGISTRY_COURT}} unter HRB {{HRB_NUMBER}}{{/IF}}{{#IF NOT HAS_HRB}} (in Gründung){{/IF}}, vertreten durch {{COMPANY_REPRESENTATIVE}}
- nachfolgend Gesellschaft" / „Assignee" -
---
## Dokumentenkontrolle
| Feld | Wert |
|---|---|
| Vertragstyp | IP-Assignment Agreement |
| Datum | {{AGREEMENT_DATE}} |
| Gegenstand | Übertragung vor-/außergesellschaftlichen IP |
| Version | {{DOCUMENT_VERSION}} |
---
## Präambel
(A) Die Gesellschaft befindet sich {{#IF NOT HAS_HRB}}in Gründung und wird ihre Tätigkeit aufnehmen{{/IF}}{{#IF HAS_HRB}}im Geschäftsbetrieb{{/IF}} im Bereich {{COMPANY_PURPOSE_SHORT}}.
(B) Der Übertragende ist {{ASSIGNOR_ROLE}} der Gesellschaft und hat in der Phase vor und/oder außerhalb seiner Gesellschafter-/Mitarbeitertätigkeit verschiedene immaterielle Vermögenswerte erschaffen, die für den Geschäftsbetrieb der Gesellschaft wesentlich sind (Vor-/Außer-Gesellschafts-IP" oder „Übertragungs-IP").
(C) Die Parteien beabsichtigen, sämtliche Rechte an diesem IP soweit rechtlich zulässig von dem Übertragenden auf die Gesellschaft zu übertragen, um Rechtsklarheit für künftige Investoren, Lizenznehmer und Vertragspartner zu schaffen.
(D) Dieses Agreement wirkt eigenständig neben etwaigen IP-Klauseln im Gesellschaftsvertrag, im Shareholders' Agreement oder im Anstellungsvertrag und konkretisiert diese.
---
## § 1 Gegenstand der Übertragung
(1) **Übertragungs-IP** im Sinne dieses Agreements umfasst sämtliche immateriellen Vermögenswerte, die der Übertragende vor Unterzeichnung dieses Agreements geschaffen, mitgeschaffen oder zu denen er beigetragen hat, soweit sie mit dem Gegenstand der Gesellschaft im Zusammenhang stehen. Insbesondere:
(a) **Software und Quellcode** (einschließlich Algorithmen, Skripte, Bibliotheken, Configurations, Build-Systeme),
(b) **KI-Modelle, Trainingsdaten, Pipelines** und damit verbundene technische Konzepte,
(c) **Datenbanken** und deren Strukturen,
(d) **Dokumentation, Spezifikationen, technische Konzepte, Architekturentscheidungen**,
(e) **Designs, UI/UX-Konzepte, Wireframes, Mockups, Branding-Materialien**,
(f) **Marken, Logos, Domains, Brandname-Konzepte**,
(g) **Erfindungen** (patentierbar oder nicht), Verfahren, Methoden,
(h) **Konzepte, Business-Pläne, Marktstudien, Strategiedokumente**,
(i) **Texte, audiovisuelle Werke** und sonstige urheberrechtlich geschützte Inhalte,
(j) **Know-how und Geschäftsgeheimnisse**, die mit den vorgenannten Werken in Zusammenhang stehen.
(2) Eine **Liste der konkret übertragenen Werke** ist als **Anlage 1** beigefügt. Die Liste hat indikative Wirkung; eine fehlende Aufführung in der Anlage führt nicht zum Ausschluss der Übertragung, sofern das jeweilige Werk dem Gegenstand der Gesellschaft dient.
## § 2 Übertragung und Einräumung von Rechten
(1) **Übertragung übertragbarer Rechte.** Der Übertragende tritt hiermit der Gesellschaft sämtliche an dem Übertragungs-IP bestehenden übertragbaren Rechte ab. Die Gesellschaft nimmt diese Abtretung an.
(2) **Einräumung nicht übertragbarer Rechte (Urheberrecht).** Soweit Rechte am Übertragungs-IP nach deutschem Recht nicht übertragbar sind (insbesondere das Urheberrecht selbst gemäß § 29 UrhG), räumt der Übertragende der Gesellschaft hiermit das ausschließliche, übertragbare, zeitlich, räumlich und inhaltlich unbeschränkte Nutzungsrecht für **sämtliche bekannten und unbekannten Nutzungsarten** ein. Dieses Recht umfasst insbesondere das Vervielfältigungs-, Verbreitungs-, Bearbeitungs-, Sendungs- und das Recht zur öffentlichen Wiedergabe.
(3) **Bearbeitungsrecht und Unterlizenzierung.** Die Gesellschaft ist berechtigt, das Übertragungs-IP zu bearbeiten, zu modifizieren, weiterzuentwickeln, Unterlizenzen zu erteilen und Schutzrechte (Patente, Marken, Designs) auf eigenen Namen anzumelden.
(4) **Verzicht auf Urheberbenennung.** Der Übertragende verzichtet, soweit rechtlich zulässig, auf das Recht auf Urheberbenennung (§ 13 UrhG) gegenüber der Gesellschaft und ihren Kunden/Lizenznehmern. Davon unberührt bleibt das Verbot der Entstellung gemäß § 14 UrhG.
(5) **Wirksamkeit.** Die Übertragung wird mit Unterzeichnung dieses Agreements wirksam und gilt rückwirkend auch für vor diesem Datum entstandenes Übertragungs-IP.
## § 3 Vergütung
{{#IF HAS_BAR_VERGUETUNG}}
(1) Als Gegenleistung für die Übertragung erhält der Übertragende eine einmalige Vergütung von **{{IP_VERGUETUNG_EUR}} EUR**, zahlbar innerhalb von {{ZAHLUNGSFRIST_TAGE}} Tagen nach Unterzeichnung.
{{/IF}}
{{#IF HAS_SHARES_AS_COMPENSATION}}
(1) Als Gegenleistung erhält der Übertragende die ihm im Rahmen der Gründung der Gesellschaft zugewiesenen Geschäftsanteile (Cash-Free / Equity-as-Consideration). Eine darüber hinausgehende Vergütung ist nicht geschuldet.
{{/IF}}
{{#IF HAS_NO_VERGUETUNG}}
(1) Die Übertragung erfolgt ohne gesonderte Vergütung im Rahmen der Gesellschafter- bzw. Anstellungsbeziehung. Die wirtschaftliche Gegenleistung ergibt sich aus der Gesellschafterstellung bzw. dem Anstellungsverhältnis des Übertragenden.
{{/IF}}
(2) **Arbeitnehmererfindergesetz.** Soweit das ArbnErfG anwendbar ist, bleiben dessen Vergütungsregelungen unberührt. Etwaige Vergütungsansprüche werden auf die in Abs. (1) genannte Gegenleistung angerechnet.
## § 4 Garantien des Übertragenden
(1) Der Übertragende garantiert, dass:
(a) er Inhaber sämtlicher abzutretenden Rechte am Übertragungs-IP ist und über diese frei verfügen kann,
(b) das Übertragungs-IP frei von Rechten Dritter ist (insbesondere keine Vorbenutzungs-, Mit-Erfinder- oder Lizenzrechte Dritter bestehen),
(c) keine Open-Source-Komponenten mit Copyleft-Wirkung (z. B. GPL) verwendet wurden, die eine kommerzielle Verwertung beschränken würden Ausnahmen sind in **Anlage 2** zu dokumentieren,
(d) das Übertragungs-IP nicht aus einem Arbeitsverhältnis, Lehrverhältnis, Forschungsprojekt oder einer Drittmittel-Förderung stammt, das/die Rechte Dritter begründen könnte; Ausnahmen sind ebenfalls in **Anlage 2** zu dokumentieren,
(e) sämtliche zur Übertragung erforderlichen Mitwirkungs- und Erklärungspflichten erfüllt werden.
(2) **Freistellung.** Bei Verletzung der vorstehenden Garantien stellt der Übertragende die Gesellschaft von allen Ansprüchen Dritter frei, soweit die Verletzung von ihm zu vertreten ist.
(3) **Verjährung.** Ansprüche aus diesen Garantien verjähren in **{{GUARANTEE_VERJAEHRUNG_JAHRE}} Jahren** ab Kenntnis der Gesellschaft vom rechtsbegründenden Sachverhalt.
## § 5 Mitwirkung und weitere Erklärungen
(1) Der Übertragende verpflichtet sich, sämtliche Erklärungen abzugeben und Handlungen vorzunehmen, die zur wirksamen Übertragung der Rechte oder zur Anmeldung von Schutzrechten erforderlich sind. Dies umfasst insbesondere:
(a) Mitwirkung bei Patent-, Marken- und Designanmeldungen,
(b) Übergabe sämtlicher relevanter Dokumente, Code-Repositories, Designdateien, Trainingsdaten in maschinenlesbarer Form,
(c) Erläuterung technischer Details an die Gesellschaft oder von ihr benannte Dritte.
(2) Diese Mitwirkungspflicht besteht auch nach Beendigung der Gesellschafter-/Anstellungsstellung fort, soweit dies zur Verteidigung oder Anmeldung von Schutzrechten erforderlich ist.
{{#IF HAS_ACADEMIC_BACKGROUND}}
## § 6 Akademische Tätigkeit und Drittmittelprojekte
(1) Der Übertragende erklärt, dass IP, das aus seiner akademischen Tätigkeit (Hochschule, Forschungseinrichtung) oder aus Drittmittelprojekten stammt, **nicht** Gegenstand dieser Übertragung ist, soweit es Rechten Dritter (z. B. Hochschule, Förderbestimmungen) unterliegt.
(2) Etwaige Nutzungsmöglichkeiten solchen IP für die Gesellschaft erfordern eine separate Vereinbarung mit der jeweiligen Einrichtung.
(3) Der Übertragende verpflichtet sich, in seiner künftigen akademischen Tätigkeit keine vertraulichen Informationen oder IP der Gesellschaft offenzulegen oder zu verwenden.
{{/IF}}
## § 7 Geltungsbereich, Vertragsänderungen, Schlussbestimmungen
(1) **Verhältnis zu anderen Verträgen.** Dieses Agreement gilt ergänzend zu:
(a) {{#IF HAS_SHA}}dem Shareholders' Agreement (SHA),{{/IF}}
(b) dem Geschäftsführerdienstvertrag/Arbeitsvertrag des Übertragenden,
(c) der Satzung der Gesellschaft.
Bei Widersprüchen zu späteren IP-Klauseln in Anstellungs- oder Gesellschafterverträgen gelten die spezifischeren Regelungen vor; im Zweifel die für die Gesellschaft günstigere Auslegung.
(2) **Schriftform.** Änderungen und Ergänzungen bedürfen der Schriftform. Dies gilt auch für die Aufhebung des Schriftformerfordernisses.
(3) **Salvatorische Klausel.** Sollten einzelne Bestimmungen unwirksam sein, bleibt das Agreement im Übrigen wirksam.
(4) **Anwendbares Recht und Gerichtsstand.** Es gilt deutsches Recht. Gerichtsstand ist der Sitz der Gesellschaft.
(5) **Anlagen.** Folgende Anlagen sind Bestandteil dieses Agreements:
- **Anlage 1:** Liste des konkret übertragenen IP (indikativ)
- **Anlage 2:** Vorbehalte und Ausnahmen (Open Source, Akademisches IP, Drittrechte)
---
**{{SIGNATURE_LOCATION}}, {{AGREEMENT_DATE}}**
___________________________
{{ASSIGNOR_NAME}} (Übertragender)
___________________________
Für die Gesellschaft
{{COMPANY_REPRESENTATIVE}}
---
## Anlage 1 Liste des konkret übertragenen IP
{{IP_LIST_DETAILS}}
## Anlage 2 Vorbehalte und Ausnahmen
{{IP_EXCEPTIONS_DETAILS}}
$template$,
'["ASSIGNOR_NAME","ASSIGNOR_BIRTHDATE","ASSIGNOR_ADDRESS","ASSIGNOR_ROLE","COMPANY_NAME","COMPANY_ADDRESS","COMPANY_REGISTRY_COURT","HRB_NUMBER","HAS_HRB","COMPANY_REPRESENTATIVE","COMPANY_PURPOSE_SHORT","AGREEMENT_DATE","DOCUMENT_VERSION","HAS_BAR_VERGUETUNG","HAS_SHARES_AS_COMPENSATION","HAS_NO_VERGUETUNG","IP_VERGUETUNG_EUR","ZAHLUNGSFRIST_TAGE","GUARANTEE_VERJAEHRUNG_JAHRE","HAS_ACADEMIC_BACKGROUND","HAS_SHA","SIGNATURE_LOCATION","IP_LIST_DETAILS","IP_EXCEPTIONS_DETAILS"]'::jsonb,
'de','DE','mit','MIT License','BreakPilot Compliance',false,true,'1.0.0','published',NOW(),NOW()
;
SELECT document_type, title, LENGTH(content) FROM compliance_legal_templates WHERE document_type = 'ip_assignment_agreement' ORDER BY created_at DESC LIMIT 1;
@@ -0,0 +1,264 @@
-- Migration 132: Term Sheet Template (Pre-Seed / Seed / Series A)
-- Indikative Investitionsabsichtserklaerung, ueberwiegend nicht-bindend
-- Bindende Elemente: §§ 12 Exklusivitaet, 13 Vertraulichkeit, 14 Kosten, 15 Recht/Gerichtsstand
-- Orientiert an BvK Mustertexten und VC-Standards (Pre-Seed bis Series A)
-- Optionale Bloecke: HAS_HRB, HAS_TRANCHES, IS_PREFERRED_SHARES/IS_COMMON_SHARES,
-- HAS_LIQUIDATION_PREFERENCE, IS_PARTICIPATING, HAS_ANTI_DILUTION, HAS_BOARD_SEAT, HAS_OBSERVER,
-- COMPANY_PAYS_COSTS / EACH_PARTY_PAYS
INSERT INTO compliance_legal_templates (
id, tenant_id, document_type, title, description, content,
placeholders, language, jurisdiction,
license_id, license_name, source_name,
attribution_required, is_complete_document, version, status,
created_at, updated_at
) SELECT
gen_random_uuid(),
'9282a473-5c95-4b3a-bf78-0ecc0ec71d3e',
'term_sheet',
'Term Sheet (Investitionsangebot)',
'Indikative Investitionsabsichtserklaerung fuer Venture Capital / Angel Investments in Pre-Seed-, Seed- oder Series-A-Phase. Strukturiert nach VC-Standards (BvK, EVCA, YC SAFE). Enthaelt alle wesentlichen Konditionen (Bewertung, Tranching, Vorzugsrechte, Liquidation Preference, Anti-Dilution, Vesting, Drag/Tag-Along, ESOP-Pool, Pre-emptive Rights, ROFR, Closing Conditions, Due Diligence). Bindende Klauseln: Exklusivitaet, Vertraulichkeit, Kostentragung, Rechtswahl. Skalierbar fuer verschiedene Investorentypen (Angel, VC, Strategischer) und Investmentstrukturen (Preferred/Common, Single Tranche/Multi Tranche, mit/ohne Boardsitz).',
$template$
# Term Sheet Investitionsangebot
**Indikative, nicht-bindende Vorvereinbarung zur geplanten Investition in {{COMPANY_NAME}}**
zwischen
**{{INVESTOR_NAME}}** (Investor")
und
**{{COMPANY_NAME}}**, {{COMPANY_ADDRESS}}{{#IF HAS_HRB}}, eingetragen unter HRB {{HRB_NUMBER}}{{/IF}} (Gesellschaft")
und
**den in Anlage A genannten Gründern** ("Gründer")
---
## Dokumentenkontrolle
| Feld | Wert |
|---|---|
| Vertragstyp | Term Sheet (Investitionsabsichtserklärung) |
| Stand | {{TERM_SHEET_DATE}} |
| Investment-Phase | {{INVESTMENT_PHASE}} (z.B. Pre-Seed, Seed, Series A) |
| Bindungswirkung | Indikativ, nicht-bindend (Ausnahmen: §§ 12-14) |
| Gültigkeit / Exklusivität bis | {{EXCLUSIVITY_END_DATE}} |
> **Hinweis:** Dieses Term Sheet beschreibt die wesentlichen Bedingungen der geplanten Investition. Es ist außer in den ausdrücklich gekennzeichneten Punkten (Exklusivität, Vertraulichkeit, Kostentragung, anwendbares Recht) **rechtlich nicht bindend**. Verbindlich werden die Konditionen erst durch Abschluss des Beteiligungs- und Gesellschaftervertrages (Definitive Documents") in notarieller Form.
---
## § 1 Investitionsstruktur
| Punkt | Wert |
|---|---|
| **Investitionsbetrag** | {{INVESTMENT_AMOUNT_EUR}} EUR |
| **Pre-Money-Bewertung** | {{PRE_MONEY_VALUATION_EUR}} EUR |
| **Post-Money-Bewertung** | {{POST_MONEY_VALUATION_EUR}} EUR |
| **Investitions-Instrument** | {{INVESTMENT_INSTRUMENT}} (z.B. Stammanteile, Vorzugsanteile, Wandeldarlehen, SAFE) |
| **Resultierende Beteiligung Investor** | {{INVESTOR_STAKE_PCT}} % am Post-Money-Stammkapital |
| **Geplantes Closing-Datum** | {{TARGET_CLOSING_DATE}} |
### 1.1 Tranching
{{#IF HAS_TRANCHES}}
Das Investment wird in folgenden Tranchen ausgezahlt:
{{TRANCHES_TABLE}}
{{/IF}}
{{#IF NOT HAS_TRANCHES}}
Das Investment wird in einer einzigen Tranche zum Closing-Datum ausgezahlt.
{{/IF}}
## § 2 Verwendung der Mittel (Use of Proceeds)
Die Investitionsmittel werden insbesondere verwendet für:
{{USE_OF_PROCEEDS}}
## § 3 Aktien-/Anteilsklassen und Vorzugsrechte
### 3.1 Anteilsklasse
{{#IF IS_PREFERRED_SHARES}}
Der Investor erhält **Vorzugsanteile (Preferred Shares)** mit den nachfolgend beschriebenen Vorzugsrechten.
{{/IF}}
{{#IF IS_COMMON_SHARES}}
Der Investor erhält **Stammanteile (Common Shares)** ohne besondere Vorzugsrechte.
{{/IF}}
### 3.2 Liquidation Preference
{{#IF HAS_LIQUIDATION_PREFERENCE}}
Im Falle eines Liquidationsereignisses (Exit, Verkauf, Liquidation der Gesellschaft) erhält der Investor:
- **{{LIQ_PREF_MULTIPLIER}}× non-participating Liquidation Preference** (1× ist marktstandard für Seed/Pre-Seed)
- Nach Erhalt der Preference partizipiert der Investor **{{#IF IS_PARTICIPATING}}vollständig (Participating){{/IF}}{{#IF NOT IS_PARTICIPATING}}nicht weiter (Non-Participating){{/IF}}** an verbleibenden Erlösen.
- Bei Non-Participating: Wahlrecht zwischen Preference oder pro-rata Erlös, je nachdem was höher ist (Standard).
{{/IF}}
### 3.3 Anti-Dilution Schutz
{{#IF HAS_ANTI_DILUTION}}
Bei künftigen Finanzierungsrunden zu einem Preis pro Anteil **unter** dem in dieser Runde gezahlten Preis greift folgender Verwässerungsschutz:
- **Methode:** {{ANTI_DILUTION_METHOD}} (Standard: Broad-Based Weighted Average)
- **Ausnahmen:** ESOP-Pool-Allokationen, Bonus-Anteile bei Akquisitionen, Pflichtwandlungen
{{/IF}}
### 3.4 Mitspracherechte (Reserved Matters)
Der Investor erhält ein Vetorecht bei wesentlichen Entscheidungen, insbesondere:
- Änderung der Satzung/des SHA
- Aufnahme weiterer Gesellschafter, neue Anteilsausgaben
- Aufnahme von Krediten > {{LOAN_THRESHOLD_EUR}} EUR
- Verkauf wesentlicher Vermögenswerte
- Änderung des Geschäftsmodells
- Bestellung/Abberufung Geschäftsführer
- Beschluss über ESOP-Plan
- Liquidations- oder Insolvenzentscheidungen
Detaillierte Reserved Matters-Liste folgt im SHA (typischerweise 12-18 Punkte).
## § 4 Governance
### 4.1 Beirat / Board
{{#IF HAS_BOARD_SEAT}}
- **Größe:** {{BOARD_SIZE}} Mitglieder
- **Investor-Sitze:** {{INVESTOR_BOARD_SEATS}} Sitz(e)
- **Gründer-Sitze:** {{FOUNDER_BOARD_SEATS}} Sitz(e)
- **Unabhängige:** {{INDEPENDENT_BOARD_SEATS}} Sitz(e)
- **Sitzungsfrequenz:** mindestens {{BOARD_MEETING_FREQ}}
- **Observer Rights:** {{#IF HAS_OBSERVER}}Ja, für nicht-stimmberechtigte Investoren{{/IF}}{{#IF NOT HAS_OBSERVER}}Keine{{/IF}}
{{/IF}}
### 4.2 Informationsrechte
Der Investor erhält folgende Mindest-Informationsrechte:
- **Monatlich:** Liquiditätsbericht, KPI-Dashboard
- **Quartalsweise:** Quartalsabschluss (P&L, Bilanz, Cashflow), Status Risk Register
- **Jährlich:** Auditierter Jahresabschluss innerhalb {{ANNUAL_REPORT_MONTHS}} Monaten nach GJ-Ende
- **Ad hoc:** Materielle Ereignisse, drohende Liquiditätsrisiken, rechtliche Verfahren
## § 5 Vesting und Founder Lock-up
### 5.1 Founder Vesting
Die Gründer unterliegen einem (Re-)Vesting:
- **Dauer:** {{VESTING_MONTHS}} Monate
- **Cliff:** {{CLIFF_MONTHS}} Monate
- **Vesting-Beginn:** {{VESTING_START_REF}}
- **Acceleration bei Exit:** Single-Trigger bei Sale of >{{ACCELERATION_THRESHOLD_PCT}}% der Anteile
### 5.2 Founder Lock-up
Die Gründer verpflichten sich für {{FOUNDER_LOCKUP_MONTHS}} Monate nach Closing zu **Full-Time-Commitment** zur Gesellschaft. Ausnahmen werden im SHA spezifiziert.
## § 6 Drag-Along und Tag-Along
- **Drag-Along-Schwelle:** {{DRAG_ALONG_THRESHOLD_PCT}} % (Standard 75 %)
- **Tag-Along ab:** {{TAG_ALONG_THRESHOLD_PCT}} % Anteilsverkauf eines Gründers
- **Same-Terms-Prinzip:** Alle Gesellschafter erhalten identische wirtschaftliche Konditionen
## § 7 ESOP / Mitarbeiter-Beteiligung
- **Aktueller ESOP-Pool:** {{CURRENT_ESOP_PCT}} %
- **Ziel-ESOP-Pool nach Investment:** {{TARGET_ESOP_PCT}} %
- **Pre-Money oder Post-Money Pool Top-up:** {{ESOP_TOP_UP_TIMING}} (Standard: Pre-Money Gründer tragen Verwässerung)
## § 8 Pre-emptive Rights (Bezugsrechte)
Der Investor erhält bei künftigen Finanzierungsrunden ein Pro-Rata-Bezugsrecht, um seine Beteiligungsquote aufrechtzuerhalten.
## § 9 Right of First Refusal (ROFR) / Co-Sale
Bei Anteilsverkäufen durch Gründer:
- **ROFR:** Investor hat Vorkaufsrecht (14-Tage-Frist)
- **Co-Sale-Right:** Investor kann pro-rata mitverkaufen, falls ROFR nicht ausgeübt wird
## § 10 Closing-Conditions
Das Closing ist u.a. an folgende Bedingungen geknüpft:
- Erfolgreiche Due Diligence (rechtlich, technisch, finanziell)
- Abschluss aller Definitive Documents (Beteiligungsvertrag, SHA, ggf. Satzungsänderung)
- Bestätigung der IP-Übertragung von den Gründern auf die Gesellschaft
- Keine wesentliche nachteilige Veränderung (Material Adverse Change")
- Erforderliche Genehmigungen (insb. ggf. behördliche Anzeigen)
- Existenz/Anpassung relevanter Anstellungsverträge der Schlüsselpersonen
## § 11 Due Diligence
Der Investor wird eine Due Diligence durchführen mit Schwerpunkt auf:
- **Legal:** Satzung, SHA, IP, Verträge, Compliance
- **Technical:** Architektur, Skalierbarkeit, IP-Sicherung, Open-Source-Compliance
- **Financial:** Liquidität, Cap Table, Forecasts, Unit Economics
- **Commercial:** Kundenbasis, Markt, Wettbewerb
Die Gesellschaft sichert volle Kooperation zu und stellt einen Datenraum zur Verfügung.
---
## **§ 12 EXKLUSIVITÄT (BINDEND)**
Die Gesellschaft und die Gründer verpflichten sich für einen Zeitraum von **{{EXCLUSIVITY_WEEKS}} Wochen** ab Unterzeichnung dieses Term Sheets (Exklusivitätsperiode"), keine Verhandlungen mit anderen potenziellen Investoren über eine vergleichbare Beteiligung zu führen und keinerlei Investitionsangebote Dritter anzunehmen.
## **§ 13 VERTRAULICHKEIT (BINDEND)**
Beide Parteien behandeln den Inhalt dieses Term Sheets sowie alle im Zusammenhang ausgetauschten Informationen vertraulich. Ausnahmen: Offenlegung gegenüber Beratern unter Verschwiegenheitspflicht oder bei gesetzlicher Pflicht.
## **§ 14 KOSTENTRAGUNG (BINDEND)**
{{#IF COMPANY_PAYS_COSTS}}
Die Gesellschaft trägt die angemessenen Rechts- und Beratungskosten des Investors bis zu einem Höchstbetrag von **{{LEGAL_COST_CAP_EUR}} EUR**, fällig bei Closing.
Bei Nicht-Closing aufgrund von Gründen, die der Gesellschaft oder den Gründern zuzurechnen sind, ist eine Erstattung dieser Kosten ebenfalls geschuldet (gedeckelt).
{{/IF}}
{{#IF EACH_PARTY_PAYS}}
Jede Partei trägt ihre eigenen Kosten.
{{/IF}}
## § 15 Anwendbares Recht, Gerichtsstand (BINDEND)
Es gilt deutsches Recht. Gerichtsstand ist der Sitz der Gesellschaft. Streitigkeiten werden, soweit zulässig, durch Mediation versucht beizulegen.
---
## § 16 Sonstiges
(1) **Endgültigkeit der Definitive Documents:** Im Kollisionsfall zwischen diesem Term Sheet und den endgültigen Verträgen (Beteiligungsvertrag, SHA, Satzung) gehen die Definitive Documents vor.
(2) **Verfall:** Dieses Term Sheet verfällt, falls bis zum {{TERM_SHEET_EXPIRY_DATE}} keine Definitive Documents abgeschlossen oder das Closing vollzogen wurde, sofern nicht beide Parteien einer Verlängerung in Textform zustimmen.
(3) **Sprache:** Verbindlich ist die deutsche Fassung. Eine englische Übersetzung ist nur informatorisch.
---
**{{SIGNATURE_LOCATION}}, {{TERM_SHEET_DATE}}**
___________________________
Für den Investor
{{INVESTOR_NAME}} / {{INVESTOR_REPRESENTATIVE}}
___________________________
Für die Gesellschaft
{{COMPANY_REPRESENTATIVE}}
{{FOUNDER_SIGNATURES}}
---
## Anlage A Gründer / Gesellschafter
{{FOUNDERS_LIST_DETAILS}}
## Anlage B Cap Table (vor und nach Investment)
{{CAP_TABLE_PRE_POST}}
$template$,
'["INVESTOR_NAME","INVESTOR_REPRESENTATIVE","COMPANY_NAME","COMPANY_ADDRESS","COMPANY_REPRESENTATIVE","COMPANY_REGISTRY_COURT","HRB_NUMBER","HAS_HRB","TERM_SHEET_DATE","INVESTMENT_PHASE","EXCLUSIVITY_END_DATE","TERM_SHEET_EXPIRY_DATE","INVESTMENT_AMOUNT_EUR","PRE_MONEY_VALUATION_EUR","POST_MONEY_VALUATION_EUR","INVESTMENT_INSTRUMENT","INVESTOR_STAKE_PCT","TARGET_CLOSING_DATE","HAS_TRANCHES","TRANCHES_TABLE","USE_OF_PROCEEDS","IS_PREFERRED_SHARES","IS_COMMON_SHARES","HAS_LIQUIDATION_PREFERENCE","LIQ_PREF_MULTIPLIER","IS_PARTICIPATING","HAS_ANTI_DILUTION","ANTI_DILUTION_METHOD","LOAN_THRESHOLD_EUR","HAS_BOARD_SEAT","BOARD_SIZE","INVESTOR_BOARD_SEATS","FOUNDER_BOARD_SEATS","INDEPENDENT_BOARD_SEATS","BOARD_MEETING_FREQ","HAS_OBSERVER","ANNUAL_REPORT_MONTHS","VESTING_MONTHS","CLIFF_MONTHS","VESTING_START_REF","ACCELERATION_THRESHOLD_PCT","FOUNDER_LOCKUP_MONTHS","DRAG_ALONG_THRESHOLD_PCT","TAG_ALONG_THRESHOLD_PCT","CURRENT_ESOP_PCT","TARGET_ESOP_PCT","ESOP_TOP_UP_TIMING","EXCLUSIVITY_WEEKS","COMPANY_PAYS_COSTS","EACH_PARTY_PAYS","LEGAL_COST_CAP_EUR","SIGNATURE_LOCATION","FOUNDER_SIGNATURES","FOUNDERS_LIST_DETAILS","CAP_TABLE_PRE_POST"]'::jsonb,
'de','DE','mit','MIT License','BreakPilot Compliance',false,true,'1.0.0','published',NOW(),NOW()
;
SELECT document_type, title, LENGTH(content) FROM compliance_legal_templates WHERE document_type = 'term_sheet' ORDER BY created_at DESC LIMIT 1;
@@ -0,0 +1,251 @@
-- Migration 133: Wandeldarlehensvertrag (Convertible Loan Agreement) Template
-- Bridge Financing fuer Pre-Seed/Seed-Phase ohne fixe Bewertung zum Zeichnungszeitpunkt
-- Wandlung bei qualifizierter Finanzierungsrunde, Laufzeitende oder Exit
-- Wandlungspreis = niedrigerer Wert von Cap und (Naechste-Runde-Preis - Discount)
-- Mit qualifiziertem Rangruecktritt (§ 39 Abs. 2 InsO), MFN-Klausel, Reserved Matters
-- Optionale Bloecke: HAS_TRANCHES, HAS_FOUNDER_GUARANTEE, HAS_OBSERVER_RIGHT, HAS_FOUNDER_PARTIES,
-- HAS_ARBITRATION, INVESTOR_HAS_HRB, HAS_HRB
INSERT INTO compliance_legal_templates (
id, tenant_id, document_type, title, description, content,
placeholders, language, jurisdiction,
license_id, license_name, source_name,
attribution_required, is_complete_document, version, status,
created_at, updated_at
) SELECT
gen_random_uuid(),
'9282a473-5c95-4b3a-bf78-0ecc0ec71d3e',
'convertible_loan_agreement',
'Wandeldarlehensvertrag (Convertible Loan Agreement)',
'Bridge-Finanzierung fuer Pre-Seed/Seed-Phase ohne fixe Bewertung. Wandlung in Geschaeftsanteile bei qualifizierter Finanzierungsrunde, Laufzeitende oder Exit. Wandlungspreis als niedrigerer Wert von Bewertungs-Cap und Discount auf die naechste Runde (Markt-Standard 15-25%). Mit qualifiziertem Rangruecktritt nach § 39 Abs. 2 InsO, Most-Favored-Nation-Klausel, Reserved Matters, optionalem Observer-Recht, Pre-emptive Rights. Skalierbar fuer Single-Tranche oder Multi-Tranche Auszahlung, mit oder ohne Gruenderbuergschaft. Orientiert an BvK-Standard und deutscher VC-Praxis.',
$template$
# Wandeldarlehensvertrag (Convertible Loan Agreement)
zwischen
**{{INVESTOR_NAME}}**, {{INVESTOR_ADDRESS}}{{#IF INVESTOR_HAS_HRB}}, eingetragen unter HRB {{INVESTOR_HRB}}{{/IF}}, vertreten durch {{INVESTOR_REPRESENTATIVE}}
- nachfolgend Darlehensgeber" / „Investor" -
**{{COMPANY_NAME}}**, {{COMPANY_ADDRESS}}{{#IF HAS_HRB}}, eingetragen im Handelsregister des Amtsgerichts {{COMPANY_REGISTRY_COURT}} unter HRB {{HRB_NUMBER}}{{/IF}}, vertreten durch {{COMPANY_REPRESENTATIVE}}
- nachfolgend Darlehensnehmerin" / „Gesellschaft" -
{{#IF HAS_FOUNDER_PARTIES}}
und
**den in Anlage A genannten Gründern**
- nachfolgend Gründer" -
{{/IF}}
---
## Dokumentenkontrolle
| Feld | Wert |
|---|---|
| Vertragstyp | Wandeldarlehensvertrag (Convertible Loan) |
| Darlehenssumme | {{LOAN_AMOUNT_EUR}} EUR |
| Datum | {{LOAN_DATE}} |
| Laufzeit max. | {{LOAN_MAX_TERM_MONTHS}} Monate |
| Version | {{DOCUMENT_VERSION}} |
---
## Präambel
(A) Die Gesellschaft betreibt ein Unternehmen im Bereich {{COMPANY_PURPOSE_SHORT}} und beabsichtigt, ihre Geschäftstätigkeit auszubauen.
(B) Der Investor ist bereit, der Gesellschaft ein Darlehen zur Verfügung zu stellen, das nach Maßgabe dieses Vertrages in Geschäftsanteile der Gesellschaft gewandelt werden kann.
(C) Die Parteien wollen mit diesem Vertrag eine Vorfinanzierung (Bridge Financing") schaffen, ohne dass bereits zum jetzigen Zeitpunkt eine Bewertung der Gesellschaft erfolgen muss; die Bewertung erfolgt bei Wandlung.
---
## § 1 Darlehenssumme und Auszahlung
(1) Der Investor gewährt der Gesellschaft ein nachrangiges Darlehen in Höhe von **{{LOAN_AMOUNT_EUR}} EUR** (Darlehen").
(2) {{#IF HAS_TRANCHES}}Die Auszahlung erfolgt in folgenden Tranchen:
{{TRANCHES_TABLE}}
{{/IF}}
{{#IF NOT HAS_TRANCHES}}Die Auszahlung erfolgt in einer Summe innerhalb von {{PAYOUT_DAYS}} Tagen nach Unterzeichnung und Eintritt aller Auszahlungsbedingungen.{{/IF}}
(3) **Auszahlungsbedingungen:**
(a) Vollständige Unterzeichnung dieses Vertrages durch alle Parteien,
(b) Vorlage der aktuellen Gesellschafterliste{{#IF HAS_SHA}} und des SHA{{/IF}},
(c) keine wesentliche nachteilige Veränderung (Material Adverse Change") seit Unterzeichnung,
(d) Bestätigung der ordnungsgemäßen Beschlussfassung in der Gesellschafterversammlung.
(4) Die Auszahlung erfolgt auf folgendes Konto der Gesellschaft: {{COMPANY_BANK_ACCOUNT}}
## § 2 Verzinsung
(1) Das Darlehen ist mit **{{INTEREST_RATE_PCT}} % p.a.** zu verzinsen (Standard im DE-Markt: 4-8 % p.a.).
(2) Die Zinsen werden **{{INTEREST_ACCRUAL_MODE}}** (z.B. jährlich kapitalisiert", „bei Wandlung dem Darlehensbetrag zugeschlagen", quartalsweise gezahlt").
(3) Bei Verzug mit Rückzahlung gilt der gesetzliche Verzugszins (§ 288 BGB).
## § 3 Laufzeit und Rückzahlung
(1) Das Darlehen hat eine maximale Laufzeit von **{{LOAN_MAX_TERM_MONTHS}} Monaten** ab Auszahlung.
(2) Vor Ablauf der Laufzeit kann das Darlehen nicht ordentlich gekündigt werden.
(3) Bei Ablauf der Laufzeit ohne vorherige Wandlung gemäß § 4 ist das Darlehen samt aufgelaufenen Zinsen zur Rückzahlung fällig, sofern die Parteien nicht eine Verlängerung der Laufzeit oder eine Wandlung einvernehmlich vereinbaren.
(4) Eine vorzeitige Rückzahlung durch die Gesellschaft ist nur mit Zustimmung des Investors zulässig.
## § 4 Wandlung in Geschäftsanteile
### 4.1 Wandlungs-Trigger
Das Darlehen ist in Geschäftsanteile der Gesellschaft zu wandeln bei Eintritt eines der folgenden Ereignisse (Wandlungsereignis"):
(a) **Qualifizierte Finanzierungsrunde:** Die Gesellschaft schließt eine Eigenkapital-Finanzierungsrunde mit einem Brutto-Volumen von mindestens **{{QUALIFIED_FINANCING_THRESHOLD_EUR}} EUR** ab (Pflichtwandlung).
(b) **Laufzeitende:** Bei Ablauf der Laufzeit nach § 3 Abs. (1), sofern keine Rückzahlung erfolgt und der Investor die Wandlung wählt (Wahlrecht).
(c) **Exit:** Vor einem Change-of-Control-Ereignis (Verkauf der Gesellschaft) wandelt der Investor das Darlehen in Anteile, sofern die Wandlung wirtschaftlich günstiger ist als die Rückzahlung.
### 4.2 Wandlungspreis
Der Wandlungspreis je Geschäftsanteil ist der **niedrigere** der folgenden Werte:
(a) **Bewertungs-Cap (Pre-Money):** {{VALUATION_CAP_EUR}} EUR
(b) **Discount auf die nächste Runde:** Preis pro Anteil der qualifizierten Finanzierungsrunde abzüglich **{{DISCOUNT_PCT}} %** (typisch: 15-25 %)
Bei Wandlung am Laufzeitende ohne Finanzierungsrunde gilt der Bewertungs-Cap als Pre-Money-Bewertung.
### 4.3 Wandlungsmodalitäten
(1) Wandlungssumme = Darlehenssumme + aufgelaufene Zinsen bis zum Wandlungsstichtag.
(2) Anzahl der zu schaffenden Geschäftsanteile = Wandlungssumme ÷ Wandlungspreis pro Anteil (auf den nächsten ganzen EUR gerundet).
(3) Die Wandlung erfolgt durch Kapitalerhöhung gegen Sacheinlage (Aufrechnung der Darlehensforderung gegen Bareinlage) gemäß § 56 GmbHG. Die Gesellschafter verpflichten sich, allen erforderlichen Beschlüssen zuzustimmen.
(4) Der Investor erhält Anteile derselben Klasse wie die Investoren der qualifizierten Finanzierungsrunde (Variante: derselben Klasse wie bestehende Stammanteile, falls keine Vorzugsklasse existiert).
## § 5 Most-Favored-Nation (MFN)
Sollten zwischen Unterzeichnung dieses Vertrages und der Wandlung andere Wandeldarlehensverträge mit für den Investor günstigeren Konditionen (Discount, Cap, Zins, Sicherheiten) abgeschlossen werden, ist der Investor berechtigt, diese günstigeren Konditionen für sich zu beanspruchen (MFN-Klausel").
## § 6 Nachrang und Sicherheiten
(1) **Nachrang.** Das Darlehen ist gegenüber allen anderen Verbindlichkeiten der Gesellschaft, mit Ausnahme von Verbindlichkeiten gegenüber Gesellschaftern, nachrangig (qualifizierter Rangrücktritt gemäß § 39 Abs. 2 InsO).
(2) **Keine Sicherheiten.** Das Darlehen wird ohne Sicherheiten gewährt.
{{#IF HAS_FOUNDER_GUARANTEE}}
(3) **Garantie der Gründer.** Die Gründer übernehmen für die Rückzahlung des Darlehens eine selbstschuldnerische Bürgschaft bis zu einem Höchstbetrag von {{FOUNDER_GUARANTEE_EUR}} EUR pro Gründer.
{{/IF}}
## § 7 Garantien der Gesellschaft
Die Gesellschaft garantiert:
(a) Bestand und Wirksamkeit ihrer Gründung und ihrer Geschäftsführerbestellung,
(b) zutreffende Darstellung der Beteiligungsverhältnisse in der zur Verfügung gestellten Cap Table,
(c) freie Verfügbarkeit ihrer wesentlichen IP-Rechte (siehe IP-Assignment-Agreement),
(d) keine bestehenden, anhängigen oder absehbaren Rechtsstreitigkeiten von wesentlicher Bedeutung,
(e) ordnungsgemäße Erfüllung aller steuerlichen und sozialversicherungsrechtlichen Pflichten,
(f) keine wesentliche Verletzung von Datenschutz- oder Compliance-Pflichten.
Bei Verletzung dieser Garantien ist der Investor berechtigt, sofortige Rückzahlung zu verlangen und Schadensersatz zu fordern.
## § 8 Informationsrechte
Der Investor erhält folgende Informationsrechte:
(1) Monatlicher Liquiditätsbericht,
(2) Quartalsweise Reporting (P&L, Bilanz, Cashflow, KPI),
(3) Jährlicher (geprüfter) Jahresabschluss innerhalb von {{ANNUAL_REPORT_MONTHS}} Monaten,
(4) Unverzügliche Information bei wesentlichen Ereignissen (Liquiditätsrisiken, Rechtsstreite, Schlüsselpersonen-Verlust, geplante Finanzierungen).
## § 9 Beirat / Observer (optional)
{{#IF HAS_OBSERVER_RIGHT}}
Der Investor erhält das Recht, **einen nicht-stimmberechtigten Beobachter (Observer)** in Beirats- und Gesellschafterversammlungen zu entsenden, solange das Darlehen besteht oder die durch Wandlung erworbenen Anteile mindestens {{OBSERVER_THRESHOLD_PCT}} % am Stammkapital betragen.
{{/IF}}
## § 10 Pre-emptive Rights
Bei künftigen Eigenkapital-Finanzierungsrunden erhält der Investor das Recht, bis zur Höhe seiner (nach Wandlung) bestehenden Pro-Rata-Quote zu zeichnen, um seine Beteiligungsquote aufrechtzuerhalten.
## § 11 Reserved Matters bis zur Wandlung
Bis zur Wandlung dürfen folgende Maßnahmen nur mit Zustimmung des Investors erfolgen:
(a) Aufnahme weiterer Darlehen außerhalb des gewöhnlichen Geschäftsbetriebs,
(b) Verkauf oder Belastung wesentlicher Vermögenswerte,
(c) wesentliche Änderung des Geschäftsmodells,
(d) Liquidation oder Insolvenz,
(e) Verkauf der Gesellschaft (Change of Control),
(f) Ausgabe neuer Anteile zu einer Bewertung unter dem Bewertungs-Cap dieses Vertrages.
## § 12 Vertraulichkeit
Beide Parteien behandeln den Inhalt dieses Vertrages und alle im Zusammenhang ausgetauschten Informationen vertraulich. Ausnahmen: Offenlegung gegenüber Beratern unter Verschwiegenheitspflicht, in Due-Diligence-Prozessen oder bei gesetzlicher Pflicht.
## § 13 Übertragung
(1) Der Investor kann seine Rechte aus diesem Vertrag mit Zustimmung der Gesellschaft auf einen Dritten übertragen. Die Zustimmung darf nicht unbillig verweigert werden. Bei Übertragung an verbundene Unternehmen entfällt das Zustimmungserfordernis.
(2) Die Gesellschaft darf ihre Pflichten aus diesem Vertrag nicht ohne Zustimmung des Investors übertragen.
## § 14 Verzug und Default-Events
(1) Bei einem der folgenden Default-Events ist das Darlehen samt Zinsen sofort fällig:
(a) Insolvenzantrag oder Eröffnung des Insolvenzverfahrens,
(b) wesentliche Verletzung der Garantien gemäß § 7,
(c) Verstoß gegen die Reserved Matters gemäß § 11,
(d) Liquidationsbeschluss,
(e) Wesentliche Pflichtverletzung, die nicht innerhalb von {{CURE_PERIOD_DAYS}} Tagen nach Mahnung geheilt wird.
(2) Der Investor kann in diesen Fällen alternativ die Wandlung gemäß § 4 wählen.
## § 15 Schlussbestimmungen
(1) **Schriftform.** Änderungen bedürfen der Schriftform.
(2) **Salvatorische Klausel.** Unwirksame Bestimmungen berühren nicht die Wirksamkeit der übrigen Bestimmungen.
(3) **Anwendbares Recht und Gerichtsstand.** Es gilt deutsches Recht. Gerichtsstand ist {{JURISDICTION_LOCATION}}.
(4) **Verhältnis zu anderen Vereinbarungen.** Im Konfliktfall mit Satzung oder SHA hat dieser Vertrag im Innenverhältnis Vorrang, soweit rechtlich zulässig.
(5) **Schiedsklausel (optional).** {{#IF HAS_ARBITRATION}}Streitigkeiten aus diesem Vertrag werden gemäß DIS-Schiedsgerichtsordnung in {{ARBITRATION_SEAT}} entschieden.{{/IF}}
---
**{{SIGNATURE_LOCATION}}, {{LOAN_DATE}}**
___________________________
Für den Investor
{{INVESTOR_REPRESENTATIVE}}
___________________________
Für die Gesellschaft
{{COMPANY_REPRESENTATIVE}}
{{#IF HAS_FOUNDER_PARTIES}}
**Gründer als Mit-Vertragspartner:**
{{FOUNDER_SIGNATURES}}
{{/IF}}
---
## Anlage A Gründer / Gesellschafter
{{FOUNDERS_LIST_DETAILS}}
## Anlage B Cap Table zum Zeitpunkt der Unterzeichnung
{{CAP_TABLE_CURRENT}}
$template$,
'["INVESTOR_NAME","INVESTOR_ADDRESS","INVESTOR_REPRESENTATIVE","INVESTOR_HAS_HRB","INVESTOR_HRB","COMPANY_NAME","COMPANY_ADDRESS","COMPANY_REGISTRY_COURT","HRB_NUMBER","HAS_HRB","COMPANY_REPRESENTATIVE","COMPANY_PURPOSE_SHORT","COMPANY_BANK_ACCOUNT","HAS_FOUNDER_PARTIES","LOAN_AMOUNT_EUR","LOAN_DATE","LOAN_MAX_TERM_MONTHS","DOCUMENT_VERSION","HAS_TRANCHES","TRANCHES_TABLE","PAYOUT_DAYS","HAS_SHA","INTEREST_RATE_PCT","INTEREST_ACCRUAL_MODE","QUALIFIED_FINANCING_THRESHOLD_EUR","VALUATION_CAP_EUR","DISCOUNT_PCT","HAS_FOUNDER_GUARANTEE","FOUNDER_GUARANTEE_EUR","HAS_OBSERVER_RIGHT","OBSERVER_THRESHOLD_PCT","ANNUAL_REPORT_MONTHS","CURE_PERIOD_DAYS","JURISDICTION_LOCATION","HAS_ARBITRATION","ARBITRATION_SEAT","SIGNATURE_LOCATION","FOUNDER_SIGNATURES","FOUNDERS_LIST_DETAILS","CAP_TABLE_CURRENT"]'::jsonb,
'de','DE','mit','MIT License','BreakPilot Compliance',false,true,'1.0.0','published',NOW(),NOW()
;
SELECT document_type, title, LENGTH(content) FROM compliance_legal_templates WHERE document_type = 'convertible_loan_agreement' ORDER BY created_at DESC LIMIT 1;
@@ -0,0 +1,338 @@
-- Migration 134: Beteiligungs- und Investmentvertrag (Subscription Agreement) Template
-- Notariell beurkundeter Vertrag fuer Eigenkapital-Beteiligung
-- Mit Kapitalerhoehung gegen Bezugsrechtsausschluss, Reps & Warranties,
-- Liquidation Preference (Non-Participating Standard), Anti-Dilution (Broad-Based Weighted Average),
-- Reserved Matters, Informationsrechte, Founder Vesting/Lock-up, Drag/Tag-Along, ESOP-Pool-Anpassung
-- Orientiert an BvK Standard-Beteiligungsvertrag und VC-Praxis (Series Seed/A)
-- Optionale Bloecke: INVESTOR_HAS_HRB, HAS_FOREIGN_INVESTOR, IS_PREFERRED_SHARES/IS_COMMON_SHARES,
-- HAS_LIQUIDATION_PREFERENCE, IS_PARTICIPATING, HAS_ANTI_DILUTION, HAS_MULTI_INVESTOR,
-- HAS_BOARD_SEAT, HAS_OBSERVER, VESTING_CREDIT_PAST_TIME, HAS_ARBITRATION
INSERT INTO compliance_legal_templates (
id, tenant_id, document_type, title, description, content,
placeholders, language, jurisdiction,
license_id, license_name, source_name,
attribution_required, is_complete_document, version, status,
created_at, updated_at
) SELECT
gen_random_uuid(),
'9282a473-5c95-4b3a-bf78-0ecc0ec71d3e',
'subscription_agreement',
'Beteiligungs- und Investmentvertrag (Subscription Agreement)',
'Notariell beurkundeter Eigenkapital-Beteiligungsvertrag fuer Venture Capital / Angel Investments (Pre-Seed bis Series A+). Regelt Kapitalerhoehung mit Bezugsrechtsausschluss, Anteilsuebernahme, Closing-Conditions, Reps & Warranties der Gesellschaft und Bestandsgesellschafter, Haftungscaps und Verjaehrungsfristen, Liquidation Preference (Non-Participating Standard, 1x), Anti-Dilution Schutz (Broad-Based Weighted Average), umfangreiche Reserved Matters, Informationsrechte (monatlich/quartalsweise/jaehrlich), Beirat mit Investor-Sitz, ESOP-Pool-Anpassung (Pre-/Post-Money), Founder Vesting und Lock-up, SHA-Beitritt. Orientiert an BvK-Mustertext und deutscher VC-Praxis.',
$template$
# Beteiligungs- und Investmentvertrag (Subscription Agreement)
zwischen
**{{INVESTOR_NAME}}**, {{INVESTOR_ADDRESS}}{{#IF INVESTOR_HAS_HRB}}, eingetragen unter HRB {{INVESTOR_HRB}}{{/IF}}, vertreten durch {{INVESTOR_REPRESENTATIVE}}
- nachfolgend Investor" -
**{{COMPANY_NAME}}**, {{COMPANY_ADDRESS}}, eingetragen im Handelsregister des Amtsgerichts {{COMPANY_REGISTRY_COURT}} unter HRB {{HRB_NUMBER}}, vertreten durch {{COMPANY_REPRESENTATIVE}}
- nachfolgend Gesellschaft" -
und
**den in Anlage A genannten Gesellschaftern**
- nachfolgend Bestandsgesellschafter" oder „Gründer" -
---
## Dokumentenkontrolle
| Feld | Wert |
|---|---|
| Vertragstyp | Beteiligungs- und Investmentvertrag |
| Investment-Phase | {{INVESTMENT_PHASE}} |
| Datum | {{INVESTMENT_DATE}} |
| Beurkundender Notar | {{NOTARY_NAME}}, {{NOTARY_PLACE}}, URNr. {{NOTARY_URNR}} |
| Closing-Datum | {{CLOSING_DATE}} |
| Version | {{DOCUMENT_VERSION}} |
**Hinweis:** Dieser Vertrag bedarf der notariellen Beurkundung (§ 15 GmbHG für die Anteilsübernahme und § 53 GmbHG für die Kapitalerhöhung). Die endgültige Form ergibt sich aus der notariellen Urkunde.
---
## Präambel
(A) Die Gesellschaft ist eine im Handelsregister eingetragene {{COMPANY_LEGAL_FORM}} mit Sitz in {{COMPANY_SEAT}} und einem Stammkapital von **{{CURRENT_STAMMKAPITAL_EUR}} EUR**.
(B) Der Investor beabsichtigt, sich an der Gesellschaft im Rahmen der **{{INVESTMENT_PHASE}}-Finanzierungsrunde** mit einem Investitionsbetrag von **{{INVESTMENT_AMOUNT_EUR}} EUR** zu beteiligen.
(C) Die Beteiligung erfolgt im Wege einer Kapitalerhöhung unter Ausschluss des Bezugsrechts der Bestandsgesellschafter zugunsten des Investors.
(D) Die Pre-Money-Bewertung der Gesellschaft wird zwischen den Parteien einvernehmlich auf **{{PRE_MONEY_VALUATION_EUR}} EUR** festgelegt. Die Post-Money-Bewertung beträgt entsprechend **{{POST_MONEY_VALUATION_EUR}} EUR**.
(E) Die Parteien wollen mit diesem Vertrag die Bedingungen der Beteiligung, die Rechte und Pflichten des Investors sowie die Anpassungen der Satzung und des Shareholders' Agreement (SHA) regeln.
---
## § 1 Kapitalerhöhung und Anteilsübernahme
### 1.1 Kapitalerhöhung
(1) Das Stammkapital der Gesellschaft wird von **{{CURRENT_STAMMKAPITAL_EUR}} EUR** um **{{CAPITAL_INCREASE_EUR}} EUR** auf **{{NEW_STAMMKAPITAL_EUR}} EUR** erhöht.
(2) Die Kapitalerhöhung erfolgt durch Schaffung von **{{NEW_SHARES_COUNT}} neuen Geschäftsanteilen** zu je {{SHARE_NENNBETRAG_EUR}} EUR Nennbetrag.
(3) Die neuen Geschäftsanteile werden vom Investor unter Ausschluss des Bezugsrechts der Bestandsgesellschafter übernommen.
### 1.2 Anteilsklasse
{{#IF IS_PREFERRED_SHARES}}
Der Investor erhält **Vorzugsanteile (Preferred Shares) Klasse {{PREFERRED_CLASS}}** mit den nachfolgend beschriebenen besonderen Rechten (siehe §§ 5-7).
{{/IF}}
{{#IF IS_COMMON_SHARES}}
Der Investor erhält **Stammanteile (Common Shares)** ohne besondere Vorzugsrechte.
{{/IF}}
### 1.3 Ausgabepreis und Aufgeld (Agio)
(1) Der Ausgabepreis beträgt insgesamt **{{INVESTMENT_AMOUNT_EUR}} EUR**, davon:
- Nennwert der neuen Anteile: **{{CAPITAL_INCREASE_EUR}} EUR**
- Agio (in die Kapitalrücklage gem. § 272 Abs. 2 Nr. 4 HGB): **{{AGIO_EUR}} EUR**
(2) Der Ausgabepreis je Anteil beträgt **{{PRICE_PER_SHARE_EUR}} EUR**.
### 1.4 Resultierende Beteiligung
Nach Durchführung der Kapitalerhöhung beträgt die Beteiligung des Investors **{{INVESTOR_STAKE_POST_PCT}} %** am Stammkapital.
## § 2 Closing und Auszahlung
### 2.1 Closing-Datum
Das Closing erfolgt am **{{CLOSING_DATE}}** durch:
(a) notariell beurkundete Kapitalerhöhung und Anteilsübernahme,
(b) Eintragung der Kapitalerhöhung im Handelsregister,
(c) Aktualisierung der Gesellschafterliste.
### 2.2 Closing-Conditions (Vollzugsvoraussetzungen)
Das Closing ist an folgende Bedingungen geknüpft, die spätestens am Closing-Datum erfüllt sein müssen:
(a) Erfolgreicher Abschluss der Due Diligence ohne wesentliche negative Erkenntnisse,
(b) Notarielle Beurkundung dieses Vertrages, der Satzungsänderung und des SHA-Beitritts,
(c) Vorlage aller in **Anlage D** aufgeführten Closing-Dokumente,
(d) Bestätigung der unverändert gültigen Garantien der Gesellschaft und der Bestandsgesellschafter zum Closing-Datum,
(e) Keine wesentliche nachteilige Veränderung (Material Adverse Change") seit Unterzeichnung,
(f) Vorliegen aller behördlichen Genehmigungen,
(g) {{#IF HAS_FOREIGN_INVESTOR}}Außenwirtschaftsrechtliche Unbedenklichkeitsbescheinigung gemäß § 58 AWV (falls anwendbar),{{/IF}}
(h) Vorliegen wirksamer IP-Assignment-Agreements aller Gründer.
### 2.3 Auszahlung
Der Investmentbetrag ist nach Vollzug aller Closing-Conditions auf das in **Anlage B** benannte Konto der Gesellschaft zu überweisen. Eine Zwischenfinanzierung erfolgt nicht.
## § 3 Verwendung der Mittel (Use of Proceeds)
Die Investmentmittel werden gemäß dem in **Anlage C** beschriebenen Business Plan eingesetzt, insbesondere für:
{{USE_OF_PROCEEDS}}
Wesentliche Abweichungen vom Use of Proceeds (>{{USE_OF_PROCEEDS_DEVIATION_PCT}} %) bedürfen der Zustimmung des Investors.
## § 4 Garantien (Reps & Warranties)
### 4.1 Garantien der Gesellschaft
Die Gesellschaft garantiert zum Datum der Unterzeichnung und zum Closing-Datum (Stichtag):
(a) **Bestand:** Die Gesellschaft ist wirksam gegründet, im Handelsregister eingetragen und vertretungsbefugt geleitet.
(b) **Cap Table:** Die in Anlage A aufgeführte Cap Table ist vollständig und korrekt. Es bestehen keine weiteren Anteile, Wandelrechte, Optionen oder Verpflichtungen zur Anteilsausgabe außerhalb der Anlage A.
(c) **Stammkapital:** Das Stammkapital ist vollständig und unwiderruflich eingezahlt; keine Rückzahlungen erfolgten.
(d) **IP-Rechte:** Die Gesellschaft hält alle für ihr Geschäftsmodell wesentlichen IP-Rechte ungeschmälert; sämtliche Gründer-IP wurde wirksam übertragen.
(e) **Verträge:** Alle wesentlichen Verträge bestehen rechtswirksam und werden ordnungsgemäß erfüllt; keine wesentlichen Kündigungen sind angekündigt.
(f) **Rechtsstreite:** Keine wesentlichen Rechtsstreite anhängig oder absehbar.
(g) **Compliance:** Einhaltung von DSGVO, Steuer-, Sozialversicherungs- und sonstigen rechtlichen Pflichten.
(h) **Finanzen:** Die offengelegten Finanzdaten (Bilanz, P&L, Cashflow) entsprechen der tatsächlichen Lage.
(i) **Keine versteckten Verbindlichkeiten** außerhalb der offengelegten.
(j) **Schlüsselpersonen:** Die in Anlage E benannten Schlüsselpersonen sind aktiv und beabsichtigen keinen Austritt.
### 4.2 Garantien der Bestandsgesellschafter
Jeder Bestandsgesellschafter garantiert für sich selbst:
(a) er ist Inhaber der in Anlage A genannten Anteile, frei von Rechten Dritter,
(b) keine Verpflichtungen zur Übertragung der Anteile bestehen,
(c) keine Vesting-/Leaver-Tatbestände sind eingetreten oder absehbar,
(d) Vollmacht und Geschäftsfähigkeit für diesen Vertrag liegen vor.
### 4.3 Rechtsfolgen bei Garantieverletzung
(1) Bei Verletzung einer Garantie hat der Investor folgende Ansprüche:
(a) Schadensersatz in Höhe des entstandenen Schadens (Naturalrestitution bevorzugt),
(b) bei wesentlicher Verletzung: Rücktritt vom Vertrag innerhalb von 6 Monaten nach Kenntnis.
(2) **Haftungshöchstbetrag:** {{LIABILITY_CAP_PCT}} % des Investmentbetrages, ausgenommen bei vorsätzlichen Verletzungen oder Garantien zu Title (Cap Table, IP).
(3) **Verjährung:** {{WARRANTY_VERJAEHRUNG_JAHRE}} Jahre ab Closing für allgemeine Garantien; {{WARRANTY_TAX_VERJAEHRUNG_JAHRE}} Jahre für steuerliche Garantien.
## § 5 Liquidation Preference
{{#IF HAS_LIQUIDATION_PREFERENCE}}
Im Falle eines Liquidationsereignisses (Exit, Verkauf, Liquidation der Gesellschaft) erhält der Investor:
(1) **{{LIQ_PREF_MULTIPLIER}}× non-participating Liquidation Preference**:
- Vorzugsweise Auszahlung des {{LIQ_PREF_MULTIPLIER}}-fachen seines Investments aus den verfügbaren Erlösen,
- danach Verteilung verbleibender Erlöse pro-rata an alle Anteilsklassen.
(2) **Wahlrecht (Non-Participating Standard):** Der Investor kann zwischen Liquidation Preference und seinem pro-rata Anteil am Gesamterlös wählen, je nachdem was höher ist.
(3) Bei mehreren Vorzugsklassen gilt {{LIQ_PREF_SENIORITY}} (z.B. pari passu" oder „seniority by series").
{{/IF}}
## § 6 Anti-Dilution Schutz
{{#IF HAS_ANTI_DILUTION}}
Bei künftigen Finanzierungsrunden zu einem Preis pro Anteil **unter** dem aktuellen Investment-Preis (Down-Round") greift folgender Verwässerungsschutz:
(1) **Methode:** **{{ANTI_DILUTION_METHOD}}** (Marktstandard: Broad-Based Weighted Average)
(2) **Berechnung:** Anpassung des Wandlungspreises der Vorzugsanteile nach der Formel:
NCP = CP × (A + B) / (A + C)
wobei:
- NCP = New Conversion Price (neuer Wandlungspreis)
- CP = Current Conversion Price
- A = Bestehende Anteile (Common + Preferred) vor neuer Runde
- B = Anteile, die zum aktuellen CP für den neuen Investmentbetrag erworben würden
- C = Anteile, die zum neuen niedrigeren Preis tatsächlich ausgegeben werden
(3) **Ausnahmen vom Verwässerungsschutz:**
- ESOP-Pool-Allokationen (bis zur in der Satzung definierten Pool-Größe),
- Anteile aus Akquisitionen mit Anteils-Gegenleistung,
- Anteile aus Lizenz- oder Kooperationsvereinbarungen,
- Pflichtwandlungen von Wandeldarlehen.
{{/IF}}
## § 7 Reserved Matters
Folgende Entscheidungen bedürfen der Zustimmung des Investors{{#IF HAS_MULTI_INVESTOR}} bzw. der Mehrheit der Investoren{{/IF}}:
(a) Änderung der Satzung oder des SHA,
(b) Aufnahme weiterer Gesellschafter, Ausgabe neuer Anteile oder Wandelrechte,
(c) Erhöhung oder Herabsetzung des Stammkapitals,
(d) Aufnahme von Krediten > **{{LOAN_THRESHOLD_EUR}} EUR**,
(e) Erwerb, Veräußerung oder Belastung von Vermögenswerten > **{{ASSET_THRESHOLD_EUR}} EUR**,
(f) Abschluss/Änderung von Geschäftsführerverträgen, Bestellung/Abberufung Geschäftsführer,
(g) Wesentliche Änderung des Geschäftsmodells oder Eintritt in neue Geschäftsfelder,
(h) Budgetfreigaben oder Abweichungen > **{{BUDGET_ABWEICHUNG_PCT}} %**,
(i) Strategische Kooperationen größeren Umfangs,
(j) Erwerb oder Veräußerung von IP-Rechten von erheblicher Bedeutung,
(k) Entscheidungen über ESOP-Plan (Erweiterung, wesentliche Änderungen),
(l) Drag-Along, Tag-Along, Verkauf der Gesellschaft (Change of Control),
(m) Liquidation, Insolvenzantrag, Strukturmaßnahmen,
(n) Wandlung oder Rückzahlung anderer Wandeldarlehen.
## § 8 Informationsrechte und Reporting
Der Investor erhält folgende Informationsrechte:
(1) **Monatlich:** Liquiditätsbericht, KPI-Dashboard (innerhalb 10 Werktagen nach Monatsende),
(2) **Quartalsweise:** Quartalsabschluss (P&L, Bilanz, Cashflow), Status Risk Register, Cap Table (innerhalb 30 Tagen),
(3) **Jährlich:** Auditierter Jahresabschluss innerhalb von {{ANNUAL_REPORT_MONTHS}} Monaten nach GJ-Ende,
(4) **Budget:** Jährliches Budget mit Begründung 30 Tage vor Beginn des neuen Geschäftsjahres,
(5) **Ad-hoc:** Materielle Ereignisse, Liquiditätsrisiken, Rechtsstreite, Schlüsselpersonenverlust.
## § 9 Beirat / Board
{{#IF HAS_BOARD_SEAT}}
(1) Es wird ein Beirat gemäß Satzung/SHA eingerichtet mit:
- **{{FOUNDER_BOARD_SEATS}}** Sitz(e) für die Gründer,
- **{{INVESTOR_BOARD_SEATS}}** Sitz(e) für den Investor,
- **{{INDEPENDENT_BOARD_SEATS}}** unabhängige(s) Mitglied(er) (durch gemeinsamen Beschluss benannt).
(2) Sitzungsfrequenz: mindestens {{BOARD_MEETING_FREQ}}, plus ad-hoc bei Bedarf.
(3) Der Investor entsendet als Beiratsmitglied: **{{INVESTOR_BOARD_REP}}**.
{{/IF}}
{{#IF HAS_OBSERVER}}
(4) **Observer:** Der Investor kann zusätzlich einen nicht-stimmberechtigten Observer entsenden, der an allen Sitzungen teilnimmt und Materialien erhält.
{{/IF}}
## § 10 Founder Vesting und Lock-up
(1) **Vesting:** Die Gründer-Anteile unterliegen folgendem Vesting (Re-Vesting für diese Runde):
- Dauer: **{{VESTING_MONTHS}} Monate** ab Closing,
- Cliff: **{{CLIFF_MONTHS}} Monate**,
- Anrechnung bisheriger Vesting-Zeit: {{#IF VESTING_CREDIT_PAST_TIME}}ja, gemäß SHA{{/IF}}{{#IF NOT VESTING_CREDIT_PAST_TIME}}nein - Re-Vesting beginnt neu{{/IF}}.
(2) **Founder Lock-up:** Die Gründer verpflichten sich für **{{FOUNDER_LOCKUP_MONTHS}} Monate** nach Closing zu Full-Time-Commitment zur Gesellschaft.
(3) **Acceleration:** Bei Change of Control mit Verkauf > **{{ACCELERATION_THRESHOLD_PCT}} %** der Anteile beschleunigt sich das Vesting zu **{{ACCELERATION_PCT}} %** (Single-Trigger).
## § 11 Drag-Along, Tag-Along, Vorkaufsrechte
Es gelten die Regelungen des SHA, insbesondere:
- **Drag-Along-Schwelle:** **{{DRAG_ALONG_THRESHOLD_PCT}} %**
- **Tag-Along ab:** **{{TAG_ALONG_THRESHOLD_PCT}} %** Anteilsverkauf
- **Vorkaufsrechte:** Pro-Rata-Recht gemäß SHA
## § 12 ESOP / Mitarbeiter-Beteiligung
(1) Vor Closing wird der **ESOP-Pool auf {{TARGET_ESOP_PCT}} %** des Stammkapitals (Post-Money) angepasst.
(2) Die Anpassung erfolgt {{ESOP_TOP_UP_TIMING}} (Pre-Money: Gründer tragen Verwässerung / Post-Money: alle Gesellschafter inkl. Investor tragen anteilig).
(3) Der ESOP-Plan wird durch separaten Gesellschafterbeschluss verabschiedet und unterliegt den Reserved Matters gemäß § 7.
## § 13 Pre-emptive Rights (Bezugsrechte)
Bei künftigen Eigenkapital-Finanzierungsrunden hat der Investor das Recht, bis zur Höhe seiner aktuellen Beteiligungsquote pro-rata neue Anteile zu zeichnen, um seine Beteiligungsquote aufrechtzuerhalten.
## § 14 Right of First Refusal und Co-Sale
(1) **ROFR:** Bei Anteilsverkäufen durch Gründer hat der Investor ein Vorkaufsrecht (14 Tage).
(2) **Co-Sale-Right:** Wird der ROFR nicht ausgeübt, kann der Investor pro-rata mitverkaufen (Tag-Along).
## § 15 Beitritt zum SHA
Mit Closing tritt der Investor dem bestehenden Shareholders' Agreement (SHA) bei. Eine angepasste Version des SHA mit Aufnahme des Investors als Vertragspartei ist als **Anlage F** beigefügt.
## § 16 Vertraulichkeit
Beide Parteien behandeln den Inhalt dieses Vertrages und alle ausgetauschten Informationen vertraulich. Ausnahmen: Offenlegung gegenüber Beratern, in Due-Diligence-Prozessen oder bei gesetzlicher Pflicht.
## § 17 Schlussbestimmungen
(1) **Schriftform / Notarielle Beurkundung.** Dieser Vertrag bedarf hinsichtlich der Kapitalerhöhung und Anteilsübernahme der notariellen Beurkundung. Spätere Änderungen bedürfen der Schriftform, soweit nicht gesetzlich eine strengere Form vorgeschrieben ist.
(2) **Salvatorische Klausel.** Unwirksame Bestimmungen berühren nicht die Wirksamkeit der übrigen Bestimmungen.
(3) **Anwendbares Recht und Gerichtsstand.** Es gilt deutsches Recht. Gerichtsstand ist {{JURISDICTION_LOCATION}}.
(4) **Verhältnis zu Satzung und SHA.** Bei Konflikten zwischen diesem Vertrag, Satzung und SHA gilt:
1. zwingendes Recht
2. Satzung (im Außenverhältnis)
3. SHA (im Innenverhältnis vorrangig)
4. dieser Beteiligungsvertrag
(5) **Schiedsklausel (optional).** {{#IF HAS_ARBITRATION}}Streitigkeiten werden gemäß DIS-Schiedsgerichtsordnung in {{ARBITRATION_SEAT}} entschieden.{{/IF}}
---
**{{NOTARIAL_LOCATION}}, {{INVESTMENT_DATE}}**
(Unterschriften erfolgen in notarieller Beurkundung)
---
## Anlagen
- **Anlage A:** Cap Table (vor und nach Investment)
- **Anlage B:** Bankverbindung der Gesellschaft
- **Anlage C:** Business Plan und Use of Proceeds
- **Anlage D:** Closing-Dokumente-Checkliste
- **Anlage E:** Liste der Schlüsselpersonen
- **Anlage F:** Angepasstes Shareholders' Agreement (SHA) mit Investor-Beitritt
- **Anlage G:** Disclosure Letter (Offenlegungen zu Garantien)
- **Anlage H:** Datenraum-Index
$template$,
'["INVESTOR_NAME","INVESTOR_ADDRESS","INVESTOR_REPRESENTATIVE","INVESTOR_HAS_HRB","INVESTOR_HRB","COMPANY_NAME","COMPANY_ADDRESS","COMPANY_SEAT","COMPANY_LEGAL_FORM","COMPANY_REGISTRY_COURT","HRB_NUMBER","COMPANY_REPRESENTATIVE","INVESTMENT_PHASE","INVESTMENT_DATE","CLOSING_DATE","NOTARY_NAME","NOTARY_PLACE","NOTARY_URNR","NOTARIAL_LOCATION","DOCUMENT_VERSION","CURRENT_STAMMKAPITAL_EUR","INVESTMENT_AMOUNT_EUR","PRE_MONEY_VALUATION_EUR","POST_MONEY_VALUATION_EUR","CAPITAL_INCREASE_EUR","NEW_STAMMKAPITAL_EUR","NEW_SHARES_COUNT","SHARE_NENNBETRAG_EUR","AGIO_EUR","PRICE_PER_SHARE_EUR","INVESTOR_STAKE_POST_PCT","IS_PREFERRED_SHARES","IS_COMMON_SHARES","PREFERRED_CLASS","HAS_FOREIGN_INVESTOR","USE_OF_PROCEEDS","USE_OF_PROCEEDS_DEVIATION_PCT","LIABILITY_CAP_PCT","WARRANTY_VERJAEHRUNG_JAHRE","WARRANTY_TAX_VERJAEHRUNG_JAHRE","HAS_LIQUIDATION_PREFERENCE","LIQ_PREF_MULTIPLIER","LIQ_PREF_SENIORITY","HAS_ANTI_DILUTION","ANTI_DILUTION_METHOD","HAS_MULTI_INVESTOR","LOAN_THRESHOLD_EUR","ASSET_THRESHOLD_EUR","BUDGET_ABWEICHUNG_PCT","ANNUAL_REPORT_MONTHS","HAS_BOARD_SEAT","FOUNDER_BOARD_SEATS","INVESTOR_BOARD_SEATS","INDEPENDENT_BOARD_SEATS","BOARD_MEETING_FREQ","INVESTOR_BOARD_REP","HAS_OBSERVER","VESTING_MONTHS","CLIFF_MONTHS","VESTING_CREDIT_PAST_TIME","FOUNDER_LOCKUP_MONTHS","ACCELERATION_THRESHOLD_PCT","ACCELERATION_PCT","DRAG_ALONG_THRESHOLD_PCT","TAG_ALONG_THRESHOLD_PCT","TARGET_ESOP_PCT","ESOP_TOP_UP_TIMING","JURISDICTION_LOCATION","HAS_ARBITRATION","ARBITRATION_SEAT"]'::jsonb,
'de','DE','mit','MIT License','BreakPilot Compliance',false,true,'1.0.0','published',NOW(),NOW()
;
SELECT document_type, title, LENGTH(content) FROM compliance_legal_templates WHERE document_type = 'subscription_agreement' ORDER BY created_at DESC LIMIT 1;
@@ -0,0 +1,310 @@
-- Migration 135: ESOP/VSOP/Phantom Stock Plan Template (Mitarbeiterbeteiligung)
-- Skalierbar zwischen 3 Varianten: VSOP (virtuell, Cash-Auszahlung), ESOP (echte Anteile), Phantom Stock
-- Mit Pool-Definition, Vesting (48/12 Standard), Acceleration (Single-/Double-Trigger),
-- Leaver-Behandlung (Good/Neutral/Bad), Rueckkaufsrecht, Verwaesserungsschutz, Plan-Verwaltung
-- Inklusive Muster-Allokationsvereinbarung (Grant Letter) als Anlage
-- Optionale Bloecke: IS_VSOP / IS_REAL_ESOP / IS_PHANTOM, HAS_DOUBLE_TRIGGER,
-- HAS_DIVIDEND_TRIGGER, HAS_SECONDARY_TRIGGER, HAS_EXPIRY_TRIGGER, HAS_SHA
INSERT INTO compliance_legal_templates (
id, tenant_id, document_type, title, description, content,
placeholders, language, jurisdiction,
license_id, license_name, source_name,
attribution_required, is_complete_document, version, status,
created_at, updated_at
) SELECT
gen_random_uuid(),
'9282a473-5c95-4b3a-bf78-0ecc0ec71d3e',
'esop_plan',
'ESOP / VSOP / Phantom Stock Plan (Mitarbeiterbeteiligung)',
'Mitarbeiterbeteiligungsplan in drei Varianten: VSOP (virtuelle Anteile mit Cash-Auszahlung bei Exit), ESOP (echte Geschaeftsanteile mit moeglicher § 19a EStG Tarifguenstigung), Phantom Stock (virtuelle Wertentwicklung). Skalierbar fuer Pool-Groesse, Vesting (Standard 48 Monate / 12 Cliff), Single-/Double-Trigger Acceleration bei Exit, Leaver-Behandlung (Good/Neutral/Bad), Rueckkaufsrecht, Verwaesserungsschutz und Plan-Verwaltung. Inklusive Muster-Allokationsvereinbarung (Grant Letter). Orientiert an Hexa/Carta-Standards und deutscher Marktpraxis.',
$template$
# {{ESOP_TYPE_LABEL}} - Mitarbeiterbeteiligungsplan
der **{{COMPANY_NAME}}**
---
## Dokumentenkontrolle
| Feld | Wert |
|---|---|
| Plan-Typ | {{ESOP_TYPE_LABEL}} ({{ESOP_TYPE_SHORT}}) |
| Gesellschaft | {{COMPANY_NAME}} |
| Plan-Bezeichnung | {{PLAN_NAME}} |
| Verabschiedung | Gesellschafterbeschluss vom {{ADOPTION_DATE}} |
| Geltungsbereich | Mitarbeiter, Berater, ggf. Geschäftsführer der {{COMPANY_NAME}} |
| Version | {{DOCUMENT_VERSION}} |
> **Hinweis:** Dieser Plan wurde durch Beschluss der Gesellschafterversammlung vom {{ADOPTION_DATE}} verabschiedet. Die konkrete Zuteilung an Berechtigte erfolgt durch individuelle Allokationsvereinbarungen (Anlage).
---
## Präambel
(A) Die {{COMPANY_NAME}} (Gesellschaft") möchte ihre Mitarbeiter, Berater und Schlüsselpersonen langfristig an den wirtschaftlichen Erfolg der Gesellschaft beteiligen.
(B) {{#IF IS_VSOP}}Die Gesellschafterversammlung hat beschlossen, einen **virtuellen Mitarbeiterbeteiligungsplan (VSOP)** einzurichten. Berechtigte erhalten **virtuelle Anteile** ohne tatsächliche Gesellschafterstellung; die Erlöse sind als Cash-Bonus bei Exit oder anderen definierten Trigger-Ereignissen geschuldet.{{/IF}}{{#IF IS_REAL_ESOP}}Die Gesellschafterversammlung hat beschlossen, einen **echten Mitarbeiterbeteiligungsplan (ESOP)** einzurichten mit Übertragung tatsächlicher Geschäftsanteile.{{/IF}}{{#IF IS_PHANTOM}}Die Gesellschafterversammlung hat beschlossen, einen **Phantom-Stock-Plan** einzurichten mit virtuellen Anteilen, deren Wertentwicklung als Bonus ausgezahlt wird.{{/IF}}
(C) Dieser Plan regelt Zuteilung, Vesting, Ausübung und Auszahlung der Beteiligungen.
---
## § 1 Plan-Pool
(1) Die Gesellschaft reserviert einen **Mitarbeiterbeteiligungspool von {{POOL_PCT}} %** des Stammkapitals (vollverwässert nach Investment / Post-Money), nachfolgend Pool".
(2) Der Pool entspricht **{{POOL_ABSOLUTE_VALUE}}** {{#IF IS_VSOP}}virtuellen Anteilen{{/IF}}{{#IF IS_REAL_ESOP}}Geschäftsanteilen{{/IF}}{{#IF IS_PHANTOM}}Phantom Shares{{/IF}}.
(3) Erweiterungen des Pools bedürfen eines Gesellschafterbeschlusses mit der im SHA{{#IF HAS_SHA}} festgelegten Mehrheit{{/IF}} (typisch: 75 %) und unterliegen den Reserved Matters.
(4) Verfallene oder zurückgekaufte Beteiligungen fließen in den Pool zurück und können neu zugeteilt werden.
## § 2 Berechtigte
(1) **Berechtigtenkreis** sind Personen, die zur Gesellschaft in einem der folgenden Verhältnisse stehen:
(a) Arbeitsverhältnis (Vollzeit, Teilzeit, befristet),
(b) Geschäftsführerdienstvertrag,
(c) Beratungsvertrag (Advisor) mit fortgesetzter und materieller Mitwirkung,
(d) Freier-Mitarbeiter-Vertrag mit Erwartung längerfristiger Mitwirkung.
(2) **Auswahl.** Die Geschäftsführung schlägt Berechtigte vor; die Zuteilung bedarf der Zustimmung des Beirats/der Gesellschafter gemäß SHA bzw. Reserved Matters.
(3) **Nicht berechtigt** sind:
(a) Gesellschafter, soweit sie ihre Anteile aus der Gründung halten und in dieser Rolle bereits beteiligt sind,
(b) Personen mit beendetem Arbeits-/Beratungsverhältnis,
(c) externe Vertragspartner ohne strategische Schlüsselrolle.
## § 3 Allokation und Strike Price
### 3.1 Allokation
(1) Die Allokation pro Berechtigtem erfolgt durch individuelle **Allokationsvereinbarung** (Grant Letter"), die diesen Plan in Bezug nimmt.
(2) Die Allokation richtet sich nach Funktion, Erfahrung, Marktstandards und Beitrag zur Gesellschaft.
(3) Beispielhafte Richtwerte:
| Funktion | Allokationsband |
|---|---|
| C-Level (CXO) | {{CXO_ALLOCATION_RANGE}} |
| Senior Engineering / Sales | {{SENIOR_ALLOCATION_RANGE}} |
| Mid-Level | {{MID_ALLOCATION_RANGE}} |
| Junior / Operational | {{JUNIOR_ALLOCATION_RANGE}} |
| Advisor | {{ADVISOR_ALLOCATION_RANGE}} |
### 3.2 Strike Price (Ausübungspreis)
{{#IF IS_VSOP}}
(1) Bei VSOP gibt es **keinen direkten Strike Price** im klassischen Sinne. Die Auszahlung erfolgt als Differenz zwischen Exit-Wert pro virtuellem Anteil und dem **Reference Price** zum Zeitpunkt der Allokation.
(2) **Reference Price** = aktuelle Pre-Money-Bewertung ÷ Gesamtzahl Anteile zum Allokationszeitpunkt.
{{/IF}}
{{#IF IS_REAL_ESOP}}
(1) Der **Strike Price (Ausübungspreis)** pro Anteil entspricht dem Verkehrswert zum Zeitpunkt der Allokation, mindestens dem Nennbetrag von {{SHARE_NENNBETRAG_EUR}} EUR.
(2) **Steuerlicher Hinweis:** Eine Ausgabe unter Verkehrswert kann steuerpflichtige geldwerte Vorteile begründen (§ 19a EStG). Empfehlung: Verkehrswert-Bestätigung durch Steuerberater.
{{/IF}}
{{#IF IS_PHANTOM}}
(1) Der **Strike Price** ist auf {{STRIKE_PRICE_EUR}} EUR pro Phantom Share festgelegt (entspricht aktueller Pre-Money-Bewertung ÷ Anteilszahl zum Allokationszeitpunkt).
(2) Bei Trigger-Ereignis erhält der Berechtigte die Differenz zwischen Exit-Preis und Strike Price.
{{/IF}}
## § 4 Vesting
### 4.1 Vesting-Schedule
(1) **Vesting-Dauer:** **{{VESTING_MONTHS}} Monate** (Standard: 48 Monate)
(2) **Cliff:** **{{CLIFF_MONTHS}} Monate** (Standard: 12 Monate)
(3) Vor Ablauf des Cliffs sind keine Anteile vested. Nach Ablauf des Cliffs werden **{{CLIFF_VEST_PCT}} %** ({{CLIFF_MONTHS}}/{{VESTING_MONTHS}}) auf einmal vested. Danach vesten die verbleibenden Anteile monatlich linear.
(4) **Vesting-Beginn:**
- für Mitarbeiter: Datum des Eintritts in die Gesellschaft,
- für Geschäftsführer: Datum der Bestellung,
- für Advisor: Datum der Allokationsvereinbarung.
### 4.2 Acceleration bei Exit
(1) **Single-Trigger Acceleration:** Bei Change-of-Control-Ereignis (Verkauf von > {{ACCELERATION_THRESHOLD_PCT}} % der Anteile) wird das Vesting zu **{{ACCELERATION_PCT}} %** beschleunigt.
(2) **Double-Trigger Acceleration:** Bei {{#IF HAS_DOUBLE_TRIGGER}}Change of Control kombiniert mit unverschuldeter Beendigung des Arbeitsverhältnisses innerhalb von 12 Monaten nach Closing wird das verbleibende Vesting vollständig (100 %) beschleunigt.{{/IF}}{{#IF NOT HAS_DOUBLE_TRIGGER}}Nicht vorgesehen.{{/IF}}
### 4.3 Pausierung des Vestings
(1) Bei Elternzeit, längerer Krankheit oder einvernehmlicher Pausierung der Tätigkeit kann das Vesting pausieren. Die Bedingungen werden in der Allokationsvereinbarung geregelt.
(2) Eine reduzierte Verfügbarkeit (z. B. Teilzeit) führt nicht automatisch zur Pausierung; gegebenenfalls erfolgt eine **proportionale Anpassung** des Vesting-Tempos.
## § 5 Trigger-Ereignisse und Auszahlung
### 5.1 Trigger-Ereignisse
Folgende Ereignisse lösen die Auszahlung der vested Beteiligungen aus:
(a) **Exit:** Verkauf von > {{EXIT_THRESHOLD_PCT}} % der Anteile (Trade Sale, Asset Deal, IPO),
(b) **Liquidation:** Auflösung der Gesellschaft mit verbleibendem Vermögen,
(c) {{#IF HAS_DIVIDEND_TRIGGER}}**Dividendenausschüttung:** Anteilige Beteiligung an Ausschüttungen aus dem Bilanzgewinn,{{/IF}}
(d) {{#IF HAS_SECONDARY_TRIGGER}}**Secondary-Verkauf:** Bei Verkäufen einzelner Gründer-Anteile zu Marktpreisen können vested Beteiligungen anteilig mitverkauft werden,{{/IF}}
(e) {{#IF HAS_EXPIRY_TRIGGER}}**Plan-Ablauf:** Nach Ablauf von {{PLAN_EXPIRY_YEARS}} Jahren ohne Exit erfolgt eine Bewertung und ggf. Auszahlung gemäß Beschluss der Gesellschafterversammlung.{{/IF}}
### 5.2 Berechnung der Auszahlung
{{#IF IS_VSOP}}
Auszahlung = Vested Virtuelle Anteile × (Exit-Preis pro Anteil Reference Price)
{{/IF}}
{{#IF IS_REAL_ESOP}}
Erlös = Vested Anteile × Exit-Preis pro Anteil
abzüglich Strike Price × Anzahl Anteile (bereits bezahlt bei Ausübung)
{{/IF}}
{{#IF IS_PHANTOM}}
Auszahlung = Vested Phantom Shares × (Exit-Preis pro Anteil Strike Price)
{{/IF}}
### 5.3 Zahlungsmodalitäten
(1) Die Auszahlung erfolgt innerhalb von **{{PAYOUT_DAYS}} Tagen** nach Eingang der Verkaufserlöse bei der Gesellschaft.
(2) **Vesting-Cap:** Die Auszahlung pro Berechtigtem ist auf das **{{PAYOUT_CAP_MULTIPLIER}}-fache** des Bruttojahresgehalts bei Allokation gedeckelt, sofern dies in der Allokationsvereinbarung vorgesehen ist.
(3) **Steuerliche Behandlung:** Die Auszahlung gilt grundsätzlich als Arbeitslohn (Lohnsteuer + Sozialabgaben){{#IF IS_VSOP}}; bei VSOP keine günstige Tarifierung gemäß § 19a EStG{{/IF}}{{#IF IS_REAL_ESOP}}; bei ESOP ggf. günstige Tarifierung gemäß § 19a EStG (Mitarbeiterkapitalbeteiligung){{/IF}}.
## § 6 Leaver-Regelungen
### 6.1 Leaver-Kategorien (entsprechend SHA-Definition)
(a) **Good Leaver:** Tod, dauerhafte Krankheit, Elternzeit, einvernehmlicher Beschluss, betriebsbedingte Kündigung durch die Gesellschaft.
(b) **Neutral Leaver:** Eigenkündigung des Berechtigten ohne Pflichtverletzung.
(c) **Bad Leaver:** Außerordentliche Kündigung durch die Gesellschaft aufgrund Pflichtverletzung, Verstoß gegen Wettbewerbsverbot oder Vertraulichkeit, strafbares Verhalten.
### 6.2 Behandlung beim Leaver-Event
| Status | Unvested | Vested |
|---|---|---|
| **Good Leaver** | Verfall | Behalten zu vollem Wert |
| **Neutral Leaver** | Verfall | Behalten zu **{{NEUTRAL_VESTED_PCT}} %** des Werts (Standard: 100% für Optionen, ggf. Rückkauf zum FMV) |
| **Bad Leaver** | Verfall | Verfall oder Rückkauf zum **Nennbetrag/Strike Price** (max. Einlage) |
### 6.3 Rückkaufsrecht der Gesellschaft
(1) Bei Ausscheiden hat die Gesellschaft das Recht (nicht Pflicht), vested Beteiligungen vom Berechtigten zurückzukaufen.
(2) Rückkaufspreis:
- **Good Leaver:** Fair Market Value (gemäß SHA-Bestimmung)
- **Neutral Leaver:** Fair Market Value, ggf. mit Abschlag (siehe Allokationsvereinbarung)
- **Bad Leaver:** Strike Price / Nennbetrag
(3) Zahlung in bis zu **{{BUYBACK_INSTALLMENTS}}** Monatsraten möglich.
## § 7 Übertragbarkeit
(1) Die Beteiligungen sind **nicht übertragbar**, ausgenommen:
(a) Erbfall (Übergang auf Erben mit Auflage der Plan-Bedingungen),
(b) Übertragung an verbundene Personen mit Zustimmung der Geschäftsführung.
(2) Eine Verpfändung, Beleihung oder sonstige Belastung ist ausgeschlossen.
## § 8 Verwässerungsschutz und Anpassungen
(1) Bei Kapitalerhöhungen, Anteilsteilungen, Zusammenlegungen oder ähnlichen Kapitalmaßnahmen wird der Plan-Pool **proportional angepasst**, sodass die wirtschaftliche Position der Berechtigten nicht verschlechtert wird.
(2) Bei künftigen Finanzierungsrunden mit Verwässerungsschutz für Investoren gelten die Anpassungen analog für den ESOP-Pool, soweit dies in den Investorenverträgen vorgesehen ist.
## § 9 Plan-Verwaltung
(1) Der Plan wird von der **Geschäftsführung** verwaltet, in Abstimmung mit dem Beirat (sofern vorhanden) und unter Beachtung der Reserved Matters.
(2) Wesentliche Entscheidungen (Pool-Erweiterung, Plan-Änderungen, Allokationen >{{MATERIAL_ALLOCATION_PCT}} %) bedürfen der Zustimmung der Gesellschafterversammlung gemäß SHA.
(3) Die Geschäftsführung führt ein **Plan-Register** mit allen Allokationen, Vesting-Ständen und Ausübungen.
## § 10 Kommunikation und Transparenz
(1) Berechtigte erhalten jährlich eine **Vesting-Übersicht** mit:
(a) Allokationsdatum, Strike Price/Reference Price,
(b) bisher vested und noch unvested Anteile,
(c) Vesting-Schedule mit Daten,
(d) Hinweise zu Steuerimplikationen.
(2) Bei wesentlichen Ereignissen (Exit, Kapitalerhöhung mit Pool-Anpassung) werden Berechtigte informiert.
## § 11 Vertraulichkeit
Berechtigte verpflichten sich zur Vertraulichkeit über Plan-Inhalte, Allokationen anderer Berechtigter und damit verbundene Geschäftsgeheimnisse.
## § 12 Plan-Änderungen und Beendigung
(1) Änderungen dieses Plans bedürfen eines Gesellschafterbeschlusses mit der im SHA festgelegten Mehrheit.
(2) Änderungen, die die Position bereits Berechtigter wesentlich verschlechtern, bedürfen zusätzlich der Zustimmung der betroffenen Berechtigten.
(3) Der Plan endet bei:
(a) Auflösung der Gesellschaft,
(b) Vollständiger Auszahlung aller vested Beteiligungen,
(c) Beschluss der Gesellschafterversammlung zur Plan-Beendigung (unter Wahrung erworbener Ansprüche).
## § 13 Schlussbestimmungen
(1) **Anwendbares Recht:** Deutsches Recht.
(2) **Gerichtsstand:** Sitz der Gesellschaft.
(3) **Salvatorische Klausel:** Unwirksame Bestimmungen berühren nicht die Wirksamkeit der übrigen.
(4) **Schriftform:** Änderungen bedürfen der Schriftform.
(5) **Anlagen:** Muster-Allokationsvereinbarung (Anlage 1).
---
**Verabschiedet durch Gesellschafterbeschluss vom {{ADOPTION_DATE}}.**
___________________________
Für die Gesellschaft / Geschäftsführung
---
## Anlage 1 Muster-Allokationsvereinbarung (Grant Letter)
**An:** {{BENEFICIARY_NAME}}
Lieber/Liebe {{BENEFICIARY_NAME}},
hiermit teilen wir Dir die folgende Beteiligung am {{PLAN_NAME}} der {{COMPANY_NAME}} zu:
| Punkt | Wert |
|---|---|
| **Anzahl Anteile** | {{ALLOCATED_SHARES_COUNT}} ({{ALLOCATED_PCT}} % am Pool) |
| **Strike Price / Reference Price** | {{STRIKE_PRICE_EUR}} EUR pro Anteil |
| **Allokationsdatum** | {{ALLOCATION_DATE}} |
| **Vesting-Beginn** | {{VESTING_START_DATE}} |
| **Vesting-Schedule** | {{VESTING_MONTHS}} Monate, Cliff {{CLIFF_MONTHS}} Monate |
| **Acceleration bei Exit** | {{ACCELERATION_PCT}} % Single-Trigger |
| **Auszahlung** | {{PAYOUT_TYPE}} |
Es gelten die Bedingungen des {{PLAN_NAME}} in der gültigen Fassung vom {{ADOPTION_DATE}}.
Mit Annahme dieser Allokationsvereinbarung bestätigst Du, den Plan gelesen und verstanden zu haben.
Bei Fragen kontaktiere bitte {{CONTACT_PERSON}}.
Herzlich willkommen im {{PLAN_NAME}}!
**{{COMPANY_SEAT}}, {{ALLOCATION_DATE}}**
___________________________
Für die Gesellschaft
___________________________
{{BENEFICIARY_NAME}} (Berechtigte/r)
$template$,
'["ESOP_TYPE_LABEL","ESOP_TYPE_SHORT","COMPANY_NAME","COMPANY_SEAT","PLAN_NAME","ADOPTION_DATE","DOCUMENT_VERSION","IS_VSOP","IS_REAL_ESOP","IS_PHANTOM","POOL_PCT","POOL_ABSOLUTE_VALUE","HAS_SHA","CXO_ALLOCATION_RANGE","SENIOR_ALLOCATION_RANGE","MID_ALLOCATION_RANGE","JUNIOR_ALLOCATION_RANGE","ADVISOR_ALLOCATION_RANGE","SHARE_NENNBETRAG_EUR","STRIKE_PRICE_EUR","VESTING_MONTHS","CLIFF_MONTHS","CLIFF_VEST_PCT","ACCELERATION_THRESHOLD_PCT","ACCELERATION_PCT","HAS_DOUBLE_TRIGGER","EXIT_THRESHOLD_PCT","HAS_DIVIDEND_TRIGGER","HAS_SECONDARY_TRIGGER","HAS_EXPIRY_TRIGGER","PLAN_EXPIRY_YEARS","PAYOUT_DAYS","PAYOUT_CAP_MULTIPLIER","NEUTRAL_VESTED_PCT","BUYBACK_INSTALLMENTS","MATERIAL_ALLOCATION_PCT","BENEFICIARY_NAME","ALLOCATED_SHARES_COUNT","ALLOCATED_PCT","ALLOCATION_DATE","VESTING_START_DATE","PAYOUT_TYPE","CONTACT_PERSON"]'::jsonb,
'de','DE','mit','MIT License','BreakPilot Compliance',false,true,'1.0.0','published',NOW(),NOW()
;
SELECT document_type, title, LENGTH(content) FROM compliance_legal_templates WHERE document_type = 'esop_plan' ORDER BY created_at DESC LIMIT 1;
@@ -0,0 +1,205 @@
-- Migration 136: Cap Table (Beteiligungsstruktur) Template
-- Strukturierte Uebersicht ueber Gesellschafter, Investoren, Convertibles, ESOP-Pool
-- Fuer Investor Due Diligence, interne Steuerung und Exit-Vorbereitung
-- Enthaelt Verwaesserungsanalyse, Liquidations-Wasserfall-Simulation und Historie
-- Dynamische Tabellen-Inhalte via Platzhalter (GRUENDER_ZEILEN, INVESTOREN_ZEILEN, etc.)
-- Optionale Bloecke: HAS_INVESTOREN, HAS_CONVERTIBLES, HAS_ESOP_POOL, HAS_OTHERS,
-- HAS_NEW_INVESTORS, HAS_ASSUMED_SERIES_A, HAS_LIQUIDATION_WATERFALL
INSERT INTO compliance_legal_templates (
id, tenant_id, document_type, title, description, content,
placeholders, language, jurisdiction,
license_id, license_name, source_name,
attribution_required, is_complete_document, version, status,
created_at, updated_at
) SELECT
gen_random_uuid(),
'9282a473-5c95-4b3a-bf78-0ecc0ec71d3e',
'cap_table',
'Cap Table (Beteiligungsstruktur)',
'Strukturierte Uebersicht ueber die Beteiligungsverhaeltnisse einer Gesellschaft. Zeigt vollverwaessert (Fully Diluted) Gruender-Anteile, Investoren mit Anteilsklasse und Liquidation Preferences, ausstehende Wandeldarlehen mit Cap/Discount, ESOP/VSOP-Pool inkl. Top-Berechtigte, Vesting-Status der Gruender, Verwaesserungsanalyse (Convertible-Wandlung, ESOP-Full-Vesting, Series-A-Annahme), Liquidations-Wasserfall-Simulation und Historie der Veraenderungen. Hinweis: Rechtlich verbindlich ist die Gesellschafterliste nach § 40 GmbHG.',
$template$
# Cap Table (Beteiligungsstruktur) der {{COMPANY_NAME}}
---
## Dokumentenkontrolle
| Feld | Wert |
|---|---|
| Gesellschaft | {{COMPANY_NAME}} |
| Stand | {{CAP_TABLE_DATE}} |
| Anlass | {{CAP_TABLE_REASON}} (z.B. Gründung, Finanzierungsrunde, Exit-Vorbereitung) |
| Erstellt von | {{CAP_TABLE_AUTHOR}} |
| Version | {{DOCUMENT_VERSION}} |
> **Hinweis:** Diese Cap Table ist eine **Übersicht** der Beteiligungsverhältnisse. Die rechtlich verbindliche Darstellung ergibt sich aus der **Gesellschafterliste** nach § 40 GmbHG und dem Handelsregisterauszug. Bei Abweichungen gilt die Gesellschafterliste.
---
## 1. Stammkapital und Anteile
| Position | Wert |
|---|---:|
| **Stammkapital aktuell** | {{CURRENT_STAMMKAPITAL_EUR}} EUR |
| **Anzahl Anteile gesamt (vollverwässert)** | {{TOTAL_SHARES_FULLY_DILUTED}} |
| **Nennbetrag pro Anteil** | {{SHARE_NENNBETRAG_EUR}} EUR |
| **Aktuelle Pre-Money Bewertung** | {{PRE_MONEY_VALUATION_EUR}} EUR |
| **Aktuelle Post-Money Bewertung** | {{POST_MONEY_VALUATION_EUR}} EUR |
| **Implizierter Preis pro Anteil** | {{IMPLIED_PRICE_PER_SHARE_EUR}} EUR |
## 2. Gesellschafterstruktur (aktuell)
### 2.1 Gründer
| Gesellschafter | Anteile (Nr.) | Nennbetrag (EUR) | Anteil am Stammkapital (%) | Vested (%) | Anteilsklasse |
|---|---:|---:|---:|---:|---|
{{GRUENDER_ZEILEN}}
**Summe Gründer:** {{GRUENDER_SUMME_PCT}} %
### 2.2 Investoren
{{#IF HAS_INVESTOREN}}
| Investor | Round | Anteile (Nr.) | Nennbetrag (EUR) | Anteil (%) | Liquidation Pref. | Klasse |
|---|---|---:|---:|---:|---:|---|
{{INVESTOREN_ZEILEN}}
**Summe Investoren:** {{INVESTOREN_SUMME_PCT}} %
{{/IF}}
{{#IF NOT HAS_INVESTOREN}}
_Noch keine Investoren beteiligt._
{{/IF}}
### 2.3 Convertibles (Wandeldarlehen / SAFE)
{{#IF HAS_CONVERTIBLES}}
| Investor | Datum | Betrag (EUR) | Cap (EUR) | Discount (%) | Verzinsung (%) | Status |
|---|---|---:|---:|---:|---:|---|
{{CONVERTIBLES_ZEILEN}}
**Summe Convertibles (zur Wandlung):** {{CONVERTIBLES_SUMME_EUR}} EUR
{{/IF}}
{{#IF NOT HAS_CONVERTIBLES}}
_Keine ausstehenden Wandeldarlehen._
{{/IF}}
### 2.4 ESOP / VSOP / Phantom Pool
{{#IF HAS_ESOP_POOL}}
| Pool-Typ | Pool-Größe (%) | Bereits zugeteilt (%) | Verfügbar (%) | Strike Price Range |
|---|---:|---:|---:|---|
| {{ESOP_TYPE}} | {{POOL_TOTAL_PCT}} | {{POOL_ALLOCATED_PCT}} | {{POOL_AVAILABLE_PCT}} | {{STRIKE_PRICE_RANGE}} |
**Top-10 Berechtigte (vested + unvested):**
| Berechtigte/r | Funktion | Allokation (%) | Vested (%) | Eintritt | Strike Price (EUR) |
|---|---|---:|---:|---|---:|
{{ESOP_TOP_BERECHTIGTE}}
{{/IF}}
{{#IF NOT HAS_ESOP_POOL}}
_Kein ESOP/VSOP-Pool eingerichtet._
{{/IF}}
### 2.5 Sonstige (Beirat, Advisor, etc.)
{{#IF HAS_OTHERS}}
| Person/Rolle | Anteile (%) | Form | Status |
|---|---:|---|---|
{{SONSTIGE_ZEILEN}}
{{/IF}}
## 3. Übersicht: Beteiligungsverhältnis (gerundet)
| Kategorie | Pre-Money (%) | Post-Money (%) |
|---|---:|---:|
| Gründer | {{GRUENDER_PRE_PCT}} | {{GRUENDER_POST_PCT}} |
| Bestehende Investoren | {{INVESTOREN_PRE_PCT}} | {{INVESTOREN_POST_PCT}} |
| ESOP-Pool | {{ESOP_PRE_PCT}} | {{ESOP_POST_PCT}} |
| Convertibles (bei Wandlung) | {{CONVERTIBLES_PRE_PCT}} | {{CONVERTIBLES_POST_PCT}} |
{{#IF HAS_NEW_INVESTORS}}
| Neue Investoren (aktuelle Runde) | | {{NEW_INVESTORS_POST_PCT}} |
{{/IF}}
| **Summe** | **100,00** | **100,00** |
## 4. Vesting-Status (Founders)
| Gründer | Total Anteile (%) | Vested (%) | Unvested (%) | Vesting-Beginn | Vesting-Ende | Cliff überschritten |
|---|---:|---:|---:|---|---|---|
{{VESTING_STATUS_ZEILEN}}
## 5. Verwässerungsanalyse
### 5.1 Bei Wandlung aller Convertibles
{{#IF HAS_CONVERTIBLES}}
| Annahme | Wert |
|---|---:|
| Nächste Bewertung (Pre-Money) | {{ASSUMED_NEXT_VALUATION_EUR}} EUR |
| Wandlungspreis (gemäß Cap/Discount) | {{CONVERSION_PRICE_EUR}} EUR/Anteil |
| Neue Anteile durch Wandlung | {{NEW_SHARES_FROM_CONVERSION}} |
| Verwässerung Gründer | {{GRUENDER_VERWAESSERUNG_PCT}} %-Pkt |
{{/IF}}
### 5.2 Bei Full-Vesting des ESOP-Pools
{{#IF HAS_ESOP_POOL}}
| Annahme | Wert |
|---|---:|
| Pool-Vergabe | 100 % (alle Allocations vested) |
| Gründer-Verdünnung gegenüber heute | {{GRUENDER_ESOP_VERWAESSERUNG_PCT}} %-Pkt |
{{/IF}}
### 5.3 Bei Series-A-Runde (Annahme)
{{#IF HAS_ASSUMED_SERIES_A}}
| Annahme | Wert |
|---|---:|
| Investment | {{ASSUMED_SERIES_A_AMOUNT_EUR}} EUR |
| Pre-Money | {{ASSUMED_SERIES_A_PRE_MONEY_EUR}} EUR |
| Neue Investoren-Quote (Post) | {{ASSUMED_NEW_INVESTOR_PCT}} % |
| Gründer-Anteil danach | {{GRUENDER_AFTER_A_PCT}} % |
{{/IF}}
## 6. Liquidations-Wasserfall (Exit-Simulation)
{{#IF HAS_LIQUIDATION_WATERFALL}}
Bei einem Exit-Erlös von **{{EXIT_PROCEEDS_EUR}} EUR** würden die Erlöse wie folgt verteilt:
| Rang | Empfänger | Berechnung | Erlös (EUR) | % vom Gesamt |
|---|---|---|---:|---:|
{{LIQUIDATIONS_WATERFALL_ZEILEN}}
**Hinweis:** Vereinfachte Darstellung. Tatsächliche Verteilung hängt von genauen Vorzugsrechten ab.
{{/IF}}
## 7. Historische Veränderungen
| Datum | Ereignis | Auswirkung | Cap-Table-Version |
|---|---|---|---|
{{HISTORIE_ZEILEN}}
## 8. Annahmen und Hinweise
(1) **Vollverwässerte Darstellung:** Alle Tabellen sind in **Fully Diluted** Sicht, inkl. ESOP-Pool und Convertibles.
(2) **Zeitpunkt:** Stand zum **{{CAP_TABLE_DATE}}**. Spätere Änderungen sind in einer aktualisierten Version zu erfassen.
(3) **Rechtlich verbindlich** ist die Gesellschafterliste nach § 40 GmbHG sowie der Handelsregisterauszug.
(4) **Steuerliche Hinweise** werden in dieser Übersicht nicht aufgenommen bitte separat mit Steuerberatung klären (insbesondere zu § 19a EStG, geldwerter Vorteil, Grunderwerbsteuer bei Anteilsübertragungen).
(5) Bei Diskrepanzen zwischen Cap Table, Gesellschafterliste und SHA gilt die jeweils aktuellere und rechtlich relevantere Quelle.
---
**Erstellt am {{CAP_TABLE_DATE}} durch {{CAP_TABLE_AUTHOR}}.**
___________________________
{{CAP_TABLE_AUTHOR}}
{{CAP_TABLE_AUTHOR_ROLE}}
$template$,
'["COMPANY_NAME","CAP_TABLE_DATE","CAP_TABLE_REASON","CAP_TABLE_AUTHOR","CAP_TABLE_AUTHOR_ROLE","DOCUMENT_VERSION","CURRENT_STAMMKAPITAL_EUR","TOTAL_SHARES_FULLY_DILUTED","SHARE_NENNBETRAG_EUR","PRE_MONEY_VALUATION_EUR","POST_MONEY_VALUATION_EUR","IMPLIED_PRICE_PER_SHARE_EUR","GRUENDER_ZEILEN","GRUENDER_SUMME_PCT","HAS_INVESTOREN","INVESTOREN_ZEILEN","INVESTOREN_SUMME_PCT","HAS_CONVERTIBLES","CONVERTIBLES_ZEILEN","CONVERTIBLES_SUMME_EUR","HAS_ESOP_POOL","ESOP_TYPE","POOL_TOTAL_PCT","POOL_ALLOCATED_PCT","POOL_AVAILABLE_PCT","STRIKE_PRICE_RANGE","ESOP_TOP_BERECHTIGTE","HAS_OTHERS","SONSTIGE_ZEILEN","GRUENDER_PRE_PCT","GRUENDER_POST_PCT","INVESTOREN_PRE_PCT","INVESTOREN_POST_PCT","ESOP_PRE_PCT","ESOP_POST_PCT","CONVERTIBLES_PRE_PCT","CONVERTIBLES_POST_PCT","HAS_NEW_INVESTORS","NEW_INVESTORS_POST_PCT","VESTING_STATUS_ZEILEN","ASSUMED_NEXT_VALUATION_EUR","CONVERSION_PRICE_EUR","NEW_SHARES_FROM_CONVERSION","GRUENDER_VERWAESSERUNG_PCT","GRUENDER_ESOP_VERWAESSERUNG_PCT","HAS_ASSUMED_SERIES_A","ASSUMED_SERIES_A_AMOUNT_EUR","ASSUMED_SERIES_A_PRE_MONEY_EUR","ASSUMED_NEW_INVESTOR_PCT","GRUENDER_AFTER_A_PCT","HAS_LIQUIDATION_WATERFALL","EXIT_PROCEEDS_EUR","LIQUIDATIONS_WATERFALL_ZEILEN","HISTORIE_ZEILEN"]'::jsonb,
'de','DE','mit','MIT License','BreakPilot Compliance',false,true,'1.0.0','published',NOW(),NOW()
;
SELECT document_type, title, LENGTH(content) FROM compliance_legal_templates WHERE document_type = 'cap_table' ORDER BY created_at DESC LIMIT 1;
@@ -0,0 +1,53 @@
-- Migration 137: Template-Kategorisierung (Lifecycle + Functional Category)
-- ADDITIVE Aenderung an compliance_legal_templates: zwei neue Spalten
-- Keine Breaking Changes; alte Code-Pfade bleiben funktionsfaehig
-- Spalte 1: Lifecycle Stage (mehrwertig - ein Template kann fuer mehrere Phasen relevant sein)
ALTER TABLE compliance_legal_templates
ADD COLUMN IF NOT EXISTS lifecycle_stage TEXT[] DEFAULT '{}';
-- Spalte 2: Funktionale Kategorie (einwertig)
ALTER TABLE compliance_legal_templates
ADD COLUMN IF NOT EXISTS functional_category TEXT;
-- Index fuer Filter-Performance
CREATE INDEX IF NOT EXISTS idx_clt_lifecycle_stage ON compliance_legal_templates USING gin(lifecycle_stage);
CREATE INDEX IF NOT EXISTS idx_clt_functional_category ON compliance_legal_templates(functional_category);
-- CHECK Constraint fuer functional_category (enum-aehnlich, aber erweiterbar)
ALTER TABLE compliance_legal_templates
DROP CONSTRAINT IF EXISTS chk_clt_functional_category;
ALTER TABLE compliance_legal_templates
ADD CONSTRAINT chk_clt_functional_category CHECK (
functional_category IS NULL OR functional_category IN (
'founding_legal',
'employment',
'investor_funding',
'customer_b2b',
'customer_b2c',
'data_protection',
'it_security',
'ai_governance',
'internal_policy',
'public_facing',
'compliance_process',
'finance_tax',
'vendor_supplier'
)
);
-- CHECK Constraint fuer lifecycle_stage Array-Werte (validate elements)
ALTER TABLE compliance_legal_templates
DROP CONSTRAINT IF EXISTS chk_clt_lifecycle_stage;
ALTER TABLE compliance_legal_templates
ADD CONSTRAINT chk_clt_lifecycle_stage CHECK (
lifecycle_stage <@ ARRAY['pre_founding','founding','startup','kmu','konzern']::TEXT[]
);
-- Verifikation: Schema-Erweiterung
SELECT
column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_name = 'compliance_legal_templates'
AND column_name IN ('lifecycle_stage', 'functional_category')
ORDER BY column_name;

Some files were not shown because too many files have changed in this diff Show More