From 4dce01f4b94c78743bed7b96b2286ee09a469797 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Thu, 13 Aug 2026 17:35:43 +0200 Subject: [PATCH] =?UTF-8?q?feat(B-PLUGIN-GUIDE):=20Plugin-Dev-Guide=20Kapi?= =?UTF-8?q?tel=2010-22=20=E2=80=94=2022=20Kapitel=20komplett?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B-PLUGIN-GUIDE: docs/plugin-development-guide.md 1107→1813 Zeilen - Kapitel 10-22 hinzugefügt (Trigger, Message-System, Search, File Storage, Redis, Permissions, AI Tools, MCP, UI-Events, AI UI Control, Sensitive Data, Migration-Staffelung, Error-Handling) - Supplementary Kapitel 23-29 (vorhandene Inhalte umnummeriert) - Keine Duplikate, keine Änderungen an Kapitel 1-9 --- docs/plugin-development-guide.md | 720 ++++++++++++++++++++++++++++++- 1 file changed, 713 insertions(+), 7 deletions(-) diff --git a/docs/plugin-development-guide.md b/docs/plugin-development-guide.md index 702a2e7..837c2b1 100644 --- a/docs/plugin-development-guide.md +++ b/docs/plugin-development-guide.md @@ -504,7 +504,7 @@ await event_bus.publish("contact.created", {"contact_id": str(contact.id)}) --- -## 7. Migration Runner +## 23. Migration Runner ### 7.1 Writing Migrations @@ -541,7 +541,7 @@ CREATE INDEX idx_my_plugin_items_tenant ON my_plugin_items(tenant_id); --- -## 8. Service Container +## 24. Service Container After activation, plugins can access shared services via `self.services`: @@ -569,7 +569,7 @@ Available services (defined in `app/core/service_container.py`): --- -## 9. RBAC / Permissions +## 25. RBAC / Permissions ### 9.1 Declaring Permissions @@ -624,7 +624,7 @@ field_definitions=[ --- -## 10. AI Agent Integration +## 26. AI Agent Integration ### 10.1 Agent Capabilities @@ -685,7 +685,7 @@ async def on_deactivate(self, db, service_container, event_bus): --- -## 11. Testing Guide +## 27. Testing Guide ### 11.1 Backend Tests @@ -743,7 +743,7 @@ describe('PluginRegistry', () => { --- -## 12. Do's and Don'ts +## 28. Do's and Don'ts ### Do's @@ -771,7 +771,7 @@ describe('PluginRegistry', () => { --- -## 13. Examples +## 29. Examples ### 13.1 Minimal Plugin @@ -1104,4 +1104,710 @@ Plugin-Migrationen werden bei Plugin-Aktivierung automatisch ausgeführt (`sync_ --- +## 10. Trigger + +Plugins können durch vier Trigger-Typen aktiviert werden: **Domain-Events**, **UI-Events**, **Cron-Jobs** und **manuelle Trigger**. Webhook-Trigger folgt in Phase G. + +### 10.1 Domain-Event-Trigger (durable) + +Domain-Events werden über die **Outbox** gepublished und sind reliable. Siehe Kapitel 8 für die Event-System-Rollen. + +```python +# Plugin deklariert Events im Manifest +manifest = PluginManifest( + name="my_plugin", + events=["contact.created", "contact.updated"], + ... +) + +# Handler wird automatisch via on_activate registriert: +async def on_contact_created(self, payload: dict[str, Any]) -> None: + """Reagiert auf contact.created Outbox-Event.""" + contact_id = payload.get("contact_id") + # Business logic here +``` + +### 10.2 UI-Event-Trigger (ephemeral) + +Flüchtige UI-Events (z.B. `ui.contact_selected`) laufen über den **EventBus** — nicht über die Outbox. Siehe Kapitel 18. + +```python +event_bus = get_event_bus() +event_bus.subscribe("ui.contact_selected", self._on_contact_selected) +``` + +### 10.3 Cron-Trigger + +Cron-Jobs werden im Manifest deklariert und vom Worker ausgeführt: + +```python +from app.plugins.manifest import CronJobContribution + +cron_jobs=[ + CronJobContribution( + name="my_plugin_daily_cleanup", + cron_expression="0 3 * * *", + job_type="custom", + plugin_name="my_plugin", + ), +], +``` + +### 10.4 Manuelle Trigger + +Manuelle Trigger laufen über API-Routes oder Automation-Templates: + +```python +@router.post("/api/v1/my-plugin/run-sync") +async def run_manual_sync(request: Request, db: AsyncSession = Depends(get_db)): + """Manueller Trigger — Benutzer startet Sync von der UI.""" + # Business logic + return {"status": "started"} +``` + +**Wichtig:** Durable Domain Events (Outbox) und ephemere UI-Events (EventBus) strikt trennen. Siehe Kapitel 8.5 Entscheidungsregel. + +--- + +## 11. Message-System + +Plugins können System-Nachrichten in Chat-Räume posten, eigene Chat-Räume erstellen und Mini-Apps registrieren. Das Communication-Plugin (`kommunikation`) stellt die Infrastruktur bereit. + +### 11.1 System-Nachrichten posten + +```python +from app.plugins.builtins.kommunikation.contracts import create_plugin_room, send_message + +# Plugin-Room für einen Benutzer erstellen +await create_plugin_room( + db, tenant_id, user_id, + plugin_name="my_plugin", + title="My Plugin", + participant_type="system", + user_role="reader", +) + +# Nachricht in den Room senden +await send_message( + db, tenant_id, conversation_id, + sender_id=None, + sender_type="system", + content="**Sync abgeschlossen**\n120 Kontakte aktualisiert", + content_format="markdown", + blocks=None, + metadata={"event_type": "sync.completed"}, +) +``` + +### 11.2 Rich Content Blocks + +Nachrichten können strukturierte Blocks enthalten (`app/plugins/builtins/kommunikation/content_types.py`): + +```python +blocks = [ + { + "block_type": "action_card", + "block_data": { + "title": "Backup fehlgeschlagen", + "body": "Letztes Backup um 03:00 Uhr ist gescheitert.", + "actions": [ + {"label": "Öffnen", "action": "/settings/backup", "type": "primary"}, + {"label": "Archivieren", "action": "dismiss", "type": "secondary"}, + ], + }, + }, + { + "block_type": "contact_card", + "block_data": {"contact_id": str(contact_id), "name": "Max Mustermann"}, + }, + { + "block_type": "miniapp", + "block_data": {"app_id": "my_miniapp", "config": {"contact_id": str(contact_id)}}, + }, +] + +await send_message(db, tenant_id, conv_id, sender_id=None, sender_type="system", + content="Neuer Kontakt", content_format="markdown", blocks=blocks) +``` + +Unterstützte Block-Typen: `text`, `markdown`, `html`, `image`, `audio`, `video`, `file`, `action_card`, `contact_card`, `miniapp`. + +### 11.3 Mini-Apps registrieren + +Mini-Apps werden im Manifest deklariert: + +```python +from app.plugins.manifest import MiniAppContribution + +miniapps=[ + MiniAppContribution( + app_id="my_miniapp", + name="My Mini App", + icon="AppWindow", + description="Interactive mini-app embedded in chat", + render_schema={"type": "object", "properties": {"contact_id": {"type": "string"}}}, + ), +], +``` + +Siehe `system_notif` Plugin als Referenz-Implementierung. + +--- + +## 12. Search + +Plugins können Search-Provider registrieren, um ihre Entitäten in der Unified Search bereitzustellen. + +### 12.1 SearchProvider implementieren + +Implementiere das `SearchProvider`-Protokoll oder erbe von `BaseSearchProvider` für automatische Visibility-Filterung: + +```python +from app.plugins.builtins.unified_search.base_provider import BaseSearchProvider + +class MyEntitySearchProvider(BaseSearchProvider): + entity_type = "my_entity" + + async def _search_fts_filtered(self, db, tsquery, tenant_id, limit, visible_ids): + # FTS-Query mit visible_ids Filter + ... + + async def _search_vector_filtered(self, db, embedding, tenant_id, limit, visible_ids): + # Vector-Search mit pgvector + visible_ids Filter + ... + + async def get_embedding_text(self, db, entity_id, tenant_id) -> str: + # Text-Repräsentation für Embedding-Generierung + ... + + def to_search_result(self, entity) -> dict: + return {"id": str(entity.id), "type": "my_entity", "title": entity.name} +``` + +### 12.2 Provider registrieren + +```python +from app.plugins.builtins.unified_search.provider_registry import get_search_registry + +async def on_activate(self, db, service_container, event_bus): + await super().on_activate(db, service_container, event_bus) + registry = get_search_registry() + registry.register(MyEntitySearchProvider()) + +async def on_deactivate(self, db, service_container, event_bus): + get_search_registry().unregister("my_entity") + await super().on_deactivate(db, service_container, event_bus) +``` + +### 12.3 Unterstützte Modi + +| Modus | Beschreibung | +|-------|-------------| +| **FTS** | PostgreSQL Full-Text Search (`tsquery`) | +| **Vector** | Semantische Suche via pgvector (`embedding`) | +| **RAG** | Retrieval-Augmented Generation (Vector + LLM) | +| **Graph** | Beziehungs-Suche (zukünftig) | + +### 12.4 Auto-Indexierung + +Wenn eine Entität erstellt/aktualisiert wird, wird ein Outbox-Event gepublished. Der Worker generiert das Embedding und aktualisiert den Such-Index automatisch. Plugins müssen nur `get_embedding_text()` korrekt implementieren. + +--- + +## 13. File Storage + +Plugins speichern Files über das zentrale Storage-Backend (`app/core/storage.py`). **Keine eigenen Storage-Backends implementieren.** + +### 13.1 save_with_metadata() + +```python +from app.core.storage import save_with_metadata, get_storage_backend + +# Speichert mit MIME-Prüfung, Size-Limit und Hash-Berechnung +metadata = await save_with_metadata( + path=f"my_plugin/{tenant_id}/{file_name}", + data=file_bytes, + allowed_mimes=["application/pdf", "image/png", "image/jpeg"], # None = Default-Allowlist + max_size_mb=10, # None = aus Config +) +# Returns: {path, mime_type, size, hash, storage_path} +``` + +### 13.2 MIME-Prüfung und Path-Traversal-Schutz + +```python +from app.core.storage import validate_mime, validate_size, compute_hash + +# MIME wird content-based erkannt (python-magic) mit Extension-Fallback +mime = validate_mime(path, data, allowed_mimes=["application/pdf"]) +# ValueError bei nicht erlaubtem MIME-Typ + +# Path-Traversal wird im LocalStorage automatisch blockiert: +# _full_path() normt den Pfad und prüft, ob er innerhalb base_path bleibt +``` + +### 13.3 Storage-Backend lesen + +```python +backend = get_storage_backend() +data = await backend.read(path) +url = await backend.get_url(path, expires=3600) +exists = await backend.exists(path) +await backend.delete(path) +``` + +Für File-Embedding siehe Kapitel 7.2 (LLM Integration — Embedding). + +--- + +## 14. Redis + +Plugins nutzen den globalen Redis-Singleton. **Keine eigenen Connections, kein `aioredis.from_url()`.** + +### 14.1 Redis-Client + +```python +from app.core.auth import get_redis + +redis = get_redis() # Globaler Singleton +await redis.setex(f"my_plugin:lock:{resource_id}", 30, "locked") +value = await redis.get(f"my_plugin:lock:{resource_id}") +await redis.delete(f"my_plugin:lock:{resource_id}") +``` + +### 14.2 Cache-Wrapper + +```python +from app.core.cache import cache_get, cache_set, cache_delete, cache_flush_pattern + +# Set mit TTL (Default: 300s) +await cache_set(f"my_plugin:summary:{tenant_id}", {"count": 42}, ttl=60) + +# Get +data = await cache_get(f"my_plugin:summary:{tenant_id}") + +# Delete +await cache_delete(f"my_plugin:summary:{tenant_id}") + +# Pattern-Flush (alle Keys mit Prefix) +await cache_flush_pattern("my_plugin:*") +``` + +**Verboten:** `aioredis.from_url()` in Plugin-Code — immer `get_redis()` oder `get_cache()` verwenden. + +--- + +## 15. Permissions + +Plugins deklarieren Permissions im Manifest und nutzen das bestehende RBAC/ABAC-System. + +### 15.1 Permissions deklarieren + +```python +manifest = PluginManifest( + name="my_plugin", + permissions=[ + "my_plugin:read", + "my_plugin:write", + "my_plugin:admin", + ], + ... +) +``` + +### 15.2 Permissions in Routes erzwingen + +```python +from app.deps import require_permission + +@router.get("/api/v1/my-plugin/items", + dependencies=[Depends(require_permission("my_plugin:read"))]) +async def list_items(...): + ... + +@router.post("/api/v1/my-plugin/items", + dependencies=[Depends(require_permission("my_plugin:write"))]) +async def create_item(...): + ... +``` + +### 15.3 Wildcard-Support + +Das Permission-System unterstützt Wildcards: `my_plugin:*`, `*:read`, `*:*` (Superadmin). Bare `*` ist verboten. + +### 15.4 Field-Level Permissions + +```python +from app.plugins.manifest import FieldDefinition + +field_definitions=[ + FieldDefinition(module="my_plugin", field="secret_code", + label="Secret Code", sensitivity="sensitive"), +], +``` + +**Wichtig:** Tools, Skills und MCP dürfen keine Rechte verleihen. Siehe `docs/permissions.md` und `docs/permissions_plugin_dev.md`. + +--- + +## 16. AI Tools + +Plugins können Tools in der globalen `ToolRegistry` registrieren, die von AI-Agenten aufgerufen werden können. + +### 16.1 Tool registrieren + +```python +from app.plugins.builtins.ai_assistant.contracts import get_tool_registry + +async def on_activate(self, db, service_container, event_bus): + await super().on_activate(db, service_container, event_bus) + registry = get_tool_registry() + registry.register( + name="my_plugin_lookup_contact", + description="Look up a contact by name in My Plugin", + parameters={ + "type": "object", + "properties": { + "name": {"type": "string", "description": "Contact name"}, + }, + "required": ["name"], + }, + handler=self._lookup_contact_handler, + plugin_name="my_plugin", + required_permission="my_plugin:read", + category="contacts", + ) + +async def _lookup_contact_handler(self, arguments: dict, context: dict) -> str: + name = arguments.get("name", "") + # Business logic — return JSON string + return f'{{"found": true, "name": "{name}"}}' +``` + +### 16.2 Tool bei Deaktivierung entfernen + +```python +async def on_deactivate(self, db, service_container, event_bus): + get_tool_registry().unregister_plugin("my_plugin") + await super().on_deactivate(db, service_container, event_bus) +``` + +### 16.3 Permission-Prüfung + +Jedes Tool hat ein `required_permission`-Feld. Der AI-Service prüft dies vor der Ausführung: + +```python +# Wird automatisch im Service geprüft: +if tool.required_permission: + if not check_permission(user_context, tool.required_permission): + return f"Error: Permission '{tool.required_permission}' required" +``` + +### 16.4 Tool-Schema (OpenAI-kompatibel) + +Das `AITool`-Dataclass konvertiert automatisch ins OpenAI Function-Calling-Format via `to_openai_schema()`. + +--- + +## 17. MCP + +MCP (Model Context Protocol) dient als **dünne Exposure-Schicht** auf bestehende Tools/Services. MCP erhält **keine eigenen Rechte**. + +### 17.1 Architektur + +``` +External MCP Server → MCP Client Plugin → ToolRegistry → Existing Tools/Services +``` + +MCP-Tools werden mit dem Naming-Schema `mcp__{server}__{tool}` in der ToolRegistry registriert. + +### 17.2 Tool-Registrierung + +```python +# MCP Client Plugin registriert externe Tools automatisch: +registry.register( + name=f"mcp__{server_name}__{tool_name}", + description=f"[MCP:{server_name}] {tool.description}", + parameters=tool.parameters, + handler=_make_handler(server_cfg, tool.name), + plugin_name="mcp_client", + required_permission="mcp-client:read", # Bestehende Permission + category="mcp-external", +) +``` + +### 17.3 Auth- und Run-as-Kontext + +Der vorhandene Auth-/Run-as-Kontext und normale Permission-Prüfungen bleiben maßgeblich. MCP-Tools erben die Permissions des aufrufenden Benutzers — MCP kann keine Rechte verleihen, die der Benutzer nicht hat. + +**Verboten:** Eigene Auth-Bypass-Logik in MCP-Handlern. Immer den Standard-Permission-Check verwenden. + +--- + +## 18. UI-Events + +Plugins können auf UI-Events reagieren und eigene UI-Events publishen. UI-Events sind **ephemeral** — über EventBus, nicht über Outbox. + +### 18.1 UI-Events abonnieren + +```python +from app.core.event_bus import get_event_bus + +async def on_activate(self, db, service_container, event_bus): + await super().on_activate(db, service_container, event_bus) + event_bus.subscribe("ui.contact_selected", self._on_contact_selected) + event_bus.subscribe("ui.page_navigated", self._on_page_navigated) + event_bus.subscribe("ui.mail_opened", self._on_mail_opened) + +async def _on_contact_selected(self, payload: dict) -> None: + contact_id = payload.get("contact_id") + # React to contact selection — e.g. preload data +``` + +### 18.2 UI-Events publishen + +```python +event_bus = get_event_bus() +await event_bus.publish("ui.my_plugin_widget_ready", { + "widget_id": "summary", + "tenant_id": str(tenant_id), + "user_id": str(user_id), +}) +``` + +### 18.3 Bekannte UI-Events + +| Event | Payload | Beschreibung | +|-------|---------|-------------| +| `ui.contact_selected` | `{contact_id, user_id}` | Benutzer hat Kontakt ausgewählt | +| `ui.page_navigated` | `{path, user_id}` | Benutzer hat Seite navigiert | +| `ui.mail_opened` | `{mail_id, user_id}` | Benutzer hat E-Mail geöffnet | + +**Wichtig:** UI-Events gehen bei Crash verloren. Für reliable Delivery Outbox verwenden (Kapitel 8). + +--- + +## 19. AI UI Control + +Das `ai_ui_control` Plugin ermöglicht AI-Agenten, die Frontend-UI zu steuern: Navigation, Filter, Kontakte öffnen, Modals, Tabs und Settings. + +### 19.1 Command-Typen + +```python +from app.plugins.builtins.ai_ui_control.schemas import UICommandType + +# Unterstützte Actions: +# navigate → {action: 'navigate', path: '/contacts/123'} +# filter → {action: 'filter', entity: 'contacts', filter: {type: 'company'}} +# open_contact → {action: 'open_contact', contact_id: '...'} +# modal → {action: 'modal', modal: 'edit', contact_id: '...'} +# tab → {action: 'tab', tab: 'emails', contact_id: '...'} +# settings → {action: 'settings', section: 'ai', key: 'model', value: 'gpt-4'} +``` + +### 19.2 Command senden (REST) + +```python +# AI-Agent sendet Command via REST: +POST /api/v1/ai-ui-control/command +{ + "action": "navigate", + "path": "/contacts/abc-123", + "description": "Opening contact detail page" +} +# Response: {command_id, status: "pending", action: "navigate"} +``` + +### 19.3 Command empfangen (WebSocket) + +Das Frontend verbindet sich via WebSocket `/ws/ai-ui-control` und empfängt Commands in Echtzeit. Nach Ausführung sendet das Frontend Feedback zurück: + +```python +UICommandFeedback( + command_id="...", + status=UICommandStatus.success, + action=UICommandType.navigate, + current_path="/contacts/abc-123", +) +``` + +### 19.4 Permissions + +AI UI Control benötigt `ai_ui_control:write` für Commands und `ai_ui_control:read` für Status-Abfragen. + +**Wichtig:** Persistente Mutationen (Daten ändern, erstellen, löschen) dürfen **nicht** über AI UI Control laufen. Diese müssen über reguläre Tools/Services mit Permission-Prüfungen gehen. AI UI Control ist nur für UI-Navigation und Anzeige. + +--- + +## 20. Sensitive Data + +Plugins müssen sensible Daten explizit deklarieren und sicherstellen, dass diese nicht in Snapshots, Such-Index, Embeddings, Exporten oder Logs landen. + +### 20.1 SENSITIVE_FIELDS deklarieren + +Im Manifest über `field_definitions` mit `sensitivity="sensitive"` oder `sensitivity="critical"`: + +```python +from app.plugins.manifest import FieldDefinition + +field_definitions=[ + FieldDefinition(module="my_plugin", field="api_key", + label="API Key", sensitivity="critical"), + FieldDefinition(module="my_plugin", field="internal_notes", + label="Internal Notes", sensitivity="sensitive"), +], +``` + +### 20.2 Was NICHT in Snapshots/Index/Embeddings/Export/Logs darf + +- **Passwords, Tokens, API Keys, Session-IDs** — niemals loggen, indexieren oder embedden +- **Personenbezogene Daten** (DSGVO-relevant) — nicht in Such-Embeddings ohne explizite Freigabe +- **Interne Notizen** mit `sensitivity="sensitive"` — nicht in Exporten ohne Berechtigung + +### 20.3 Error-Logging Sanitization + +Das Error-Logging-Endpoint (`/api/v1/errors`) sanitized automatisch sensible Keys: + +```python +# app/routes/errors.py — _SENSITIVE_PATTERNS +# Erkennt: token, password, secret, authorization, cookie, session, +# api_key, access_token, refresh_token, csrf, bearer, private_key +# Diese Felder werden durch "[redacted]" ersetzt. +``` + +### 20.4 AI/Data Exposure Policy + +- AI-Tools dürfen keine sensiblen Felder an LLM-Provider senden, ohne dass der Benutzer die entsprechende Permission hat +- Embeddings dürfen nur aus nicht-sensiblen Texten generiert werden +- Export-Service respektiert Field-Level Permissions (`hidden`, `readonly`, `read`) + +--- + +## 21. Migration-Staffelung + +Schema-Änderungen in Plugins müssen gestaffelt durchgeführt werden, um Downtime und Datenverlust zu vermeiden. + +### 21.1 Sechs-Schritt-Prozess + +```text +1. Neue Struktur erstellen (neue Tabelle/Spalte/Index) + → Migration 0002_add_new_column.sql + +2. Daten migrieren (Backfill) + → Migration 0003_backfill_data.sql (oder Worker-Job) + +3. Reads/Writes umstellen + → Code schreibt in neue UND alte Struktur (Dual-Write) + → Code liest aus neuer Struktur (mit Fallback auf alte) + +4. Tests + → Unit-Tests mit neuer Struktur + → Integration-Tests mit Dual-Write + → Migration-Tests (upgrade + downgrade) + +5. Stabiler Release + → Deploy mit neuer Struktur + Dual-Write + → Verify: alle Daten korrekt migriert + +6. Alte Struktur entfernen + → Migration 0004_drop_old_column.sql + → Code: Dual-Write entfernen, nur neue Struktur +``` + +### 21.2 Plugin-Migration-Beispiel + +```sql +-- migrations/0002_add_status_v2.sql +ALTER TABLE my_plugin_items ADD COLUMN status_v2 VARCHAR(20) DEFAULT 'active'; + +-- migrations/0003_backfill_status.sql +UPDATE my_plugin_items SET status_v2 = CASE + WHEN status = 'pending' THEN 'pending' + WHEN status = 'done' THEN 'completed' + ELSE 'active' +END; +``` + +```python +# Schritt 3: Dual-Write in Service +item.status = old_status # alte Spalte +item.status_v2 = map_to_new_status(old_status) # neue Spalte +``` + +**Wichtig:** Jeder Schritt ist ein separater Release. Nie Struktur ändern und Daten migrieren in einer Migration. + +--- + +## 22. Error-Handling + +Plugins werfen strukturierte Errors über `ApiError` mit `code`, `detail`, `field` und `status`. Tracebacks werden niemals an den User gesendet. + +### 22.1 ApiError werfen + +```python +from app.core.error_codes import ApiError + +@router.post("/api/v1/my-plugin/items") +async def create_item(body: ItemCreate, db: AsyncSession = Depends(get_db)): + if not body.name: + raise ApiError(code="validation_error", detail="Name is required", field="name") + + existing = await check_duplicate(db, body.name) + if existing: + raise ApiError(code="not_found", detail="Item already exists", status=409) + + # Service unavailable + raise ApiError(code="service_unavailable", detail="External API timeout") +``` + +### 22.2 Standardisierte Error-Codes + +| Code | HTTP Status | Beschreibung | +|------|------------|-------------| +| `not_found` | 404 | Resource nicht gefunden | +| `permission_denied` | 403 | Keine Berechtigung | +| `validation_error` | 422 | Validierung fehlgeschlagen | +| `rate_limited` | 429 | Zu viele Requests | +| `internal_error` | 500 | Interner Fehler | +| `service_unavailable` | 503 | Service temporär nicht verfügbar | + +### 22.3 Error-Propagation-Kette + +```text +Plugin → ApiError(code, detail) + → Core Exception Handler → JSON Response {error: {code, detail, field}} + → Frontend API Client → TanStack Query onError + → ErrorBoundary / Toast Notification → User +``` + +### 22.4 trace_id-Korrelation + +Jeder Error bekommt eine `trace_id` für End-to-End-Tracing: + +```python +# WebSocket-Errors via ws_helpers: +from app.core.ws_helpers import send_ws_error +await send_ws_error(websocket, code="validation_error", + detail="Invalid input", trace_id=trace_id) +``` + +### 22.5 Frontend ErrorBoundary + +Plugin-Seiten und MiniApps **müssen** eine React ErrorBoundary haben. Bei unhandled Errors wird eine freundliche Fehlermeldung angezeigt — kein Stacktrace. + +### 22.6 Partial-Failure bei Batch-Operationen + +Bei Batch-Operationen (z.B. Bulk-Import) wird **nicht** die gesamte Operation abgebrochen. Erfolgreiche Items werden committed, fehlgeschlagene Items werden mit Fehlergrund gesammelt zurückgegeben: + +```python +results = { + "success": [item_id_1, item_id_2], + "failed": [{"item": item_3, "error": "validation_error: Name required"}], +} +``` + +**Verboten:** Unbehandelte Tracebacks an den User senden. Alle Plugin-Errors müssen als `ApiError` geworfen werden. + +--- + *This document is authoritative for all plugin development at LeoCRM.*