- In-app reminders: task due (skips rescheduled tasks, catch-up wording) and a per-user morning digest step in the daily job - Frontend: "Als Nächstes" on the colony page, "Heute" on the dashboard, inspection-due card, season chip, apiary overview fields, cleanup sheet for ended colonies - Frontend: plausibility warnings that stay until dismissed, withdrawal spans in the calendar, notification bell with categories and links, notification settings in the profile - ARCHITECTURE.md: T4a sections 11.1-11.10, assumptions 31-41, handler table and daily job step order Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
134 KiB
ApiCore – Architektur
Basiert auf docs/lastenheft.md. Diese Datei beschreibt, WIE die fachlichen Anforderungen technisch umgesetzt werden.
1. Backend – Modulschnitt (NestJS)
Ein NestJS-Modul pro fachlichem Bereich, Reihenfolge entspricht Baureihenfolge in TASKS.md:
| Modul | Zweck | Abhängig von |
|---|---|---|
AuthModule |
Login, Refresh, Passwort-Reset, JWT-Strategien | – |
UsersModule |
Benutzerverwaltung, Rollen | Auth |
AuditModule |
Audit-Log-Grundstruktur (Service AuditLogService, wird von anderen Modulen injiziert) |
Users |
ApiariesModule |
Bienenstände | Auth |
ColoniesModule |
Völker, Standortwechsel-Historie, Ableger/Vereinigung | Apiaries |
QueensModule |
Königinnen, Volk-Historie | Colonies |
InspectionsModule |
Durchsichten | Colonies |
TasksModule |
Aufgaben, polymorph verknüpft | – |
VarroaModule |
Varroamessungen | Colonies |
TreatmentsModule |
Behandlungen | Colonies |
FeedingsModule |
Fütterungen | Colonies |
HarvestsModule |
Ernten | Apiaries, Colonies |
ExtractionsModule |
Schleuderungen | Harvests |
HoneyBatchesModule |
Honigchargen, Honigmessungen | Extractions |
StorageModule |
Lagerbehälter, einfache Lagerverwaltung (StockItem/StockMovement) | HoneyBatches |
DashboardModule |
Aggregations-Endpunkte | alle o.g. |
SearchModule |
globale Suche, Filter-Bausteine | alle o.g. |
ExportModule |
CSV/JSON/PDF-Export | alle o.g. |
ErpExportModule |
Nur lesender Inventar-Spiegel für ERPNext (/api/v1/export/inventar/..., Recht erp-inventory:read), Vertrag in docs/erp-inventar-export.md |
Products, Bottling, ProductionBatches, Stock, Harvests |
SuggestionsModule |
Vorschläge (Modell, API, Aufräumen) und Ketten-Handler, Abschnitt 11 | Tasks, Notifications |
AutopilotModule |
Saison-Autopilot (A1–A3), „Die nächsten Schritte“, „Heute“ | Season, Suggestions |
SeasonModule |
Saisonabschnitt, Fälligkeit von Durchsicht und Varroa-Messung, Alter der Königin | – |
WithdrawalModule |
Wartezeit-Prüfung bei Ernte, Abfüllung und Behandlung, Meldungen | Notifications |
RemindersModule |
Erinnerung je Aufgabe, Morgenübersicht | Notifications, Season, Autopilot |
Jedes Modul: *.controller.ts, *.service.ts, dto/, *.module.ts, *.spec.ts (Unit), test/*.e2e-spec.ts (Integration). Rollenprüfung über @Roles()-Decorator + RolesGuard, angewendet pro Endpunkt – niemals nur UI-seitig.
Alle Routen unter /api/v1/..., versioniert über NestJS URI Versioning. OpenAPI-Doku automatisch über @nestjs/swagger, erreichbar unter /api/v1/docs.
2. Frontend – Seitenstruktur (Next.js App Router)
app/
├── (auth)/login/
├── (app)/ geschütztes Layout mit Bottom-Nav (mobil) / Sidebar (Desktop)
│ ├── dashboard/
│ ├── apiaries/[id]/
│ ├── colonies/[id]/
│ ├── queens/[id]/
│ ├── inspections/new/
│ ├── tasks/
│ ├── suggestions/ Vorschläge (Übernehmen, Verwerfen, Aufräumen)
│ ├── varroa/new/
│ ├── treatments/new/
│ ├── feedings/new/
│ ├── harvests/new/
│ ├── honey-batches/[id]/
│ ├── storage/
│ ├── search/
│ ├── offline/ Offline-Seiten (Start, apiary?id=, colony?id=)
│ ├── nav-config.ts einzige Quelle aller Navigationspunkte
│ └── settings/
│ ├── users-roles/ nur Administrator
│ └── system/ „Verwaltung > System“ (Jobs, Ereignisse, Laufrollen)
Zentrale Schnellaktion "+ Neu" (Abschnitt 22): permanent sichtbarer Floating-Button in der mobilen Bottom-Nav, öffnet ein Auswahl-Sheet mit Icons und 56-px-Zeilen (auch am Desktop; quick-add-sheet.tsx, Einträge aus quick-actions.ts) (Durchsicht, Fütterung, Behandlung, Varroamessung, Aufgabe, Ernte, Volk, Königin) → direkt zum jeweiligen Schnellerfassungs-Formular, mobil möglichst 1-2 Taps bis zum ersten Eingabefeld.
Navigation aus einer Quelle: app/(app)/nav-config.ts ist die einzige Quelle für Seitenleiste, Bottom-Nav, „Mehr“-Sheet und „+ Neu“; der Berechtigungsfilter gilt überall gleich. Neue Seiten (auch „Verwaltung > System“) werden nur dort eingetragen. Auf allen new-Seiten führt ein Zurück-Link zur Liste, bei einem Entwurf mit Rückfrage. „Mehr“ und „+ Neu“ teilen sich die Sheet-Hülle components/nav/nav-sheet.tsx.
Offline-Seiten: Ohne Netz zeigen /offline (Start: geladene Stände), /offline/apiary?id=… und /offline/colony?id=… die Daten aus den Offline-Paketen (IndexedDB). QR- und NFC-Deep-Links (/apiaries/<id>, /colonies/<id>) weicht der Service Worker ohne Netz auf diese Seiten aus; der Weg zurück („Mit Netz öffnen“) übernimmt from nur bei gleicher Herkunft (same-origin). Formulare lesen Völker und Königinnen aus dem Paket, wenn die API nicht antwortet (withPackFallback, networkMode: "always").
Stand-Seite und Standrunde: apiaries/[id] zeigt die lebenden Völker mit Ampel und „Runde starten“; nach dem Speichern führt „Weiter mit …“ zum nächsten Volk. Der Rundenzustand liegt je Benutzer lokal und wird beim Abmelden gelöscht.
Als Nächstes und Heute: Die Volksseite zeigt oben „Als Nächstes“ (höchstens 3 Schritte, GET /colonies/:id/next-steps), die Startseite „Heute“ den dringlichsten Schritt und die fälligen Durchsichten je Stand (GET /autopilot/today; bei Fehler, offline oder ohne Recht gilt die bisherige Aufgabenlogik). Die Stand-Seite nennt je Volk „Durchsicht fällig in n Tagen“ und „Als Nächstes: …“. Offline zeigt die Volksseite die Schritte aus dem Paket nur lesend. Einzelheiten: Abschnitt 11.5.
Datenzugriff ausschließlich über TanStack Query gegen die Backend-API (kein direkter DB-Zugriff, kein Next.js-eigenes Datenbank-Layer) – erzwingt API-first auch praktisch.
3. Auth-Flow
- Login (
POST /api/v1/auth/login) mit E-Mail/Passwort → Access-Token (15 min, JWT HS256,iss=apicore,aud=apicore:access) + Refresh-Token (in DB als Hash, rotierend). Bei aktiver MFA zuerst ein Zwischen-Token (aud=apicore:mfa-challenge), das nie als Access-Token gilt. - Frontend hält Access-Token im Memory/React-Context, Refresh-Token als httpOnly-Cookie (
Secure,SameSite=Lax, Pfad/api/v1/auth). - Bei 401 automatischer Refresh über
POST /api/v1/auth/refresh, danach Retry. Rotation atomar; Wiederverwendung eines alten Tokens (nach 10 s Gnadenfrist für parallele Tabs) beendet alle Sitzungen. SpätestensSESSION_MAX_AGE(7 Tage) nach dem Login ist neu anzumelden. - Guards in dieser Reihenfolge:
ThrottlerGuard→JwtAuthGuard(Authentifizierung, Gültigkeit frisch aus der DB je Anfrage) →PermissionsGuard(Berechtigung, fail-closed). - Maschinen-Zugriff (Wabenhain) nur per API-Schlüssel mit Scope-Allowlist und optionaler IP-Allowlist, siehe Annahme 14 und Abschnitt 9.
4. Datenfluss / Rückverfolgbarkeit
Kernkette (Abschnitt 16, 29) relational über Fremdschlüssel, nicht über Textfelder:
Apiary → Colony → (Inspection | Treatment | Feeding | VarroaMeasurement)
Apiary/Colony → Harvest → Extraction → HoneyBatch → StorageContainer
HoneyBatch referenziert Extraction, Extraction referenziert n Harvests (m:n über ExtractionHarvest), Harvest referenziert n Colonys (m:n über HarvestColony) + eine Apiary. Damit ist "von welchem Volk stammt Charge X" eine reine Join-Abfrage, kein Freitext.
Historie statt Löschen: alle fachlichen Haupttabellen haben status/deactivatedAt statt Hard-Delete. Verschiebungen (Volk↔Stand) und Königinnenwechsel werden als eigene Historientabellen geführt (ColonyApiaryHistory, QueenColonyHistory), nicht durch Überschreiben des aktuellen Zustands.
5. Technische Leitentscheidungen (bestätigt, keine Abweichung)
- Validierung: class-validator/class-transformer (nicht Zod) – konsistent mit NestJS-Idiomatik und
@nestjs/swagger-Decorators, ein Schema-Ort statt zwei. - Ein gemeinsames Git-Repo im
ApiCore/-Root für Backend + Frontend (Monorepo-artig, aber zwei unabhängigepackage.json, kein Turborepo/Nx-Overhead im MVP). - Package-Manager: npm (in beiden Projekten, keine Vermischung mit pnpm/yarn).
- Backend-Tests: Jest (NestJS-Standard). Frontend-Tests: Vitest + React Testing Library.
6. Annahmen & offene Fragen
Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige Vorgabe macht. Bei Bedarf mit dem Fachbereich klären, aktuell aber Grundlage der Umsetzung:
-
HoneyMeasurement als MVP-Entität: Abschnitt 26 MUSS-Liste nennt Honigmessungen nicht explizit, Abschnitt 4.17 aber als eigenen Unterpunkt und Abschnitt 30 verlangt Wassergehalt schnell abrufbar. Da sie klein und für die Chargen-Kette relevant ist, wird sie als Teil des
HoneyBatchesModuleim MVP mitgebaut statt auf v1.x verschoben. -
Treatment auf Einzelvolk statt "Völkergruppe": Abschnitt 4.11 erlaubt "Volk oder Völkergruppe". MVP modelliert
Treatmentpro Volk (colonyIdPflichtfeld); eine Gruppenbehandlung wird im MVP als mehrere Treatment-Datensätze mit gleicherbatchNumber/Notiz erfasst statt eigener Gruppen-Entität. Spätere Migration kann eineTreatmentGroup-Entität nachziehen, falls das in der Praxis zu unhandlich ist. -
Lagerverwaltung MVP-Umfang: "grundlegende Lagerverwaltung" (Abschnitt 26 Punkt 12) wird als generisches
StockItem+StockMovement(Kategorie Honig/Verbrauchsmaterial/Imkereimaterial) umgesetzt. Das detaillierte Inventar mit Wartungsdaten (Abschnitt 4.22) bleibt v1.x/"SOLL", wie im Lastenheft vorgesehen. -
API-Client-Rolle im MVP: kein vollwertiger OAuth2/Client-Credentials-Server. Ein API-Client ist ein
Usermit RolleAPI_CLIENTund einem langlebigen Service-Refresh-Token; vollwertiges Scopes-/Client-Management ist SOLL/KANN. -
Backup-fähige Struktur: reproduzierbare Prisma-Migrationen (kein manuelles Schema-Drift), anwendungsseitiges Backup (
BackupModule, Abschnitt 8) und der CronJobapicore-db-backupmitpg_dump(Abschnitt 7),/health-Endpoint für Betriebsüberwachung. -
Deviations vom Leitentscheidungs-Rahmen: bisher keine. Wird ergänzt, falls während der Umsetzung ein konkreter Grund für eine Abweichung von NestJS/Prisma/Next.js/Tailwind/shadcn/TanStack Query auftritt.
-
Dokumentenspeicher als lokales Volume (Modul 29):
DocumentsModulespeichert Uploads unterhalb vonDOCUMENTS_STORAGE_PATHauf dem lokalen Dateisystem des Backend-Pods, keine Objektspeicher-Anbindung (S3-kompatibel o.ä.) im MVP/v1.x. Der Pfad ist über die ConfigMap austauschbar, wie in Abschnitt 27 des Lastenhefts vorgesehen. Wichtig für den Betrieb: das aktuelledeploy/k8s-Deployment läuft mitreplicas: 2ohne geteiltes Volume – für konsistente Uploads über beide Pods hinweg muss vor Produktivbetrieb mit Dokumenten-Feature einReadWriteMany-PVC (z.B. NFS- oder EFS-basiert) gemountet undDOCUMENTS_STORAGE_PATHdarauf gesetzt werden, sonst sieht nur der Pod, der die Datei entgegengenommen hat, sie auch beim Download wieder. -
Entra ID SSO ohne Auto-Provisioning:
POST/GET /api/v1/auth/sso/*implementiert Authorization Code + PKCE gegen Entra ID überopenid-client. Der E-Mail-Claim aus dem ID-Token wird ausschließlich gegen einen bereits existierenden, aktivenUser-Datensatz gematcht – es wird kein neuer Benutzer automatisch angelegt (Just-in-Time-Provisioning), passend zum bestehenden Modell, dass Admins Benutzer manuell anlegen. Eine erfolgreiche SSO-Anmeldung überspringt die App-eigene TOTP-MFA-Abfrage, da der Identity Provider bereits eine eigene (typischerweise stärkere) Authentifizierung durchgesetzt hat. Der PKCE-code_verifierwird kurzlebig (5 min) in einem httpOnly-SameSite=Lax-Cookie zwischengespeichert statt serverseitig, damit der Flow ohne Sticky-Sessions über mehrere Backend-Replicas hinweg funktioniert; der Rückweg vom Callback zum Frontend nutzt ein kurzlebiges (60 s), zweckgebundenes JWT statt des echten Access-Tokens in der Redirect-URL.OIDC_ISSUER_URL/OIDC_CLIENT_ID/OIDC_CLIENT_SECRET/OIDC_REDIRECT_URIkommen ausschließlich aus Environment-Variablen; fehlen sie, meldetGET /api/v1/auth/sso/statusenabled: falseund die Login-Seite blendet den "Mit Microsoft anmelden"-Button aus, statt beim Start zu scheitern. -
M365-Kalender-Synchronisation als einseitiger, asynchroner Push: Abschnitt 4.25 verlangt nur einen internen Betriebskalender, keine externe Anbindung – diese Funktion ist eine reine Erweiterung ohne Lastenheft-Vorgabe. Umgesetzt als
CalendarSyncModule(ApiCore-Backend/src/calendar-sync/): App-only Graph-API-Zugriff per Client-Credentials-Flow (GraphClientService) statt delegierter Anmeldung, da die Synchronisation unbeaufsichtigt im Hintergrund laufen muss und nicht an eine interaktive Benutzersitzung gebunden sein darf. Nutzt bewusst dieselbe Entra-App-Registrierung wie das SSO-Login (OIDC_ISSUER_URL/OIDC_CLIENT_ID/OIDC_CLIENT_SECRET, ausschließlich Env-Variablen) statt einer zweiten, separaten App-Registrierung – ein Enterprise-App-Objekt im Tenant genügt, sofern es zusätzlich zu den delegierten Login-Scopes auch die Application-Permission „Calendars.ReadWrite“ (Admin-Consent) erhält; die Tenant-ID wird dafür aus dem Pfad vonOIDC_ISSUER_URLabgeleitet, statt eine weitere Konfigurationsvariable zu benötigen. Die Übertragung ist bewusst nur einseitig (ApiCore → M365-Gruppenkalender, adressiert übergroupId); Änderungen, die direkt im M365-Kalender vorgenommen werden, fließen nicht zurück. Statt eines synchronen Push im Request-Pfad nutztCalendarSyncOutboxEntryein Outbox-Muster, das die Worker-Schleifecalendar-outboxabarbeitet (alle 60 s und aufpg_notify('calendar_sync', ''), siehe Abschnitt 10): Graph-API-Aufrufe sind zu langsam/fehleranfällig (Token-Refresh, Drosselung, transiente 5xx), um eine Durchsicht-/Aufgaben-Erstellung darauf warten zu lassen; fehlgeschlagene Versuche werden bis zu 5-mal wiederholt, danach bleibt der EintragFAILEDbis zu einem manuellen "Jetzt synchronisieren" oder einer erneuten Änderung des Quelldatensatzes. Die Outbox ist sicher bei mehreren Replicas: Jeder Eintrag wird vor der Arbeit per bedingtem Update (claimedAt) reserviert, ein Claim älter als 5 min gilt als verwaist und wird übernommen, Graph-Requests haben ein 30-s-Timeout.revisionsorgt dafür, dass eine Änderung während des Syncs nicht verloren geht (Eintrag bleibt PENDING, dasselbe Event wird beim nächsten Lauf aktualisiert); ein Abbruch während des Syncs löscht das gerade geschriebene Event. Abmelden eines Eintrags aus dem Kalender geschieht durch erneutes Einreihen: Wird eine Quelle (z. B. eine Aufgabe, die erledigt oder storniert wird oder ihr Fälligkeitsdatum verliert, oder ein gelöschter Datensatz) erneut in die Kalender-Outbox eingereiht, findet der Worker inbuildEventInputnichts mehr anzuzeigen. Hat der Eintrag einegraphEventId, löscht der Worker das Graph-Event (404/410 gelten als bereits gelöscht, jeder andere Fehler zählt als fehlgeschlagener Versuch) und setzt den Eintrag aufSKIPPEDmitgraphEventId = null. Die API ruft Graph dafür nie auf; die Einreihung selbst erfolgt in der Handler-Transaktion der Domain-Events (calendar-sync.*-Handler überCalendarSyncService.enqueueIn(tx, …)). Auch die Königin-Zuordnung (QueenAssigned→calendar-sync.queen-assignment) und die Wartezeit (calendar-sync.withdrawal, Abschnitt 11.8) laufen so;CalendarSyncService.enqueueund das Einreihen nach dem Commit gibt es nicht mehr, nurenqueueIn. Ein neues Graph-Event entsteht beim Aktualisieren nur, wenn das alte nicht mehr existiert (404/410); andere Fehler gelten als fehlgeschlagener Versuch. Jedes Anlegen schickt einetransactionId(aus Eintrag, Revision und ersetztem Event), damit Graph eine Wiederholung nach einem Timeout nicht doppelt anlegt; „Jetzt synchronisieren“ startet den Lauf im Hintergrund (202,runningim Status) und setzt FAILED-Einträge auf PENDING zurück.groupIdmuss eine Entra-Objekt-ID (GUID) sein;""odernulllöscht sie; sie wird in Graph-URLs kodiert. Fehler aus Prisma werden nur mit Name, Code und Modell geloggt (src/common/loggable-error.ts), nie mit Abfragewerten. Offene Entscheidung (2026-10-12): Laut Microsoft-Doku (group-post-events) unterstütztPOST /groups/{id}/eventskeine Application-Permission, nur delegiertesGroup.ReadWrite.All. Mit dem App-only-Token oben kann das Anlegen im Gruppenkalender deshalb in Produktion scheitern (nicht gegen einen echten Tenant geprüft). Auswege: Zielkalender auf ein Ressourcen- oder Benutzerpostfach umstellen (/users/{upn}/calendar/events, App-only erlaubt, per Application Access Policy begrenzt) oder delegierter Zugriff über ein Dienstkonto. Pro Quelltyp (Aufgabe/Durchsicht/Behandlung/Fütterung/Ernte/Königinnen-Zuordnung/Wartezeit) ist die Synchronisation einzeln über die GUI (Verwaltung > Automatisierung > Kalender-Sync) abschaltbar; Kalendereinträge werden als ganztägige Graph-Events (isAllDay: true) angelegt, um Zeitzonen-/Uhrzeit-Mehrdeutigkeiten zu vermeiden, da die zugrundeliegenden Fachdaten selbst keine Uhrzeit führen. -
Wartezeit manuell statt aus Wirkstoff-Datenbank abgeleitet:
Treatment.withdrawalPeriodDayswird pro Behandlung von Hand eingetragen (laut Packungsbeilage des verwendeten Mittels), statt automatisch aus Produktname/Wirkstoff hergeleitet zu werden. Eine hartkodierte Zuordnung Produkt→Wartezeit wäre eine faktische, rechtlich relevante Behauptung über Tierarzneimittel-Zulassungen, die im Code weder aktuell gehalten noch verlässlich verifiziert werden kann; die Verantwortung für den korrekten Wert bleibt beim Imker. Die App warnt in der Ernte-Erfassung nur, wenn ein Wert hinterlegt wurde und dessen Fenster (endDate ?? date+withdrawalPeriodDays) noch nicht abgelaufen ist – bezogen auf das Erntedatum (GET /treatments/withdrawal-status?colonyIds=…&at=JJJJ-MM-TT, ohneatgilt jetzt; Behandlungen, die erst nach diesem Datum beginnen, zählen nicht) – sie blockiert die Ernte nicht hart, da Ernte-Datensätze auch nachträglich für bereits erfolgte, reale Ernten erfasst werden. Warnungen sperren nie; einzige Ausnahme ist der Wassergehalt (Bestätigung, T4b). Ein einziger Fenster-Helfer (src/treatments/withdrawal-window.ts) bestimmt die Wartezeit; Warnung bei Ernte und Abfüllung, Meldung, Kalender undgetWithdrawalStatusrechnen alle darüber, in Berliner Kalendertagen (Annahme 41). Die Kette beschreibt Abschnitt 11.8. -
Idempotenz per Opt-in: Anlege-Endpunkte, die Formulare und die Offline-Warteschlange wiederholen können (Durchsicht, Behandlung, Fütterung, Varroa, Sammelerfassung von Behandlung, Fütterung und Varroa, Schwarmkontrolle, Ernte, Aufgabe, Abfüllung, Lagerbewegung, Herstellung, Rohstoffe, Überwinterung, Dokument-Upload), tragen
@Idempotent(). Der Client sendet je Vorgang einenIdempotency-Key; eine Wiederholung liefert die gespeicherte erste Antwort (IdempotencyRecord, 90 Tage). Bewusst kein globaler Interceptor, da gespeicherte Antworten sonst Geheimnisse (MFA-Setup, Tokens) enthalten könnten. Die Tabelle ist flüchtig und nicht Teil des Backups. Idempotenz schützt nur, wenn nach dem Commit nichts mehr fehlschlägt. Darum ist das Ereignis-Rückgrat (Abschnitt 10) der Weg für Seiteneffekte (Webhooks, Kalender, Automationsregeln, Benachrichtigungen): Der Schreibpfad nimmt das Domänenereignis in derselben Transaktion auf, Handler im Worker setzen die Wirkung um. Aufgaben, Durchsichten, Varroamessungen, Behandlungen, Fütterungen, Ernten, Völker, Königin-Zuordnung sowie Bestand und Abfüllung haben keine Post-Commit-Seiteneffekte mehr (Abschnitt 10, Absätze „Aufgaben“, „Durchsichten und Varroa“, „Behandlungen, Fütterungen und Ernten“, „Völker“, „Bestand und Abfüllung“, „Sammelerfassung am Stand“). Wiederkehrende Arbeit läuft als Kubernetes-CronJob überdist/cli.js, nie als Timer im Prozess:@nestjs/scheduleundScheduleModulegibt es nicht mehr, der Wächtersrc/runtime/no-in-process-schedulers.spec.tshält das fest.runAfterCommit(src/common/after-commit.ts, best-effort: loggt auf Error-Level, wirft nie) bleibt nur für die AutomatikSWARM_MOOD_HIGHundSTOCK_ITEM_LOW(AutomationService.evaluateSwarmControlCheckundevaluateStockItem) und den Webhookcolony.created. Nachzügler für eine spätere Stufe, die weiter überWebhooksService.dispatchlaufen: Königinnen (queen.created;queen.replacedkommt seit T4 ausQueenAssigned), Honigchargen (batch.created) und Lager (product.stock_changed). Der Wächtersrc/common/after-commit-usage.spec.tshält die Aufrufer fest. Die früherenAutomationService.evaluate*-Wrapper für Durchsicht, Varroa, Behandlung, Fütterung, Volksgesundheit und Rohstoff sind entfernt. Was das Schreiben verhindern muss (z. B.generateNumber, Validierung), wirft weiter. Doppelte Automatik-Aufgaben verhindert eine Zeilensperre auf der Regel (SELECT … FOR UPDATE). Den Entwurf des Rückgrats beschreibt die Specdocs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md. -
Automatische Hinweise sind Vorschläge, keine Aufgaben: Automatisierungsregeln legen keine Aufgaben mehr an, sondern schreiben Vorschläge (
Suggestion, Abschnitt 11.1). Erst „Übernehmen“ erzeugt die Aufgabe (Quelle „Aus Vorschlag“:Task.sourceSuggestionId,sourceAutomationTrigger= Regel); bestehende Automatik-Aufgaben bleiben Aufgaben. Je Regel und Objekt existiert höchstens eine offene Sache (Vorschlag oder Aufgabe). Geprüft wird unter der Volkssperre und der Zeilensperre der Regel (SELECT … FOR UPDATE, Reihenfolge Volk, dann Regel), die bis zum Commit gilt. Keine voreingestellte Frist (fachlich nicht belastbar ableitbar); optionalconfig.dueInDaysje Regel, gerechnet ab dem Berliner Tag. Ohne sie ist die Aufgabe beim Übernehmen heute fällig. Anker je Regel (ein verworfener Anker kommt nie wieder):QUEENLESS_NO_EGGSundFEED_STATUS_CRITICALdie Durchsicht-ID;VARROA_THRESHOLDdie Mess-ID (nur rot, nicht im Behandlungseffekt, Abschnitt 11.4);SWARM_MOOD_HIGHdie Kontroll-ID;COLONY_HEALTH_CRITICALdie Ereignis-ID;STOCK_ITEM_LOWdie jüngste IN-Bewegung (sonstnone);RAW_MATERIAL_LOWdie jüngste Charge der Sorte (sonstnone);TREATMENT_FOLLOW_UPundFEEDING_FOLLOW_UPdie Behandlungs- bzw. Fütterungs-ID. Die Tagesrate des natürlichen Milbenfalls wird bei jeder Korrektur einer Eingangsgröße (Methode, Anzahl, Dauer) neu berechnet; ein mitgesendeter, vom gespeicherten abweichender Wert gilt als manuell gesetzt und gewinnt. Vorschlag, Meldung und der Audit-Eintrag der Regel (AutomationRule CREATE, mitsuggestionId) entstehen in einer Transaktion: Scheitert ein Teil, entsteht nichts davon. Eine Meldung (KategorieSUGGESTION, WARNUNG, beiURGENTKRITISCH) schreibt ein Vorschlag nur, wenn die Regelconfig.notifyhat. Empfänger sind Inhaber vontasks:readund des Leserechts des Bereichs; der Text lautet „: “ (bewusste Abweichung vom Spec-Wortlaut, bei dem das Präfix der Regelcode war; ohne Volk nur der Titel). Der Kalendereintrag (calendar-sync.task) und der Webhooktask.created(webhooks.forward) folgen erst ausTaskCreatedbeim Übernehmen; die Regel ruftWebhooksServicenicht selbst auf. Eine neuere Durchsicht führt die offenen Vorschläge vonQUEENLESS_NO_EGGSundFEED_STATUS_CRITICALnach: Zeigt die tatsächlich jüngste Durchsicht des Volkes den Befund nicht mehr, wird der VorschlagRESOLVED(„Bedingung entfällt“); besteht der Befund, bleibt es bei einem Vorschlag mit aktuellem Beleg (der Anker bleibt der alte); eine rückdatierte Durchsicht ändert nichts, eine Korrektur schlägt nie neu vor, führt aber Offenes nach. Dasselbe gilt fürVARROA_THRESHOLDbeiVarroaCorrected. Eine abgeschaltete Regel lässt offene Vorschläge unberührt. Ausgewertet wird in den Handlernautomation.*(src/automation/automation.handlers.ts) in der Zustell-Transaktion, bei Fehler mit Wiederholung; sie rufenevaluate…In(tx, …), überspringen beendete Völker und prüfen unter der Volkssperre erneut. NurSWARM_MOOD_HIGHundSTOCK_ITEM_LOWlaufen dauerhaft überrunAfterCommitin eigener Transaktion (Annahme 11); sie rufen dieselbenevaluate…In, werfen aber nie. -
API-Schlüssel für Maschinen-Zugriffe: Integrationen (Wabenhain-Shop, ERPNext) nutzen einen Schlüssel
ack_…imAuthorization: Bearer-Header statt eines Service-Kontos mit Passwort. Gespeichert wird nur der SHA-256-Hash (32 Zufallsbytes, daher kein langsamer KDF nötig); die Suche läuft über den Hash, Geheimnisse werden nie verglichen; der Klartext erscheint einmal bei der Erstellung. Ein Schlüssel ist kein Benutzer:JwtAuthGuardsetztrequest.apiKeystattrequest.user,PermissionsGuardlässt ihn nur auf Routen, deren Berechtigung in seinen Scopes steht –@AnyAuthenticatedUser-Routen nie. Erlaubte Scopes sind eine kurze Allowlist (api-keys/api-key-scopes.ts,shop-sync:read,shop-sync:write,erp-inventory:read); je angebundenem System ein eigener Schlüssel. Verwaltung mitapi-keys:manage. Schlüssel sind bewusst nicht Teil des Backups: nach einer Wiederherstellung wird ein neuer Schlüssel ausgestellt. Lesen und Buchen sind getrennte API-Schlüssel (shop-sync:readbzw.shop-sync:write;erp-inventory:readzählt als Lesen, Buchungsrecht ist nurshop-sync:write, ListeAPI_KEY_WRITE_SCOPES): Ein neuer Schlüssel trägt genau ein Recht: einer mit mehreren Rechten wird beim Anlegen mit 400 abgelehnt (mit Lese- und Buchungsrecht eigens begründet); bereits ausgestellte Schlüssel mit mehreren Rechten bleiben gültig, die Oberfläche markiert sie und empfiehlt getrennte Schlüssel (neue anlegen, im angebundenen System – Wabenhain bzw. ERPNext – eintragen, alten widerrufen). Ein geleakter Lese-Schlüssel kann so nicht buchen, ein Buchungs-Schlüssel nicht lesen. Die Oberfläche bietet nur Rechte an, die der Anlegende selbst hat (alles andere lehnt der Server ohnehin ab). Ungültige Schlüssel werden je Client-Netz gezählt und nach 20 Fehlversuchen in 15 min gesperrt: Annahme 30. -
Lebende vs. beendete Völker: Nur
DISSOLVED,SOLD,DECEASED,MERGEDsperren Umsetzen/Teilen/Vereinigen/Königinzuweisung (colonies/colony-status.ts).WEAK,CONSPICUOUS,QUEENLESSbleiben bearbeitbar. -
Bestandsbuch aus Behandlungen, nicht als eigenes Register: Das Bestandsbuch nach Art. 108 VO (EU) 2019/6 wird aus
Treatmenterzeugt (export/treatment-log.ts); Lieferant, Erwerbsbeleg und verschreibender Tierarzt sind Freitextfelder der Behandlung, Name/Anschrift des Halters liegen inSiteSettings. Der Stand am Behandlungstag wird beim Anlegen alsTreatment.apiaryIdfestgehalten (Umsetzen ändert ihn nicht nachträglich); für Altbestand wird er ausColonyApiaryHistoryabgeleitet und im PDF bei Unsicherheit markiert. Fehlende Pflichtangaben werden als „FEHLT“ gedruckt statt still weggelassen. Keine Wartezeit-Automatik (siehe 10). -
Überwinterung als eigene Einträge statt Volksstatus:
WinterCheck(eindeutig je Volk,winterYear, Saison) hält Ein- und Auswinterung; ab Juli gehört ein Datum zum Winter desselben Jahres. Verlustursachen sind bewusst nur die COLOSS-Kategorien, keine Diagnosen. Die Übersicht gruppiert nach dem aktuellen Stand des Volkes. Ein Verlust ändert den Volksstatus nur auf ausdrücklichen Wunsch (markColonyDeceased). Neue Rechte vergibt die Migration an Nicht-System-Rollen, weil der Katalog-Sync nur System-Rollen berechtigt. Ein Verlust mitmarkColonyDeceasedbeendet auch die Königin-Zuordnung und schreibtColonyEndedmitvia: WINTER_CHECK(Abschnitt 11.7). -
Schutz gegen Rechteausweitung in der Benutzerverwaltung: Wer Benutzer pflegt, darf nur Konten verwalten (Passwort/MFA zurücksetzen, E-Mail ändern, (de)aktivieren) und Rollen vergeben, deren Rechte er selbst vollständig hat (
UsersService.assertWithinCallerPermissions), analog zur Rollenbearbeitung. -
SSO-Bindung an die IdP-Identität: Die erste Microsoft-Anmeldung bindet das Konto an
"<iss>|<oid>"(User.ssoSubject); danach zählt nur noch diese Identität, nicht der unverifizierte E-Mail-Claim. Mandantenübergreifende Issuer (common/organizations/consumers) schalten SSO ab. E-Mails werden ohne Groß-/Kleinschreibung verglichen. -
Backups versiegelt: AES-256-GCM über das gzip-Bundle, Schlüssel aus dem Schlüsselbund
BACKUP_ENCRYPTION_KEY(+BACKUP_ENCRYPTION_KEY_PREVIOUS, in Produktion Pflicht, siehe Annahme 22); Dateien von vor dem Schlüsselbund (Schlüssel per HKDF ausJWT_ACCESS_SECRET) bleiben lesbar, solangeJWT_ACCESS_SECRETunverändert ist. Nur versiegelte Dateien stellen vollständig wieder her (und überschreiben). Eine unversiegelte (alte v2-)Datei darf nur die 27 Stamm- und Feldtabellen der Erlaubt-ListeLEGACY_UNSEALED_TABLESergänzen – fehlende Zeilen anlegen, bestehende nie ändern. Alles andere (Benutzer, Rollen, Webhooks, Audit-Log, Kalender-Sync, Backup- und Website-Einstellungen, Nummernkreise, Bestands- und Chargenbewegungen, Shop-Anfragen, Rohstoffe/Herstellung, Dokumente, Benachrichtigungen samt Einstellungen, Vorschläge und jede künftige Tabelle) kommt nur aus versiegelten Dateien; die Oberfläche listet übersprungene Tabellen auf; Import verlangt Passwort (+ TOTP). MFA- und Webhook-Secrets liegen mit eigenen Schlüsseln (MFA_ENCRYPTION_KEY,WEBHOOK_ENCRYPTION_KEY) verschlüsselt in der DB und damit auch im Backup – für eine Wiederherstellung in einem anderen Deployment dort dieselben Schlüssel setzen (oder als*_PREVIOUS), sonst müssen Webhook-Secrets und MFA neu eingerichtet werden. -
Bearbeiten-Regel für
null(Grundsatz „alles bearbeitbar“): In jeder Bearbeiten-Anfrage bleibt ein weggelassenes Feld unverändert,nullleert ein Feld, das leer sein darf, undnullauf einem Pflichtfeld wird mit 400 abgelehnt (statt still ignoriert oder als 500). Umgesetzt überPartialType(..., { skipNullProperties: false })in den Update-DTOs,@IsOptionalNotNull()(src/common/validation/is-optional-not-null.ts) für weglassbare, aber nicht leerbare Felder undoptionalDate()(src/common/patch-values.ts) für leerbare Datumsfelder;update-dto-null-handling.spec.tshält den Vertrag je DTO fest. Das Frontend sendet geleerte optionale Felder überemptyToNull(ApiCore-Frontend/src/lib/forms/empty-to-null.ts) alsnull. Für Regel-Configs gilt dasselbe Muster:nulllöscht einen optionalen Schlüssel (z. B.dueInDays,windowDays,minGrams, einzelne GTS-Schwellen), ein Pflichtschlüssel (z. B.queenMaxAgeYears,notify, die GruppeswarmSeason) lehntnullmit 400 ab (Abschnitt 11.2). Neu leerbar sindColony.occupiedFrames,VarroaMeasurement.sampleBeesundInspection.feedStatus. -
Lebende Völker: „Lebend“ heißt jeder Status außer
DISSOLVED,SOLD,DECEASED,MERGED– also auchWEAK,CONSPICUOUS,QUEENLESS. Backend-Abfragen filtern überLIVING_COLONY_WHERE(colonies/colony-status.ts), das Frontend überisLivingColony(lib/colonies/status.ts);status = ACTIVEsteht nur dort, wo wirklich genau dieser Status gemeint ist. -
Schlüsselbund: Jeder Zweck hat einen eigenen Schlüssel, nie ein JWT-Secret:
MFA_ENCRYPTION_KEY(TOTP-Secrets),WEBHOOK_ENCRYPTION_KEY(Webhook-Signaturschlüssel),BACKUP_ENCRYPTION_KEY(Backup-Dateien). In Produktion Pflicht: je ≥ 32 Zeichen, paarweise verschieden und verschieden vonJWT_ACCESS_SECRET/JWT_REFRESH_SECRET– sonst startet kein Prozess (src/runtime/startup-checks.ts). Außerhalb der Produktion fällt ein fehlender Schlüssel je Zweck auf eine Ableitung ausJWT_ACCESS_SECRETzurück. Formatk<8 hex>:<iv>.<tag>.<data>(Base64-Teile, AES-256-GCM, 128-Bit-Tag Pflicht); AES-Schlüssel = HKDF-SHA256(Secret, Saltapicore-key-ring, Infoapicore-<zweck>-v1), Schlüssel-ID =k+ die ersten 8 Hex-Zeichen von sha256(Schlüssel). Backups behalten Dateiformat v3 und ihre bisherige Ableitung (keyId12 Hex), folgen aber denselben Ring-Regeln. Code:src/common/crypto/key-ring.ts. In Produktion lehnt der Start außerdem Platzhalterwerte ab (change-me…, ohne Groß-/Kleinschreibung) – für beide JWT-Secrets, die drei Schlüssel und ihre*_PREVIOUS, ein gesetztesOIDC_CLIENT_SECRETund das Passwort inDATABASE_URL; auch*_PREVIOUSmuss von den JWT-Secrets verschieden sein. Außerhalb der Produktion meldet jeder Prozess einmal je Variable, dass ein ausJWT_ACCESS_SECRETabgeleiteter Entwicklungsschlüssel einspringt. -
Öffentliche Herkunftsseite ohne Standort: Sie nennt nur
Apiary.publicLocationLabel(je Stand, z. B. „Nordhessen“) undSiteSettings.originCountry. Name, Ort, Adresse und Koordinaten eines Stands werden nie öffentlich ausgeliefert (Diebstahlschutz). Ohne Label erscheint nur das Land. Eine Label-Änderung kann am Edge bis zu 1 h verzögert sichtbar werden. Rotation über*_PREVIOUS: alten Wert nach<NAME>_PREVIOUS(öffnet weiter, versiegelt nie), neuen<NAME>setzen, api und worker neu starten (sonst versiegeln sie weiter mit dem alten Schlüssel), Jobrekeylaufen lassen; danach mussrekey --dry-runbeirekeyed,plaintextSealed,changedMeanwhileundfailed0 melden, erst dann*_PREVIOUSentfernen. Backup-Dateien werden nicht umgeschlüsselt:BACKUP_ENCRYPTION_KEY_PREVIOUSbleibt gesetzt, solange ältere Dateien einspielbar sein sollen. Altchiffrate sind nur lesbar (nie geschrieben), und nur, solangeJWT_ACCESS_SECRETunverändert ist: MFA-Secrets ohne Präfix und Webhook-Secrets mitenc:v1:(beide AES-Schlüssel sha256(JWT_ACCESS_SECRET)), Webhook-Secrets im Klartext, Backup-Dateien mit JWT-abgeleiteterkeyId. Liest ein Prozess ein solches MFA- oder Webhook-Secret, warnt er einmal je Zweck „legacy ciphertext read – run the rekey job“ (ohne Werte). Reihenfolge darum: Schlüssel setzen →rekey→ neues Backup ziehen → erst dann die JWT-Secrets rotieren (alle Sitzungen enden einmal; JWT-versiegelte Backup-Dateien sind danach nicht mehr einspielbar). Jobrekey(src/jobs/rekey.job.ts, nur von Hand:kubectl create job --from=cronjob/apicore-job-rekeybzw.…-rekey-dry-run): versiegelt jedes MFA-Secret (User.mfaSecret) und jedes Webhook-Secret (WebhookSubscription.secret), das nicht mit dem aktuellen Schlüssel seines Zwecks versiegelt ist, neu – Werte unter*_PREVIOUS, Altchiffrate und Webhook-Secrets im Klartext. Zeile für Zeile, ohne Transaktion um den Lauf: Jede Schreiboperation ist ein Compare-and-Swap auf den gelesenen Wert (UPDATE … WHERE id = … AND "mfaSecret" = <alter Wert>, bei Webhookssecret), damit überschreibt der Job nie, was die App inzwischen geschrieben hat (z. B. eine neue MFA-Einrichtung). Solche Zeilen zählt er alschangedMeanwhile;changedMeanwhile > 0ist ein harter Stopp, kein Erfolg: die Zeile kann noch mit dem alten Schlüssel versiegelt sein, darum Dry-Run (und bei Bedarf Lauf) wiederholen, bis alle Zähler 0 sind, erst dann rotieren. Vor dem Schreiben prüft der Job, dass der neue Wert zum selben Klartext öffnet. Ein abgebrochener Lauf behält, was er umgestellt hat; der nächste überspringt diese Zeilen ungeöffnet (alreadyCurrent). Ergebnis:dryRunund je Zweckmfa.*/webhook.*mittotal,rekeyed,alreadyCurrent,plaintextSealed,changedMeanwhile,failed. Eine Zeile, die sich nicht öffnen oder schreiben lässt, behält ihren Wert; der Lauf macht weiter und endet beifailed > 0immerFAILED(JobRun, Zähler im Fehlertext, das Log nennt nur Zeilen-ID und Grund, z. B.unknown key id).rekey --dry-runschreibt nichts und öffnet jeden umzustellenden Wert, anders als der echte Lauf auch die Zeilen, die schon den aktuellen Schlüssel tragen (ein beschädigter Wert zählt alsfailed). Andere Argumente lehnendist/cli.js(Exit 2) und der Job selbst ab. Werte, Chiffrate und Schlüssel-IDs erscheinen nie in Log oderJobRun.updatedAtumgestellter Zeilen ändert sich. Den Klartext-Rückfall inopenWebhookSecretkann man entfernen, sobald der Dry-Run in Produktionwebhook.plaintextSealed: 0zeigt. -
Offline-Standmodus: „Stand laden“ legt ein Paket je Stand in IndexedDB ab (
GET /apiaries/:id/offline-pack: lebende Völker, Königinnen, die letzten 3 Durchsichten je Volk, offene Aufgaben, je VolkoccupiedFramesund die nächsten SchrittenextSteps, optional, Version 1). Es enthält weder Koordinaten noch Adresse des Standes; Königinnen, Durchsichten und Aufgaben fehlen ohne das jeweilige Leserecht (Benutzer-ID der Durchsichten wird weggelassen). Ein Paket gilt nach 24 h als veraltet (Alter immer sichtbar), wird nach 1 h automatisch erneuert und nach 14 Tagen oder nach 403/404 gelöscht. Lehnt der Server einen Eintrag ab (bei der Sammelerfassung mitinvalidColonyIds), erzwingt das ein Neuladen der betroffenen Pakete (frühestens nach 5 min). Der Service Worker (public/sw.js,sw-routes.js) speichert Seiten nur zusammen mit ihren Assets und Daten nur alsapplication/json; nie gespeichert werden Auth-Antworten, Anfrage-Header (kein Bearer-Token im Cache), die Antworten, die ein Paket liefert (eine Offline-Datenquelle mit sichtbarem Alter),/api/v1/apiariesund/apiaries/:id(Standort),/export/und/backup/. Navigation hat routenabhängige Zeitlimits: App-Hülle 4 s, Offline-Seiten zuerst aus dem Cache, Deep Links etwa 10 s, übrige Seiten Netzfehler bzw. erst ab 15 s. Alle API-Anfragen haben eine Gesamtfrist inklusive Antwortkörper (Standard 4 s; Schreibzugriffe ohne Idempotenz-Schlüssel, darunter Login, MFA und Refresh, 15 s; Sammelerfassung 15 s; Upload 30 s; Download 60 s). -
Offline-Sitzung: Ohne Netz bleibt der Benutzer mit der zwischengespeicherten Identität (Name, Rechte) 7 Tage angemeldet, nie mit einem Token; der Sync bleibt gesperrt, bis ein Refresh gelingt. Ein Refresh gilt nur für dieselbe Benutzer-ID; das Token eines anderen Kontos löscht das Gerät (Pakete, Warteschlange, Caches). Nur 401/403 beenden die Sitzung, 429/502/503 und Netzfehler zählen als Netzfehler. Ein Abmelden ohne Netz merkt den Widerruf vor und holt ihn beim nächsten Start vor allem anderen nach. Jeder Warteschlangen-Eintrag hat einen Besitzer (
ownerUserId); fremde Einträge werden nie gesendet. Beim Abmelden und Benutzerwechsel werden Pakete, Rundenzustand, Query-Cache, Foto-Übergabemarker und die Cache-Storage-Einträge des Service Workers gelöscht (WIPE_CACHESmit Generationszähler, damit eine laufende Anfrage danach nichts mehr schreibt). Beim Sync beendet 401 den Lauf, ohne Einträge zu markieren; 408/429/502/503/504 lassen Einträge wartend; 409 „wird bereits verarbeitet“ heißt „später erneut“. -
Fotos: Aufnahmen werden im Browser per Canvas neu kodiert (längste Kante 2560 px, JPEG, ohne EXIF/XMP/GPS), auch im bestehenden Dokument-Upload. Vor dem Upload prüft der Client Metadaten und höchstens 10 MB; der Upload ist idempotent. Ein Foto wartet in IndexedDB auf die Server-ID seiner Durchsicht und wird danach angehängt. Ein Übergabemarker in
localStorage(mitcreatedAt, höchstens 3 Tage alt, je Benutzer) verhindert, dass ein bereits gespeicherter Entwurf samt Fotos ein zweites Mal gesendet wird. Offen: die Prüfung mitexiftoolauf einem echten Telefon (auch älteres iOS Safari). -
Sammelerfassung am Stand:
POST /treatments|feedings|varroa/bulkschreibt alles oder nichts in einer Transaktion (@Idempotent()). Die Völker werden sortiert mitlockRowsForShare(FOR SHARE,src/prisma/row-lock.ts) gesperrt, der Ablauf steckt im HelferrunBulk; das Ereignis je Volk trägtbulkId. Entscheidung K1: Die Chargennummer des Arzneimittels (batchNumber) wird einmal eingegeben und nie erzeugt (rechtlich relevantes Bestandsbuch); gruppiert wird überbulkId. Entscheidung K2: Varroa nimmt je Volk einen eigenen Eintrag, ein gemeinsamer Milbenwert wäre erfundene Messung. Unbekannte oder beendete Völker meldet die 400-Antwort alsinvalidColonyIds. Der Idempotenz-Schlüssel bindet Benutzer, Schlüssel und Pfad, nicht den Inhalt: Der Client nimmt darum je abweichender Absendung einen neuen Schlüssel. -
Skalen und Vorbelegung: Alle Bewertungsskalen der Durchsicht zeigen „5 = gut“ (Gespeichertes bleibt unverändert). Die Schwarmstimmung ist gespeichert umgekehrt (1 = ruhig, 5 = stark) und wird nur in der Anzeige gedreht (
RatingScale direction="lower-is-better"; die Beschriftungen nennen die gespeicherten Enden); keine Datenmigration. Waben stehen per Plus/Minus incolonyStrengthund neubroodFrames, das AltfeldbroodAmountnur im Bearbeiten-Dialog.colonyStrengthzählt besetzte Waben, höchstens 40 (Annahme 34). Werte der letzten Durchsicht erscheinen als sichtbarer Hinweis je Feld mit „Letztes Mal … übernehmen“ und „Alles wie letztes Mal“, nie stillschweigend vorbelegt. -
Ampeln: Die Varroa-Ampel kommt vom Server aus Bändern nach Methode, Monat und Volkstyp (Abschnitt 11.4);
VARROA_WARN_FRACTIONgibt es nicht mehr. Der Trend zeigt die Grenzen als Stufenlinien je Monat, Zeiträume 1/2/3/7 Tage oder „anders“. Die Ampel eines Volkes (lib/colonies/status-light.ts) nimmt den schlechtesten Befund: rot beiQUEENLESS, kritischem Gesundheits-, Futter- oder Brutzustand oder offener kritischer Meldung; gelb beiWEAK/CONSPICUOUS, schwachem Zustand, offener Warnung oder überfälliger Durchsicht. „Überfällig“ heißtinspectionDueOnüberschritten („Durchsicht seit 3 Tagen fällig“, Abschnitt 11.3); das ersetzt die festen 14 TageINSPECTION_STALE_DAYS. Ohne Fälligkeitstag (Winterruhe, alte Pakete) gibt es keinen Befund, und „Noch keine Durchsicht“ entfällt, weil die Fälligkeit auf die Anlage des Volkes zurückfällt.openWarningsundopenCriticalder Volksübersicht zählen offene Vorschläge der PrioritätHIGHbzw.URGENT, die der Nutzer sehen darf (tasks:readund das Recht des Vorschlags), nicht mehr Benachrichtigungen. Die Farbe steht nie allein (Symbol und Wort); die Kontraste der Farbtöne prüft ein Test. -
Fehlversuchs-Sperre für API-Schlüssel, über alle Pods: Ungültige Schlüssel (unbekannt, widerrufen, abgelaufen) zählen je Client-Netz – IPv4-Adresse bzw. IPv6-/64, wie der Throttler (
rateLimitSubject). Nach 20 ungültigen Schlüsseln in einem Fenster von 15 min (ab dem ersten Fehlversuch) bekommt das Netz für weitere ungültige Schlüssel 15 min lang429(src/api-keys/failed-attempt-limiter.ts, GrenzenAPI_KEY_FAILURE_LIMIT). Ein gültiger Schlüssel wird davon nie abgewiesen: Erst kommt die Suche über den Hash, nur ein ungültiger Schlüssel wird gezählt oder gesperrt. Sonst könnte jeder, der eine gemeinsame Adresse (Cluster-Ausgang, Proxy) nutzt oder vortäuscht, ERPNext oder den Shop aussperren; bei 256 Zufallsbits je Schlüssel kostet das nichts. Falsch aufgebaute Tokens werden ohne Datenbankzugriff mit 401 abgewiesen und zählen nicht. Die Zähler liegen wie die des Throttlers in der UNLOGGED-TabelleThrottlerHit(überPrismaThrottlerStorage: eigener Throttler-Nameapi-key-failures, Schlüssel-Präfixapi-key-failures:, eine Upsert-Anweisung, nur DB-Uhrnow()) und gelten damit für alle api-Pods gemeinsam; kein zweiter Zähler im Prozessspeicher. Bei einem DB-Fehler schlägt die Anfrage fehl (fail-closed, wie der Throttler). Abgewiesene Schlüssel werden nur mit Präfix und IP protokolliert, das Erreichen der Grenze einmal je Fenster. -
Vorschläge, Anker und Dubletten: Regel-, Ketten- und Autopilot-Vorschläge sind ein Modell mit den Arten
PLANundDO(Abschnitt 11.1).dedupeKey = <ruleKey>:<relatedId|none>:<anker>ist eindeutig. Offene, übernommene, verworfene und veraltete (OBSOLETE) Vorschläge behalten den Schlüssel, ein verworfener oder übernommener Anker kommt also nie wieder; erledigte (RESOLVED) und abgelaufene (EXPIRED) geben ihn im selben Statement frei, derselbe Anker darf danach neu vorgeschlagen werden. Je Absicht und Objekt gibt es höchstens eine offene Sache: ein offener Vorschlag oder eine offene Aufgabe (OPEN,IN_PROGRESS); die Absicht einer Aufgabe istsourceSuggestion.intent, sonst die der Altregel, sonstFOLLOW_UP. Bei Vorschlägen ohne Volk und Objekt gilt die Prüfung je Regel, damit sich unabhängige nicht blockieren. Verworfene lassen sich 30 Tage nachdecidedAtwieder öffnen. Systemübergänge (Auffrischen, Auflösen, Veralten, Ablaufen) schreiben keinen Audit-Eintrag, weil Status unddecidedAtsichtbar sind; der Audit-Eintrag gilt Entscheidungen von Nutzern. Eine Lücke bleibt bewusst: Beim Wiederöffnen gibt es keine Volkssperre, ein gleichzeitiger Vorschlag derselben Absicht kann ausnahmsweise doppelt offen sein. -
Vorschläge verbergen ihr Dasein: Liste, Zähler und nächste Schritte zeigen nur Vorschläge, deren
requiredPermissionleer ist oder vom Nutzer gehalten wird. Übernehmen, Verwerfen und Wiederöffnen eines unsichtbaren Vorschlags antworten 404 statt 403, damit weder Titel noch Dasein durchsickern. -
Futter-Vokabular:
Inspection.feedStatusistColonyConditionwie auf der Volkskarte (EXCELLENTReichlich,GOODAusreichend,AVERAGEMittel,WEAKNiedrig,CRITICALKritisch). Die Migration bildet den alten Freitext (getrimmt auf Leerzeichen, Tab, CR, LF; ohne Groß-/Kleinschreibung) ab: „ausreichend“ →GOOD, „niedrig“ →WEAK, „kritisch“ →CRITICAL; leer →NULL; alles andere →NULLundFutter (alt): <Text>wird nach einem Zeilenumbruch annotesgehängt (der Lauf meldet die Zahl der verschobenen Texte perRAISE NOTICE). Dieselbe Abbildung gilt für den CSV-Völkerimport (unbekannter Text landet in den Notizen, die Zeile trägt die Meldung „Futterangabe … nicht erkannt, in die Notizen übernommen.“) und für alte Backup-Bundles (ein Wert, der kein Text ist, wirdNULLmit NotizFutter (alt): <JSON>). Die API akzeptiert beiPOST/PATCH /inspectionsdie Enum-Namen in jeder Schreibweise und die drei alten Wörter (so bleiben Einträge der Offline-Warteschlange alter Clients sendbar); leerer Text wirdNULL, anderer Freitext 400. Die RegelFEED_STATUS_CRITICALfeuert beifeedStatus = CRITICAL; der Webhookinspection.createdträgtfeedStatusals Enum-Wert (der Shop nutzt das Ereignis nicht). -
Volksstärke in besetzten Waben:
Colony.occupiedFramesundInspection.colonyStrengthzählen besetzte Waben, 0 bis 40 (CHECK in der Datenbank; 400 „Höchstens 40 besetzte Waben.“). Das alteColony.strength(1–10) bleibt unverändert, wird in Swagger als veraltet markiert und nie umgerechnet; die Oberfläche zeigt es nur, wenn es gespeichert ist, als „Volksstärke (alt)“. Vor dem Ausrollen listet der Kopf der Migration20261014100700_t4_occupied_framesdie Abfrage nachInspection.colonyStrength > 40. -
Übernahme Durchsicht → Volkskarte: Siehe Abschnitt 11.10. Annahme: Das Brutbild wird zum Brutzustand der Volkskarte (5
EXCELLENT, 4GOOD, 3AVERAGE, 2WEAK, 1CRITICAL); diese Zuordnung ist nicht aus dem Lastenheft abgeleitet. -
Saison aus Pflanzenentwicklung: Die Saison (Abschnitte
WINTERRUHE,FRUEHJAHR,SCHWARMZEIT,SOMMER,SPAETSOMMER,HERBST) leitet sich aus der Pflanzenentwicklung ab (Abschnitt 11.3). T4a kennt nur den Kalender-Rückfall (PHENOLOGY.calendarFallback, StandardSALWEIDE20.03.,KIRSCHE15.04.,APFEL28.04.,HOLUNDER30.05.,LINDE20.06.,TRACHTENDE01.08.,HERBST15.09.,WINTER15.11.). Die Tage sind eine Annahme für Schwaben und per Regel anpassbar. VorSALWEIDEgilt nochWINTERdes Vorjahres. Gemeldete Phasen und Wetter (Gradtagszahl) folgen später überSeasonService.statesFor; die Aufrufer bleiben gleich. -
Alter der Königin: Eine Königin gilt als gealtert, wenn aktuelles Jahr minus Schlupfjahr ≥
queenMaxAgeYears(RegelSEASON_AUTOPILOT, Standard 2). Die frühere getrennte Schwelle (3 im Dashboard, 2 im Ereignis) ist aufgehoben;isQueenAged(src/season/queen-age.ts) ist die einzige Stelle, ein Wächter-Test (queen-age-threshold.spec.ts) hält das fest. Das Dashboard-Fenster „alte Königinnen“ beginnt daher bei 2 Jahren und folgt der Regel. Der Wert 2 ist eine Annahme (Imkerpraxis). -
Autopilot: Annahmen zu Ablauf und Alter: Ein Vorschlag läuft
max(Fälligkeit, heute) + suggestionTtlDays(Standard 30 Tage) ab, nicht schlicht Fälligkeit + 30: Sonst würde eine schon über 30 Tage überfällige Bedingung beim Anlegen abgelaufen, ihr Schlüssel freigegeben und sie täglich neu vorgeschlagen, ohne je sichtbar zu sein. Das Auffrischen behältexpiresAt; besteht die Bedingung weiter, läuft der Vorschlag nach etwa 30 Tagen ab und kommt mit frischer Frist wieder. A3 beobachtet nur Behandlungen, die vor höchstens 60 Tagen endeten (RECHECK_MAX_AGE_DAYS); ältere Historie ohne spätere Messung erzeugt keinen Vorschlag. Beim Abgleich werden entfallene Vorschläge vor neuen Kandidaten aufgelöst (gleicher Endzustand, aber einen Abgleich früher, weilproposeInKandidaten bei offenem Vorschlag derselben Absicht verwirft). -
Benachrichtigungen je Nutzer: Jede Meldung gehört genau einem Empfänger (
Notification.userIdPflicht). Rundmeldungen gibt es nicht mehr:NotificationsService.recordForPermissionundrecordForUsersschreiben beim Anlegen je einen Eintrag für jeden aktiven Nutzer mit aktiver Rolle, der alle nötigen Rechte hält (Fan-out mit Rechteprüfung beim Schreiben, bewusste Abweichung von der Rückgrat-Spec §4). Lesen und Gelesen-Markieren ist so pro Person, und wer das Recht nicht hat, sieht den Inhalt nie. Dasselbe Recht gilt beim Lesen:requiredPermissions(Vereinigung aus Empfänger- und Inhaltsrechten) muss der Nutzer zum Lesezeitpunkt vollständig halten, sonst ist die Zeile für ihn unsichtbar (GET /notifications,POST /notifications/:id/read→ 404,read-allmarkiert nur sichtbare).dedupeKey = "<Präfix>:<userId>"verhindert Doppelungen (createManyAndReturnmitskipDuplicates);linkist ein relativer App-Pfad (assertAppLink). Die Migration hat vorhandene Rundmeldungen für alle aktiven Nutzer kopiert (gleiche Sichtbarkeit wie zuvor; ohne aktiven Nutzer entfallen sie).Notification.userIdundNotificationPreference.userIdhängen mitON DELETE CASCADEam Benutzer (Benutzer werden produktiv nur deaktiviert,RESTRICTschützte nichts und blockierte Aufräumen). Wächter:no-broadcast-notifications.spec.ts. -
Erinnerungen in der App und Morgenübersicht: Siehe Abschnitt 11.9. Die Morgenzeit (05:00–12:00), die Ruhezeit und die Umstellung Sommer-/Winterzeit steuern in dieser Lieferung nichts in der App; die Übersicht entsteht immer um 04:30 mit dem Stand dieser Stunde. Sie wirken erst auf die Telegram-Zustellung (T4c).
-
Berliner Kalendertage: Tagesrechnung steckt in
src/common/dates.ts(berlinToday,berlinDayOf,dayToDate,addDays,daysBetween; unmögliche Tage wie 30.02. werfen). Fachliche Tage sind Berliner Kalendertage, gespeichert als Mitternacht UTC dieses Tages: Varroa-Ampel, Behandlungs- und Wartezeitfenster, Erntetag, Fälligkeiten der Vorschläge, „heute“ im Job und im Autopilot. Für Werte um Mitternacht UTC ändert das nichts; ein Behandlungszeitpunkt am Abend (UTC) kann so auf den nächsten Berliner Tag fallen.
7. Kubernetes
deploy/k8s/ ist Kustomize und die Quelle der Wahrheit für den Cluster (Spec docs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md §6):
base/: clusterneutral, ohne Namespace, Hosts und Digests.backend/(Deploymentapicore-backend, Service, PVCapicore-data),frontend/(Deployment, Service),postgres/(StatefulSet, headless Serviceapicore-postgres, CronJobapicore-db-backupmitpg_dump). Die Vorlagenconfigmap.example.yamlundsecret.example.yamlstehen nicht unterresources; Secrets bleiben außerhalb des Repos.- Selektoren und Service-Ports entsprechen dem Cluster (ein Test hält sie fest): Backend und Frontend
app: apicore-backend|apicore-frontend, Postgresapp.kubernetes.io/name: apicore-postgres(Pod zusätzlichencry.de/tier: database, Service-Portpostgres). Bis 2026-10-13 wich das Repo bei Postgres ab (app: apicore-postgres, ohne Härtung,volumeClaimTemplates). Seit 2026-10-13 istbase/postgresder Live-Stand: StatefulSet mit Härtung (uid/gid 70, nur lesbares Root-Dateisystem, begrenzteemptyDirs für Socket,/tmpund/dev/shm, kein Service-Account-Token,enableServiceLinks: false), Probes, Ressourcen und Affinität wie live, Service und das statisch gebundene PVCapicore-postgres-data(PVpv-apicore-postgres). Das PV und sein SMB-Secret gehören zur Cluster-Infrastruktur und liegen nicht im Repo. Ein Test verhindert, dass das Repo die Härtung abschwächt; Selektor und Volumes sind unveränderlich, darum Postgres-Änderungen immer zuerst perkubectl diffprüfen. components/, je Rollout-Schritt einschaltbar. Das Auskommentieren im Overlay ist nur lokal fürkubectl diff/applygedacht und wird nicht committet.network-policies: Default-Deny und die Freigaben aus der Tabelle unten. Setzt selbst das Labeldb-client: "true"an der api, damit Schritt 2 sie nicht von Postgres abschneidet.pod-hardening: Frontend mitreadOnlyRootFilesystem,emptyDirauf/tmpund/app/.next/cache, ohne Service-Account-Token, Ressourcen laut Spec §7.backend-roles:APICORE_ROLE=apisamt initContainernmigrateundseedim Deploymentapicore-backend, Deploymentapicore-worker, CronJobsapicore-job-<name>. Das neue Backend-Image (CMD nurnode dist/main.js) geht nur zusammen mit dieser Komponente live. OhneAPICORE_ROLEbricht es den Start in Produktion ab, und ohne die initContainer liefe keine Migration.api-hpa: HPAapicore-backend(autoscaling/v2, 1–2 Pods bei 70 % CPU); entferntreplicasaus dem Deployment.
overlays/encry/: Namespaceapicore, ConfigMaps, Ingresses (Traefik, Middlewareapicore-cloudflare-only@kubernetescrd, cert-managerletsencrypt-prodmit HTTP-01), Image-Digests. Hosts, ConfigMap-Werte und Digests sind bis zum Rollout Platzhalter (example.com,sha256:000…).tests/:manifest-policy.test.mjsprüft das gerenderte Overlay (NetworkPolicies nach Kubernetes-Semantik, Pod-Härtung, Ressourcen, CronJobs, Migration, Digests, Live-Selektoren) und jeden Rollout-Schritt (DNS, Ingress, api → Postgres und alle DB-Clients bleiben erreichbar).render-stages.mjsrendert die Schritte 1, 2, 3 und 5 aus dem Overlay. Die CI (.gitea/workflows/manifests.yml, nurpushundworkflow_dispatch, keinpull_request, weil der Runner im Cluster läuft) rendert mitkubectl kustomize, validiert mitkubeconform -strictund führt den Test aus; kubectl, kubeconform und yq mit festen Versionen und SHA-256.
Rollen-Deployments (ein Image, Rolle per APICORE_ROLE):
| Objekt | Rolle | Start | Ports | Pods | CPU Request/Limit | RAM Request/Limit |
|---|---|---|---|---|---|---|
Deployment apicore-backend |
api |
Image-CMD node dist/main.js |
3001 (Service, Ingress) | 1, mit HPA 1–2 | 50m/500m | 192Mi/512Mi |
Deployment apicore-worker |
worker |
node dist/worker.js |
3002 nur /health für die Probes; kein Service, kein Ingress |
1 | 20m/200m | 128Mi/256Mi |
CronJobs apicore-job-<name> |
job |
node dist/cli.js <name> |
keine | je Lauf ein Pod | 50m/500m | 192Mi/512Mi |
Deployment apicore-frontend |
– | node server.js |
3000 | 1 | 30m/300m | 128Mi/256Mi |
initContainer haben die Ressourcen der CronJob-Pods. Alle App-Pods laufen ohne Service-Account-Token, mit nur lesbarem Root-Dateisystem und emptyDir auf /tmp (HOME=/tmp), als uid 1000, mit seccompProfile: RuntimeDefault und ohne Capabilities. api und worker rollen mit maxSurge: 1, maxUnavailable: 0 aus.
CronJobs: timeZone: Europe/Berlin, concurrencyPolicy: Forbid, startingDeadlineSeconds: 600, je 3 erfolgreiche und fehlgeschlagene Jobs, ttlSecondsAfterFinished: 86400, restartPolicy: Never.
| CronJob | Zeitplan | backoffLimit | activeDeadlineSeconds |
|---|---|---|---|
apicore-job-cleanup |
0 4 * * * |
1 | 1800 |
apicore-job-daily-evaluation |
30 4 * * * |
1 | 1800 |
apicore-job-task-templates |
0 5 * * * |
1 | 1800 |
apicore-job-app-backup |
0 * * * *, prüft selbst, ob fällig; apicore-data auf /data |
0 | 3600 |
apicore-job-rekey, apicore-job-rekey-dry-run |
suspend: true, nur von Hand (kubectl create job --from=cronjob/…) |
0 | 1800 |
NetworkPolicies: Default-Deny für Ingress und Egress im Namespace. Kubelet-Probes erlaubt Kubernetes immer.
| Pod (Selektor) | Ingress | Egress |
|---|---|---|
| alle | – | DNS: UDP und TCP 53 zu k8s-app: kube-dns in kube-system |
frontend (app: apicore-frontend) |
Traefik (app.kubernetes.io/name: rke2-traefik in kube-system) auf 3000 |
app: apicore-backend auf 3001 |
api (app: apicore-backend) |
Traefik und frontend auf 3001 | Postgres 5432; TCP 443 zu öffentlichen Adressen |
worker (app: apicore-worker) |
keiner | Postgres 5432; TCP 443 zu öffentlichen Adressen |
Jobs (app in (apicore-job, apicore-db-backup)) |
keiner | Postgres 5432 |
postgres (app.kubernetes.io/name: apicore-postgres) |
nur Pods mit db-client: "true" auf 5432 |
keiner außer DNS |
cert-manager-Solver (acme.cert-manager.io/http01-solver: "true") |
Traefik auf 8089 (HTTP-01-Erneuerung) | keiner außer DNS |
„Öffentlich“ heißt 0.0.0.0/0 ohne 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 100.64.0.0/10, 169.254.0.0/16, 127.0.0.0/8 und ::/0 ohne fc00::/7, fe80::/10, ::1/128. Canal kennt keine FQDN-Regeln; den SSRF-Schutz für Webhook-Ziele behält die App (webhook-url-guard.ts). db-client: "true" tragen nur api, worker, die App-Jobs und apicore-db-backup.
Übergang: Im Namespace liegen außerdem drei Policies aus dem Repo rke2-cluster (Label encry.de/managed-by: cluster-hardening): default-deny-ingress, allow-from-traefik und allow-same-namespace. NetworkPolicies addieren sich; bis diese drei in Runbook Schritt 2 dort entfernt werden, gelten sie neben den neuen.
allow-same-namespaceöffnet die Ingress-Seite jedes Pods für alle Pods im Namespace. Postgres nimmt dann auf der Ingress-Seite Verbindungen von jedem Pod an, nicht nur vondb-client. Weiter sperrt nur die Egress-Seite: Jeder Pod im Namespace unterliegt dem neuen Default-Deny-Egress, das Frontend kommt also trotzdem nicht an Postgres. Die Absicherung hat dann eine statt zwei Schichten.allow-from-traefiklässt Traefik jeden Pod auf jedem Port erreichen, auch Postgres 5432 und den Worker auf 3002. Die Regel „Traefik nur auf 3000 und 3001“ gilt erst danach; ausnutzbar ist das nur über eine Ingress-Route.default-deny-ingressist neben dem neuendefault-denyüberflüssig, schwächt aber nichts.
Images: Das Base-Image node:22-alpine ist in beiden Dockerfiles per Digest gepinnt; ein Update ist ein bewusster Commit mit neuem Digest. Die CI taggt nur noch sha-<commit> und Versionen, kein latest. Das Overlay setzt die Digests der gebauten Images. Bis Schritt 3 bleibt beim Backend der Digest des laufenden Images.
Rollout: nur nach dem Runbook im Plan docs/superpowers/plans/2026-10-13-plattform-rueckgrat.md, jeder Schritt mit Freigabe des Inhabers: 0 Digests, 1 Overlay auf den Ist-Stand (Diff leer), 2 NetworkPolicies, 3 Rollen und Pod-Härtung, 4 Schlüssel trennen, 5 HPA.
8. Backup & Restore
Schema-Migrationen (Reproduzierbarkeit): Ausschließlich prisma/migrations/ als Quelle der Wahrheit. Lokal/Entwicklung: npx prisma migrate dev. Gegen jede Produktions- oder Staging-Datenbank ausschließlich npx prisma migrate deploy (idempotent, wendet nur fehlende Migrationen an, kein Schema-Drift). prisma db push wird an keiner Stelle im Code, in Skripten oder im Dockerfile verwendet und ist für Produktions-/Staging-Umgebungen untersagt.
Die Migration läuft nicht mehr im Container-CMD (das ist nur noch node dist/main.js). Sie läuft als initContainer migrate (node node_modules/prisma/build/index.js migrate deploy, HOME=/tmp, CHECKPOINT_DISABLE=1) in den Pods von api und worker (deploy/k8s/components/backend-roles).
Starten mehrere dieser initContainer gleichzeitig, serialisiert sie der datenbankseitige Advisory-Lock von Prisma. Ein Lauf, der den Lock nicht binnen 10 s bekommt, bricht ab. Kubelet startet den initContainer dann neu, und der nächste Lauf findet nichts mehr zu tun. Das ist harmlos.
Der Seed (node dist/seed.js) läuft als zweiter initContainer seed nur im api-Pod. Er ist per Unique-Constraint auf User.email idempotent: Ein verlierender Pod bekommt P2002, seed.ts behandelt das als No-op.
Scheitert die Migration, scheitert der initContainer und der neue Pod startet nicht. Wegen maxSurge: 1 und maxUnavailable: 0 läuft der alte Pod weiter. Die Hauptprozesse starten ohne Prisma-CLI. Die startupProbe der api (bis 5 Minuten) bleibt für das alte Image, das die Migration noch im CMD ausführt.
Erster Admin-Zugang: prisma/seed.ts legt beim allerersten Start (leere User-Tabelle) einen Administrator an (SEED_ADMIN_EMAIL/SEED_ADMIN_PASSWORD, Default [email protected]/ChangeMe123!) und gibt URL (FRONTEND_ORIGIN), Benutzername und Passwort einmalig im Container-Log aus. Existiert der User schon (auch deaktiviert), passiert nichts – kein erneutes Log, kein erneutes Anlegen, damit spätere Passwortänderungen nicht durch veraltete Logs verwirrend überschrieben wirken und Zugangsdaten nicht bei jedem Neustart in die Logs wandern. Ersetzen: neuen Admin über /settings/users anlegen, dann den alten deaktivieren (Löschen gibt es nicht, siehe "Historie statt Löschen") – die Lockout-Sperre in UsersService.assertNotLastSystemRoleUser verhindert ohnehin, den letzten aktiven Admin zu deaktivieren.
Datensicherung: Seit der Entscheidung, PostgreSQL selbst im Cluster zu betreiben (siehe CLAUDE.md), liegt die Backup-Verantwortung bei diesem Projekt, nicht mehr bei einem externen Cluster-/DBA-Betrieb. Umgesetzt als anwendungsseitiges BackupModule (ApiCore-Backend/src/backup/) statt der ursprünglich hier vorgesehenen reinen pg_dump/PITR-Strategie:
- Export/Import:
BackupService.exportAll()/.importAll()sichert alle fachlichen und Konfigurationsmodelle (Betriebsdaten inkl. Herstellung, Rohstoffe, Bewegungsbuch und Shop-Anfragen, Nummernkreise, Benutzer/Rollen/Rechte, Audit-Log, Website-/Backup-/Kalender-Einstellungen samt Kalender-Outbox, hochgeladene Dokumente inkl. Dateiinhalt als Base64) als eine versionierte JSON-Datei (Bundle-Version 3; ältere Bundles bleiben einspielbar). Neu im Bundle:Suggestion(Import nachcolony, vortask, weilTask.sourceSuggestionIddarauf zeigt;decidedByUserIdwie andere Benutzer-IDs,nullbleibtnull; nur aus versiegelten Dateien, sonst kämen verworfene oder übernommene Vorschläge nach einem Restore als neue zurück) undNotificationPreference. Meldungen: Ein Eintrag eines im Zielsystem unbekannten Nutzers oder mit einemdedupeKey, der dort unter anderer ID existiert, wird übersprungen und inimported.notificationSkippedgezählt (nie umgehängt); Einträge alter Bundles ohneuserIdwerden je aktivem Nutzer kopiert (abgeleitete ID<id>:<userId>, wiederholbar), ohnecategorynach der Regel von Migration100800(relatedType 'JobRun'oder leer →SYSTEM, sonstSUGGESTION). Ein alter Bundle-Wert fürInspection.feedStatuswird auf das Futter-Vokabular abgebildet (Annahme 33). Bewusst ausgenommen sind nur Permission (Katalog aus dem Code), RefreshToken, ConsumedToken, ApiKey (Annahme 14), IdempotencyRecord (Annahme 11) sowie die Betriebsmodelle des Plattform-Rückgrats DomainEvent und DomainEventDelivery (Outbox – Seiteneffekte dürfen nach einem Restore nicht erneut ausgelöst werden), WebhookDelivery (Zustellprotokoll und Wiederholungs-Warteschlange – sonst gingen alte Ereignisse erneut an Empfänger), JobRun (Laufhistorie der CronJobs dieses Deployments) und ThrottlerHit (UNLOGGED-Zähler des Rate-Limits); die Liste mit Begründung steht inMODELS_EXCLUDED_FROM_BACKUP, ein Test erzwingt sie für jedes neue Prisma-Modell. Import ist upsert-basiert (Rollen/Berechtigungen überkey/resource+actionstatt roherid, damit ein Restore auch in ein anderes Deployment funktioniert) und läuft in einer einzigen$transaction. - Automatischer Zeitplan: Kein Timer im API-Prozess. Der CronJob
apicore-job-app-backup(0 * * * *, Europe/Berlin,concurrencyPolicy: Forbid) startet stündlichnode dist/cli.js app-backup(BackupSchedulerService.runIfDue). Der Job liest Pfad (BACKUP_STORAGE_PATH), Cron-Ausdruck und Aufbewahrung (BACKUP_RETENTION_COUNT) frisch – Env-Variable vor GUI-Wert (Verwaltung > Backup) –, vergleicht den nächsten Termin nach der mtime der neuesten Backup-Datei (isBackupDue, Zeitzone Europe/Berlin, „jetzt“ = DB-Uhr) und schreibt nur, wenn er erreicht ist; alte Stände rotiert er nach der Aufbewahrung. Prüfung und Schreiben laufen unter einem DB-Advisory-Lock, zwei Job-Pods schreiben nie doppelt. Ohne Pfad endet der Lauf erfolgreich mit{ written: false, reason: 'no-path' }; ein Fehler lässt denJobRunfehlschlagen (Benachrichtigung an die Inhaber vonbackup:configure). GUI-Änderungen und Importe gelten ab der nächsten stündlichen Prüfung. „Jetzt sichern“ bleibt in der API und schreibt immer.GET /backup/scheduleliefert zusätzlichlastJobRun(Status, Start, Ende, Fehler); die Seite zeigt „Wird stündlich geprüft · letzter Lauf …“. Die Lock-Transaktion der Fälligkeitsprüfung hält eine Verbindung bis zum Ende des Schreibens (Timeout 1 h =activeDeadlineSeconds; der Export nutzt andere Verbindungen, der Pool braucht mindestens zwei). Sie braucht wederidle_in_transaction_session_timeoutnoch einen Transaction-Mode-PgBouncer: Postgres wird direkt angesprochen, und der Lock hängt an der Transaktion. Scheitert der Commit, nachdem die Datei schon geschrieben wurde, bleibt die Datei bestehen, der Lauf steht aber kosmetisch aufFAILED; der nächste Lauf sieht die Datei an ihrer mtime. Das Aufräumen von Idempotenz-Einträgen (90 Tage) und verbrauchten Einmal-Tokens läuft einmal täglich im CronJobapicore-job-cleanup(Abschnitt 10, „Jobs“). - Restore: Über
POST /backup/import(bzw. den Import-Dialog unter Verwaltung > Backup) eine zuvor exportierte Bundle-Datei einspielen. Import-LimitBACKUP_MAX_IMPORT_BYTES(Standard 50 MB, höchstens 256 MB, weil der Upload vollständig im RAM des 512-Mi-Pods verarbeitet wird); Export und Zeitplan prüfen jede Sicherung dagegen und warnen in Log und UI (Verwaltung > Backup), bevor eine Datei entsteht, die sich im Notfall nicht mehr einspielen ließe.
Ein rohes pg_dump/PITR-Backup auf Infrastrukturebene bleibt als zusätzliche, komplementäre Absicherung für den Katastrophenfall (z. B. korrupte Datenbankdateien, nicht nur fachliche Daten) sinnvoll, ist aber (CronJob apicore-db-backup, Abschnitt 7) nicht der primäre, hier dokumentierte Mechanismus – die in-App-Lösung deckt den fachlichen Anwendungsfall "Daten sichern/übertragen" bereits vollständig ab.
9. API-Sicherheit (Stand Oktober 2026)
Grundlage: OWASP API Security Top 10 (2023) und OWASP ASVS Level 2. Umsetzung und Begründung je Punkt in docs/pruefung-2026-09.md, Abschnitt „API-Sicherheit“.
| Bereich | Maßnahme |
|---|---|
| Authentifizierung | JWT mit festem Algorithmus, Issuer und Audience je Zweck; MFA (TOTP) mit Replay-Schutz; Kontosperre 15 min nach 5 Fehlversuchen; gleiche Antwort für unbekannte/gesperrte Konten; bcrypt auf jedem Pfad |
| Passwörter | mind. 12 Zeichen, max. 72 Byte, Abgleich mit Have I Been Pwned (k-Anonymität, abschaltbar per PASSWORD_BREACH_CHECK=false) |
| Sitzungen | Refresh-Rotation atomar, Diebstahlerkennung, max. 7 Tage; Einmal-Tokens für SSO-Abschluss und MFA-Schritt |
| Step-up | Backup-Import, MFA ein/aus verlangen aktuelles Passwort (+ TOTP) |
| Autorisierung | fail-closed PermissionsGuard; keine Rechteausweitung über Benutzer-/Rollenpflege; Suche nur über lesbare Objektarten |
| Maschinen-Zugriff | API-Schlüssel nur als Hash (Suche über den Hash, kein Vergleich von Geheimnissen), Scope-Allowlist (shop-sync:read, shop-sync:write, erp-inventory:read), IP-Allowlist, widerrufbar, befristbar; Ersteller kann nur eigene Rechte vergeben. Je neuem Schlüssel genau ein Recht: ApiKeysService.create lehnt mehrere Rechte mit 400 ab (Lesen und Buchen damit immer getrennt); bereits ausgestellte Schlüssel mit mehreren Rechten bleiben gültig und werden in der Oberfläche mit Hinweis markiert; erp-inventory:read zählt als Lesen. Nach 20 ungültigen Schlüsseln in 15 min je IP (IPv6: je /64) 429 für weitere ungültige – gültige Schlüssel nie, falsch aufgebaute Tokens zählen nicht; Zähler in ThrottlerHit, über alle Pods geteilt (Annahme 30); Warnung beim Start, wenn TRUST_PROXY in Produktion fehlt; Logs nur mit Schlüssel-Präfix |
| Ausgehende Aufrufe | Webhooks: nie aus der Anfrage heraus gesendet, sondern über die Zustelltabelle WebhookDelivery (Warteschlange und Zustellprotokoll), die der Worker abarbeitet; Ziel-IP beim Verbindungsaufbau geprüft (kein DNS-Rebinding), nur Port 80/443, https in Produktion, keine Weiterleitungen, 5 s Leerlauf-Timeout und 10 s Frist je Versuch (danach wird die Verbindung abgebrochen); nur eine 2xx-Antwort gilt als zugestellt, sonst Wiederholung nach der Outbox-Regel (30 s bis 6 h, nach 8 Versuchen FAILED); stabile Delivery-ID (X-ApiCore-Delivery = WebhookDelivery.id, mit gleichem Body über alle Wiederholungen); Signatur über Zeitstempel+ID+Body; Antwortkörper wird nie gelesen, lastError nur über loggableError() |
| Transport/Antworten | Alle API-Antworten tragen Cache-Control: no-store und Pragma: no-cache; einzige Ausnahme ist die erfolgreiche (200) öffentliche Herkunftsantwort GET /public/batches/:lot mit Cache-Control: public, max-age=300, s-maxage=3600 (ohne Pragma, am Cloudflare-Edge cachebar), Fehlerantworten bleiben no-store; Cache-Control: no-store (außer öffentliche Chargen-Seite), CSP default-src 'none', frame-ancestors 'none', keine ETags; Produktion startet nur mit https-FRONTEND_ORIGIN und sicheren Cookies |
| Rate-Limits | Throttler vor der Authentifizierung, Zähler in der UNLOGGED-Tabelle ThrottlerHit und damit über alle Pods geteilt (eine Upsert-Anweisung, ausschließlich DB-Uhr now(); 100/min, Login/MFA/Refresh/Logout 10/min, öffentliche Chargen 30/min); Client-IP über TRUST_PROXY (IPv6 je /64); bei DB-Fehler schlägt die Anfrage fehl (fail-closed) – nur die Liveness GET /health ist ausgenommen, damit ein DB-Ausfall nicht alle Pods neu startet; abgelaufene Zeilen räumt der Job cleanup auf |
| Daten | Backups verschlüsselt und signiert, Dateien 0600; CSV-Export ohne Formel-Injection; Logs ohne Query-Strings |
Bekannte Restpunkte (bewusst nicht umgesetzt):
- Eine E-Mail-Änderung im eigenen Profil meldet „bereits vergeben“, wenn die Adresse existiert. Das erfordert eine Anmeldung plus aktuelles Passwort; eine generische Meldung würde Nutzer nur verwirren.
10. Ereignis-Rückgrat und Laufrollen
Ereignisse
Outbox. Fachliche Folgen werden nicht direkt nach dem Commit ausgeführt. Sie werden als DomainEvent geschrieben, in derselben Prisma-Transaktion wie die fachliche Änderung und der Audit-Eintrag (DomainEventsService.record/recordAll, nur mit Transaktions-Client). Kein Commit ohne Ereignis, kein Ereignis ohne Commit. Nach dem Commit weckt pg_notify('domain_event', '') den Worker. Ein verlorenes Wecken verzögert nur bis zum nächsten Takt (5 s).
Claim. DomainEventDispatcher.runOnce() übernimmt höchstens 50 fällige Ereignisse (status = 'PENDING', availableAt <= now()), älteste zuerst (occurredAt). Das geschieht per UPDATE … WHERE id IN (SELECT … FOR UPDATE SKIP LOCKED) RETURNING * → PROCESSING, claimedAt = now(). Mehrere Worker übernehmen so nebeneinander disjunkte Mengen, ohne aufeinander zu warten. drain() wiederholt runOnce(), bis eine Runde nichts übernimmt, höchstens 20 Runden je Takt. Folgeereignisse der Handler kommen so in der nächsten Runde dran. Claim, Abschluss und Freigabe liegen für alle Outboxen in src/common/outbox/outbox-claims.ts, die Wiederholregel in src/common/retry-policy.ts. Alle Zeitvergleiche laufen in SQL gegen now() (DB-Uhr). availableAt ist timestamp(3): Ein per SQL geschriebenes now() wird auf die Millisekunde gerundet und kann bis 0,5 ms hinter dem now() der nächsten Anweisung liegen. Darum vergleicht der Claim gegen CAST(now() AS timestamp(3)), sonst fände ein sofortiger Claim frisch fällig gesetzte Zeilen gelegentlich nicht (Regressionstest test/outbox-claims.e2e-spec.ts).
Delivery-Sperre. Je Ereignis laufen die Handler nacheinander in fester Reihenfolge (Handler-ID nach Codepunkt), jeder in einer eigenen Transaktion mit 10 s Timeout:
DomainEventDelivery(eventId, handler) anlegen, falls es sie noch nicht gibt (skipDuplicates).- Die Zeile
FOR UPDATEsperren. Ist sieDONE, wird der Handler übersprungen. - Den Handler mit dem Transaktions-Client aufrufen; die Korrelations-ID steht im
domainEventContext. - Die Delivery auf
DONEsetzen,attempts + 1.
Die Wirkung des Handlers und DONE werden gemeinsam committet. Die Exklusivität je (Ereignis, Handler) kommt allein von dieser FOR UPDATE-Sperre der Delivery-Zeile, nicht vom Claim: Ein Ereignis kann nach einer Stale-Freigabe von einem zweiten Worker übernommen werden, während der erste noch läuft. Der zweite Worker wartet dann auf die Sperre und findet danach DONE. So entsteht jede Wirkung höchstens einmal je (Ereignis, Handler), auch bei einem Retry oder einem wieder freigegebenen Ereignis. Scheitert ein Handler, rollt seine Transaktion zurück. Außerhalb davon zählt die Delivery attempts + 1 und speichert lastError; die übrigen Handler laufen trotzdem.
Backoff und FAILED. Sind alle Deliveries DONE (oder hat der Typ keinen Handler), wird das Ereignis DONE (processedAt = now()). Sonst gilt attempts + 1, lastError = "<handlerId>: <Meldung>" (erster gescheiterter Handler) und availableAt = now() + Wartezeit: 30 s, 2 min, 10 min, 1 h, ab dem 5. Fehlversuch 6 h. Beim 8. Fehlversuch wird das Ereignis FAILED, ebenso seine offenen Deliveries. Erledigte Handler laufen beim Wiederholen nicht erneut. Der Abschluss ist ein Compare-and-Set auf id + claimedAt: Wer seinen Claim verloren hat, ändert nichts.
Liegengebliebene Claims. releaseStaleClaims() läuft in jedem Worker-Takt vor drain(). Es setzt Ereignisse, deren claimedAt älter als 5 min ist, sofort wieder auf PENDING, mit attempts + 1 und lastError = 'claim expired (worker restarted?)'; ab 8 Versuchen werden sie FAILED, und ihre offenen Deliveries ebenfalls. So kann ein Ereignis, das seinen Worker abstürzen lässt, nicht endlos kreisen.
Handler-Vertrag. @OnDomainEvent(types, '<consumer>.<name>') steht nur auf Singleton-Providern. Ein Handler schreibt ausschließlich über den übergebenen tx; lesen darf er auch über PrismaService. Er macht keine HTTP- oder Graph-Aufrufe: Netzwerkarbeit kommt in eine eigene Outbox (CalendarSyncOutboxEntry, WebhookDelivery), die eine Worker-Schleife abarbeitet. Der Wächter src/domain-events/handler-contract.spec.ts prüft, dass *.handlers.ts keine Netzwerk-Clients importieren. Fehlertexte gehen nur über loggableError() in lastError und Logs, nie Payload oder Secrets.
Aufgaben. Jede Aufgabe, die die App anlegt oder bearbeitet, läuft über TasksService (auch die Aufgaben der Automatik-Regeln, siehe Annahme 12; nur die Backup-Wiederherstellung ist ausgenommen). createInTx(tx, input, actingUserId) schreibt Aufgabe, Audit-Eintrag und TaskCreated (relatedType, relatedId, dueDate als UTC-Tag, utcDay()) in der Transaktion des Aufrufers – Aufgabenseite, Folgeaufgabe aus der Durchsicht, Vorlage. update schreibt TaskUpdated (status, dueDate). TaskCompleted entsteht nur beim Übergang nach DONE, durch „Erledigt“ (complete) ebenso wie durch eine Bearbeitung mit status: DONE: beide setzen Status und completedAt über dasselbe bedingte Update (status <> 'DONE'), sodass die Erledigung auch bei zwei gleichzeitigen Anfragen genau einmal gemeldet wird. Kalender (calendar-sync.task) und Webhooks (webhooks.forward: task.created, task.completed) folgen ausschließlich aus diesen Ereignissen; direkte Aufrufe gibt es für Aufgaben nicht mehr. Wiederkehrende Vorlagen erzeugt TasksService.generateFromTemplates(now) (lazy vor GET /tasks): je Vorlage ein Compare-and-Swap auf nextDueDate und createInTx in einer Transaktion, sodass von mehreren Replicas genau eine das Vorkommen anlegt und ein gescheiterter Schreibversuch es nicht verliert. Solche Aufgaben haben keinen Ersteller, ihr Audit-Eintrag keinen Benutzer, ihr Ereignis actorUserId = null. createInTx nimmt auch sourceSuggestionId (Aufgabe aus einem Vorschlag, Abschnitt 11.1). completeIn und cancelIn laufen in der Transaktion des Aufrufers (Zeilensperre, Neulesen, bedingter Übergang, Audit-Eintrag und TaskCompleted bzw. TaskUpdated nur beim Übergang); complete() nutzt completeIn, es gibt keinen zweiten Pfad. Das Aufräumen eines beendeten Volkes bricht Aufgaben darüber ab oder erledigt sie (Abschnitt 11.7); TaskDue löst die Erinnerung aus (Abschnitt 11.9).
Durchsichten und Varroa. InspectionsService und VarroaService schreiben Datensatz, Audit-Eintrag und Ereignis in einer Transaktion: create → InspectionRecorded bzw. VarroaMeasured, update → InspectionCorrected bzw. VarroaCorrected, jeweils mit { colonyId } (das Volk nach der Änderung, auch nach einer Umbuchung). update sperrt die Zeile zuerst (lockRowForUpdate) und liest „vorher“ danach über tx; auch die Tagesrate der Varroamessung wird aus dieser Zeile abgeleitet. Alle Folgen kommen aus Handlern: Regeln über automation.inspection-rules (InspectionRecorded: auswerten und offene Vorschläge nachführen; InspectionCorrected: nur nachführen, nie neu vorschlagen) und automation.varroa-threshold (VarroaMeasured und VarroaCorrected; eine korrigierte Zählung kann die Schwelle überschreiten, und der offene Vorschlag derselben Messung wird aufgefrischt oder aufgelöst, Abschnitt 11.4), den Autopilot über autopilot.colony-sync (Abschnitt 11.5), Kalender über calendar-sync.inspection (beide Durchsicht-Ereignisse), Webhooks inspection.created und varroa.measured über webhooks.forward. Ein Handler wertet den Datensatz so aus, wie er bei der Zustellung in der Datenbank steht.
Behandlungen, Fütterungen und Ernten. Anlegen schreibt Datensatz, Audit-Eintrag und TreatmentRecorded, FeedingRecorded bzw. HarvestRecorded in einer Transaktion, Ändern TreatmentCorrected, FeedingCorrected bzw. HarvestCorrected. Kalendereintrag (calendar-sync.*), Webhook (treatment.created, feeding.created, harvest.created) und Folgekontrolle (automation.treatment-follow-up, automation.feeding-follow-up) entstehen ausschließlich in den Handlern; Korrekturen lösen nur den Kalender-Handler aus, keine Folgekontrolle und keinen Webhook. Die Fälligkeit der Folgekontrolle gilt ab dem Datum der Maßnahme. update sperrt die Zeile und liest „vorher“ danach über tx; die Behandlung sperrt beim Anlegen das Volk und liest es über tx, bei Ernten läuft die Gewichtsprüfung unter der Sperre.
Völker. ColoniesService und WinterChecksService schreiben Audit, Datenänderung und Ereignis in einer Transaktion. Ändert sich status oder healthStatus (update, merge je vereinigtem Volk, dissolve, reactivate, Auswinterungsverlust mit markColonyDeceased), entsteht ColonyStatusChanged { from, to }; beim Übergang von lebend nach beendet (DISSOLVED, SOLD, DECEASED, MERGED) zusätzlich ColonyEnded { status, endedOn, releasedQueenId, via } (Abschnitt 11.7). automation.colony-health hört auf ColonyStatusChanged. Die Operationen sperren das Volk (lockColony: Sperre, dann Neulesen über tx) und prüfen darunter, ob es noch lebt; merge sperrt alle vereinigten Völker mit einer Anweisung (lockRowsForUpdate). colony.created bleibt im Altpfad WebhooksService.dispatch (Annahme 11). Das Teilen schreibt ColonySplit, die Königin-Zuordnung QueenAssigned in ihrer Transaktion (Handler-Übersicht unten); Kalender, Webhook und Ketten folgen aus diesen Ereignissen.
Bestand und Abfüllung. Bestandsbuchungen kündigen sich über StockChanged { bottlingBatchIds, productIds, rawMaterialKinds } an. Das Ereignis entsteht in derselben Transaktion wie Bewegung, Audit-Eintrag und Datenänderung, über StockService.recordChange(tx, change, actingUserId, aggregate). Eine leere Änderung erzeugt kein Ereignis, jede ID und jede Sorte steht nur einmal darin.
- Entnahme und Inventur hängen das Ereignis an die Bewegung (
BatchMovement). Bei einer Rohstoff-Charge steht deren Sorte inrawMaterialKinds. - Jede angenommene Shop-Buchung (
ShopStockRequest,actorUserId = null) schreibt ihr eigenes Ereignis. Abgelehnte und wiederholte Anfragen schreiben keines. - Abfüllungen schreiben beim Anlegen
BatchBottled { honeyBatchId }, bei Korrektur und LöschungBottlingCorrected { honeyBatchId, removed }, jeweils zusammen mitStockChangedin einemrecordAll, also mit einer Korrelations-ID. - Herstellungschargen schreiben
StockChangedmit dem Artikel und den verbrauchten bzw. beim Löschen zurückgegebenen Rohstoff-Sorten.
Daraus folgen die Webhooks batch.updated und stock.changed (webhooks.forward; reine Rohstoff-Änderungen gehen nicht an den Shop) sowie die Rohstoff-Mindestmenge (automation.raw-material-low, je Sorte über remainingByRawMaterialKind). Korrekturen und Löschungen von Abfüllung, Herstellungscharge und Rohstoff-Charge sperren die Zeile (lockRowForUpdate) und lesen „vorher“ erst danach über tx; Mengenprüfung und Bewegungs-Check vor dem Löschen laufen ebenfalls unter dieser Sperre. Herstellungschargen sperren zuerst den Artikel (StockService.lock), dann die Charge: dieselbe Reihenfolge wie ein Shop-Verkauf, der den Artikel hält, während seine Bewegung die Charge referenziert. Reines Anlegen oder Ändern einer Rohstoff-Charge schreibt kein StockChanged; die Mindestmenge wird erst bei der nächsten Entnahme, Korrektur oder Verbrauchsbuchung dieser Sorte geprüft.
Sammelerfassung am Stand. POST /treatments/bulk, /feedings/bulk und /varroa-measurements/bulk legen in einer Transaktion (runBulk, src/common/bulk/run-bulk.ts) je Volk einen eigenen Datensatz an: höchstens BULK_MAX_COLONIES = 100 Völker, Timeout BULK_TRANSACTION_TIMEOUT_MS = 15 s. Es gilt alles oder nichts: assertLivingColonies sperrt die Völker per lockRowsForShare (SELECT … FOR SHARE, aufsteigend nach id) und liest sie neu. Ist ein Volk unbekannt oder beendet, wird nichts gespeichert, und die Antwort 400 nennt die Völker in invalidColonyIds; der Client kann ohne sie erneut senden. Jeder Datensatz bekommt seinen eigenen Audit-Eintrag und sein eigenes TreatmentRecorded, FeedingRecorded bzw. VarroaMeasured; diese Ereignisse tragen optional bulkId (RecordedForColony), bei Einzelanlage fehlt das Feld. Kalender, Folgekontrolle, Varroa-Schwelle und Webhooks folgen daher je Volk unverändert aus den Ereignissen. Einzel- und Sammelanlage nutzen dieselbe Datenaufbereitung; bei Behandlungen kommt der Stand je Volk aus der gesperrten Zeile (Bestandsbuch). Die Chargennummer ist die Arzneimittel-Charge der Packung: einmal eingegeben, für alle Datensätze gleich, nie erzeugt. Die Gruppierung leistet bulkId. Varroa nimmt entries mit Milbenzahl je Volk (keine gemeinsame Milbenzahl), die Tagesrate wird je Eintrag wie bei der Einzelmessung abgeleitet. Die Einzel-Endpunkte akzeptieren weiter beendete Völker, nur die Sammelerfassung prüft auf lebende.
Zeilensperren. src/prisma/row-lock.ts bietet lockRowForUpdate (eine Zeile, liefert, ob sie existiert), lockRowsForUpdate und lockRowsForShare (mehrere Zeilen mit einer Anweisung, aufsteigend nach id, damit sich überlappende Mengen nicht gegenseitig blockieren). Sie laufen nur mit einem Transaktions-Client, Tabellennamen kommen aus einer festen Liste (nie aus Eingaben). Muster für jedes Ändern: sperren, dann „vorher“ über tx neu lesen, nie die Vorab-Lesung verwenden.
Tests. e2e-Tests haben keinen Worker. Sie rufen nach der Anfrage drainDomainEvents(app, [aggregateId]) aus test/utils/domain-events.ts auf. Das arbeitet nur die Ereignisse dieser Korrelationen ab, sodass parallele e2e-Dateien sich nicht stören.
Neue und geänderte Ereignisse. ColonySplit { parentColonyId, lineageId, queenSource | null, splitOn } (Aggregat: das neue Volk) schreibt ColoniesService.split in der Teil-Transaktion. QueenAssigned { colonyId, historyId, assignedOn, previousColonyId | null } (Aggregat: die Königin) schreibt QueensService.assignColony nach dem Sperren beider Völker (eine Anweisung, aufsteigend nach id) und der Königin; ein geänderter vorheriger Stand zwischen Vorab-Lesung und Sperre ergibt 409. ColonyEnded trägt jetzt { status, endedOn, releasedQueenId, via } (Abschnitt 11.7). InspectionOverdue, VarroaMeasurementDue und QueenAged haben zusätzliche, optionale Payload-Felder (Abschnitt „Jobs“, daily-evaluation).
Handler-Übersicht (T4). Alle Handler laufen nach dem Vertrag oben (eigene Transaktion, nur über tx schreiben, kein Netzwerk). Die Handler der Ketten, des Autopilots und der Wartezeit prüfen zuerst das Volk: Zeile sperren (lockRowForUpdate; fehlt es, ist nichts zu tun), Status über tx neu lesen (beendet: nichts), Regel über readRule(tx, …) lesen (abgeschaltet: nichts vorschlagen).
| Handler-ID | Ereignis | Wirkung |
|---|---|---|
automation.inspection-rules |
InspectionRecorded, InspectionCorrected |
Regeln QUEENLESS_NO_EGGS, FEED_STATUS_CRITICAL: bei InspectionRecorded Vorschlag oder Nachführen offener Vorschläge, bei InspectionCorrected nur Nachführen (Annahme 12). |
automation.varroa-threshold |
VarroaMeasured, VarroaCorrected |
VARROA_THRESHOLD: nur rot (außerhalb des Behandlungseffekts) schlägt VARROA_RED vor; bei VarroaCorrected wird der offene Vorschlag derselben Messung aufgefrischt (noch rot) oder aufgelöst. |
automation.colony-health |
ColonyStatusChanged |
COLONY_HEALTH_CRITICAL, Anker = Ereignis-ID. |
automation.treatment-follow-up, automation.feeding-follow-up, automation.raw-material-low |
TreatmentRecorded, FeedingRecorded, StockChanged |
Unverändert ausgelöst, schreiben jetzt Vorschläge (Annahme 12). |
suggestions.colony-ended |
ColonyEnded |
Setzt offene Vorschläge des Volkes auf OBSOLETE; bei offenen Aufgaben oder einer freigegebenen Königin mit Status ACTIVE legt er den Aufräum-Vorschlag an (Abschnitt 11.7). |
chains.queen-cells |
InspectionRecorded |
Erledigt zuerst offene Ketten-Vorschläge (Notiz „Mit Durchsicht erledigt“): SWARM_CHECK bei Weiselzellen alle, sonst die bis zum Durchsichtstag fälligen; QUEEN_ACCEPTANCE (fällig bis Durchsichtstag) nur bei queenSeen oder eggsPresent. Bei queenCellsPresent = true und aktiver Regel QUEEN_CELLS_SWARM_CHECK: PLAN SWARM_CHECK (inspections:read), Anker Durchsicht-ID, fällig Durchsichtstag + dueInDays (7). |
chains.queen-acceptance |
QueenAssigned |
Regel QUEEN_ACCEPTANCE_CHECK aktiv: PLAN QUEEN_ACCEPTANCE (queens:read), Anker historyId, fällig assignedOn + dueInDays (7). |
chains.split-schedule |
ColonySplit |
Regel SPLIT_SCHEDULE aktiv und queenSource gesetzt: RAISE_OWN und QUEEN_CELL → PLAN SPLIT_PLAN (colonies:read), QUEEN → PLAN QUEEN_ACCEPTANCE (queens:read); Anker lineageId, Plan = Schritte der Variante ab splitOn (Abschnitt 11.6). |
autopilot.colony-sync |
19 Typen: InspectionRecorded/Corrected, VarroaMeasured/Corrected, TreatmentRecorded/Corrected, FeedingRecorded/Corrected, HarvestRecorded/Corrected, ColonyStatusChanged, ColonySplit, QueenAssigned, TaskCompleted, TaskUpdated, WithdrawalEnded, QueenAged, InspectionOverdue, VarroaMeasurementDue |
Ruft AutopilotService.syncColonyIn(tx, colonyId, berlinToday()) je betroffenem Volk in aufsteigender ID-Reihenfolge: bei Ernten jedes Volk, bei ColonySplit neues und Muttervolk, bei QueenAssigned Volk und vorheriges Volk; Aufgaben-Ereignisse nur mit relatedType = COLONY (Abschnitt 11.5). |
withdrawal.harvest-check |
HarvestRecorded, HarvestCorrected |
Prüft Wartezeit-Konflikte der Ernte und schreibt die Meldung (Abschnitt 11.8). |
withdrawal.bottling-check |
BatchBottled, BottlingCorrected |
Wie oben für die Honigcharge der Abfüllung; bei removed: true nichts. |
withdrawal.treatment-check |
TreatmentRecorded, TreatmentCorrected |
Prüft Ernten des Volkes, die das (nachträglich erfasste oder verlängerte) Wartezeitfenster treffen. |
notifications.withdrawal-ended |
WithdrawalEnded |
INFO „ ist wieder erntefähig“ (Abschnitt 11.8). |
notifications.task-due |
TaskDue |
Erinnerung je fälliger Aufgabe (Abschnitt 11.9). |
calendar-sync.queen-assignment |
QueenAssigned |
enqueueIn(tx, 'QUEEN_ASSIGNMENT', historyId). |
calendar-sync.withdrawal |
TreatmentRecorded, TreatmentCorrected |
enqueueIn(tx, 'WITHDRAWAL', treatmentId) (Abschnitt 11.8). |
webhooks.forward |
erweitert um QueenAssigned |
queen.replaced (siehe „Webhook-Zustellung“). |
Webhook-Zustellung
Einreihen. WebhooksService.enqueueDeliveries(db, event, payload) legt je aktivem Abonnement des Webhook-Ereignisses eine WebhookDelivery an und weckt den Worker mit pg_notify('webhook_delivery', ''). Gesendet wird dabei nichts. Der Body JSON.stringify({ event, data }) wird beim Einreihen festgehalten, damit jede Wiederholung dieselben Bytes sendet. Mit einem Transaktions-Client committen Zeilen und Wecken zusammen mit dem Aufrufer. Zwei Wege führen dorthin:
- Der Handler
webhooks.forward(src/webhooks/webhook.handlers.ts) übersetzt Ereignisse nachDOMAIN_EVENT_WEBHOOKS:InspectionRecorded→inspection.created,VarroaMeasured→varroa.measured,TreatmentRecorded→treatment.created,FeedingRecorded→feeding.created,HarvestRecorded→harvest.created,BatchBottledundBottlingCorrected→batch.updated,StockChanged→stock.changed,TaskCreated→task.created,TaskCompleted→task.completed,QueenAssigned→queen.replaced.dataist wie bisher der Datensatz, gelesen übertx: die Ernte mit ihren Völkern, beibatch.updateddie Honigcharge, beiqueen.replaceddie Königin, beistock.changednur{ bottlingBatchIds, productIds }und nur, wenn eine der Listen nicht leer ist. Fehlt der Datensatz, wird nichts eingereiht. - Der Altpfad
WebhooksService.dispatch(event, payload)für Erzeuger, die noch kein Ereignis schreiben (colony.created,batch.created,queen.created,product.stock_changed), reiht nach dem Commit überrunAfterCommitein und wirft nie.
Zustellung. Die Worker-Schleife webhook-deliveries (alle 5 s und auf webhook_delivery) ruft WebhookDeliveryWorker.releaseStaleClaims() und dann drain() auf. Claim, Abschluss und Freigabe laufen über outbox-claims.ts: höchstens 50 fällige Zustellungen je Runde, älteste zuerst (createdAt), höchstens 20 Runden je Takt. Höchstens 5 Zustellungen laufen gleichzeitig. Jede wird mit Zeitstempel und ihrer ID signiert (X-ApiCore-Signature: sha256=HMAC(secret, "<t>.<id>.<body>")) und über postWebhook gesendet: keine Weiterleitungen, Ziel-IP beim Verbindungsaufbau geprüft, 5 s Leerlauf-Timeout und eine Frist von 10 s je Versuch. Bei Ablauf der Frist wird die Anfrage samt Verbindung abgebrochen, auch wenn der Empfänger seine Antwort nur tröpfchenweise sendet; so hält er weder einen der 5 Plätze noch einen Socket. X-ApiCore-Delivery ist die WebhookDelivery.id und bleibt über alle Wiederholungen gleich. Zugestellt wird mindestens einmal; Empfänger verwerfen Duplikate anhand dieser ID.
Ergebnis. Nur eine 2xx-Antwort gilt als zugestellt: DELIVERED, deliveredAt = now(). Jede andere Antwort, ein Netzwerkfehler und ein pausiertes Abonnement (subscription inactive, ohne zu senden) sind Fehlversuche nach derselben Regel wie bei Ereignissen: attempts + 1, Wartezeit 30 s, 2 min, 10 min, 1 h, danach 6 h, beim 8. Fehlversuch FAILED. Ein Abonnement, das innerhalb dieses Fensters wieder aktiviert wird, bekommt seine Zustellungen also noch. lastStatusCode hält den Status der letzten Antwort (ohne Antwort null), lastError einen festen Text (Receiver answered HTTP <status>) oder die Meldung aus loggableError(); Antwortkörper, Secret und Payload stehen nie darin. Status-Code und Abschluss werden in einer Transaktion gesetzt, beide als Compare-and-Set auf id + claimedAt. Beim Herunterfahren holt drain() keine weitere Runde; die laufende wird zu Ende zugestellt.
Zustellprotokoll. GET /webhooks/:id/deliveries?limit=1..100 (Standard 50, Recht webhooks:read) liefert die neuesten Zustellungen eines Abonnements ohne Body: id, event, status, attempts, lastStatusCode, lastError, createdAt, deliveredAt.
Tests. e2e-Tests rufen deliverWebhooks(app, [subscriptionId]) aus test/utils/webhooks.ts auf. Das stellt nur die Zustellungen dieser Abonnements zu.
Jobs
Aufruf. node dist/cli.js <job> [args] (src/cli.ts, Rolle job) prüft wie main.ts und worker.ts zuerst Rolle und Umgebung (resolveRole('cli'), assertStartupEnvironment) und startet dann JobsModule per createApplicationContext: die Module, die die Jobs brauchen, ohne HTTP-Schicht und ohne Worker-Schleifen. Jobs (JOB_NAMES in src/jobs/job-names.ts): daily-evaluation, task-templates, cleanup, app-backup, rekey. Weitere Argumente gehen unverändert an den Job, erlaubt sind nur die aus JOB_ALLOWED_ARGS (heute einzig rekey --dry-run). Exit-Code 0 Lauf erfolgreich, 1 Lauf gescheitert (auch wenn er gar nicht erst aufgezeichnet werden konnte, etwa bei einem Modul- oder DI-Fehler – der Kontext startet mit abortOnError: false), 2 falscher Aufruf (kein oder unbekannter Job oder ein nicht erlaubtes Argument wie cleanup --dry-run oder rekey --dry-rn – dann wird nichts verbunden und kein JobRun geschrieben). Shutdown-Hooks gibt es keine: Ein abgebrochener Pod hinterlässt seinen Lauf als RUNNING, cleanup erkennt ihn (siehe unten). JobRegistry prüft beim Start, dass es zu jedem Namen genau einen Job gibt.
JobRun. JobRunService.run(job, work) schreibt je Lauf eine Zeile: RUNNING, danach SUCCEEDED mit result (nur Zähler, Schalter, kurze Namen) oder FAILED mit error aus loggableError(); finishedAt setzt immer die DB (now()), auch bei abgebrochenen Läufen. Ein gescheiterter Lauf benachrichtigt die Inhaber des Job-Rechts (JOB_FAILURE_PERMISSION: daily-evaluation und task-templates → automation:update, app-backup → backup:configure, cleanup und rekey → system:manage) mit „Job „“ fehlgeschlagen“ (WARNUNG, relatedType: 'JobRun', relatedId = Lauf). Höchstens eine solche Benachrichtigung je Job und UTC-Tag (DB-Uhr), sonst meldete die stündliche app-backup-Prüfung denselben Ausfall jede Stunde; Prüfung und Schreiben laufen in einer Transaktion hinter einem Advisory-Lock je Job. recent(limit, job?) und latestPerJob() lesen die Läufe für die System-Seite. SystemModule stellt JobRunService für API und Jobs bereit.
cleanup (täglich 04:00). Alle Altersvergleiche laufen in SQL gegen now(); PENDING- und PROCESSING-Einträge bleiben immer stehen.
- Läufe, die seit mehr als 6 h
RUNNINGsind (JOB_RUN_STALE_HOURS, weit über jederactiveDeadlineSeconds), werdenFAILEDmiterror: 'aborted (pod ended without result)'; je betroffenem Job geht die übliche Benachrichtigung raus, damit ein Job, der jedes Mal abgeschossen wird, nicht still scheitert. - Idempotenz-Einträge nach 90 Tagen und verbrauchte Tokens 1 h nach ihrem Ablauf (
IdempotencyCleanupService; die Stunde Puffer deckt Uhrabweichungen der api-Pods ab, die den Ablauf mit ihrer eigenen Uhr prüfen). ThrottlerHit-Zeilen, deren Fenster abgelaufen ist und die keine aktive Sperre tragen ("expiresAt" < now()und"blockedUntil"leer oder< now()); eine noch laufende Sperre bleibt so erhalten.DONE-Ereignisse undDELIVERED-Zustellungen nach 30 Tagen (occurredAtbzw.createdAt),FAILED-Einträge beider Tabellen nach 90 Tagen; die Deliveries eines Ereignisses gehen perON DELETE CASCADEmit. 30 Tage liegen bewusst über dem Nachholfenster der täglichen Auswertung, sonst würde ein gelöschterdedupeKeyein Vorkommen erneut erzeugen.JobRun-Zeilen nach 90 Tagen.
Das Ergebnis zählt jede Kategorie (idempotencyRecords, consumedTokens, throttlerHits, domainEvents.done, domainEvents.failed, webhookDeliveries.delivered, webhookDeliveries.failed, jobRuns.aborted, jobRuns.deleted).
task-templates (täglich 05:00) ruft TasksService.generateFromTemplates() auf und meldet { created }. Wegen des Compare-and-Swap je Vorlage entsteht jedes Vorkommen genau einmal, auch bei zwei Läufen oder parallel zur Lazy-Erzeugung vor GET /tasks.
daily-evaluation (täglich 04:30) führt feste Schritte in dieser Reihenfolge aus (DAILY_EVALUATION_STEP_ORDER, runDailyEvaluationSteps in src/jobs/daily-evaluation-steps.ts): phenology, time-events, autopilot, suggestions-expiry, digest. „Heute“ ist der Berliner Tag (berlinToday(), Annahme 41). Der Schritt phenology (Wetter, gemeldete Phasen) gehört zur Wetter-Stufe; sein Platz in der Reihenfolge steht schon fest. Ein scheiternder Schritt hält die übrigen nicht auf: Der Lauf endet FAILED mit allen Zählern (JobPartialFailure, JobRun.result bleibt erhalten), error nennt Schritt und Ursache (Daily evaluation failed for: time-events: TaskDue, bei Völkern autopilot: 2 colonies).
time-events(src/jobs/steps/time-events.step.ts) erzeugt die zeitbasierten Ereignisse. Regeln und Werte stehen insrc/jobs/daily-evaluation.rules.ts, eine Zeile je Typ inDAILY_EVALUATION_RULES; jede Regel nennt je Vorkommen das Ereignis und den Tag des fachlichen Ereignisses. Je Typ eine Transaktion; scheitert eine Regel, laufen die übrigen weiter, und der Schritt wirftJobPartialFailuremit den Zählern der anderen. Das Ergebnis zählt die neuen Ereignisse je Typ.WithdrawalEnded(AggregatTreatment, PayloadcolonyId,endedOn): Ende des Wartezeitfensters über den Fenster-Helfer (Berliner Tag, Abschnitt 11.8), nur mit Wartezeit. Die SQL-Kandidatenabfrage nimmt einen Tag Rand auf jeder Seite, der Helfer entscheidet.TaskDue(Task,dueOn):dueDateoffener Aufgaben (OPEN,IN_PROGRESS).QueenAged(Queen,hatchYear,colonyId): jeden Jobtag für lebende Völker, deren aktuelle KöniginACTIVEund gealtert ist (isQueenAged, Annahme 37); SchlüsselQueenAged:<queenId>:<Jahr>-01-01(anchorDay), also höchstens einmal je Königin und Jahr. Beim ersten Lauf nach dem Rollout bekommt jedes lebende Volk mit schon gealterter Königin einmal ein Ereignis.VarroaMeasurementDue(Colony,dueOn,lastMeasuredOn, optionalkind,anchor): am Fälligkeitstag ausvarroaMeasurementDue(Abschnitt 11.3).InspectionOverdue(Colony,dueOn,lastInspectedOn, optionalfirstOfYear): am Tag nachinspectionDueOn;dueOnim Payload ist der echte Fälligkeitstag, nicht der Ereignistag (Abschnitt 11.3). Eine feste Märzmeldung entfällt.- Die Ereignisse sind Tatsachen:
activeeiner Regel (INSPECTION_INTERVAL,SEASON_AUTOPILOT) unterdrückt nur Vorschläge, nicht das Ereignis. Es entsteht ein Ereignis je Fälligkeitstag; ein Wechsel des Abschnitts-Intervalls ergibt einen neuen Fälligkeitstag und damit ein zweites Ereignis. - Aufgezeichnet wird jedes Vorkommen, dessen Tag im Nachholfenster
[heute − 7, heute]liegt (CATCH_UP_DAYS), mitdedupeKey = "<type>:<aggregateId>:<YYYY-MM-DD>"(dailyDedupeKey). Jeder Lauf im Fenster wiederholt denselben Schlüssel, der eindeutigededupeKey(recordAll,skipDuplicates) hält ein Ereignis je Vorkommen: Ausfälle des Jobs bis 7 Tage werden nachgeholt, wiederholte Läufe erzeugen nichts. Wer länger überfällig ist, bekommt kein neues Ereignis; die Erinnerung tragen dann die Autopilot-Regeln A1 und A2. - Handler:
TaskDue→notifications.task-due;WithdrawalEnded→notifications.withdrawal-endedundautopilot.colony-sync;QueenAged,InspectionOverdue,VarroaMeasurementDue→autopilot.colony-sync.
autopilot(AutopilotStep): lädt Regeln und Saisonzustände einmal vorab (preloadFor) und gleicht dann jedes lebende Volk in einer eigenen Transaktion ab (syncColonyIn, Abschnitt 11.5). ZählerautopilotColonies,suggestionsOpened,suggestionsResolved. Scheitert ein Volk, läuft der Schritt weiter und endet mitJobPartialFailure.suggestions-expiry:OPENmitexpiresAtvor heute →EXPIRED, gibt dendedupeKeyfrei. ZählersuggestionsExpired; zweimal am selben Tag ändert nichts.digest: schreibt die Morgenübersicht (Abschnitt 11.9). ZählerdigestsWritten; ein Wiederholungslauf am selben Tag schreibt 0.
app-backup (stündlich) prüft, ob ein Backup fällig ist, und schreibt es dann: siehe Abschnitt 8, „Automatischer Zeitplan“.
rekey (nur von Hand, CronJobs apicore-job-rekey und apicore-job-rekey-dry-run mit suspend: true): siehe Annahme 22.
Tests. test/jobs.e2e-spec.ts ruft runCli([...]) mit dem echten JobsModule auf, wie node dist/cli.js. test/daily-evaluation.e2e-spec.ts ruft TimeEventsStep.run(Tag) mit Tagen in den Jahren 2001 und 2002 auf: Der Schritt wertet die ganze geteilte Test-DB aus, so bleiben die Ereignisse paralleler e2e-Dateien unberührt (und es entstehen keine JobRun-Zeilen, die jobs.e2e-spec.ts zählt). test/rekey.e2e-spec.ts setzt nur *_PREVIOUS, weil der Job jede Zeile der geteilten Test-DB besucht und die anderen e2e-Prozesse mit denselben aktuellen Schlüsseln weiterlesen müssen.
System-Seite
Die Seite „Verwaltung > System“ (Rechte system:read zum Ansehen, system:manage zum Wiederholen; nur Systemrollen) zeigt je Job den letzten Lauf (JobRun), die Warteschlange der Domänenereignisse (offen, in Arbeit, fehlgeschlagen, Alter des ältesten offenen Eintrags nach DB-Uhr) und die offenen und fehlgeschlagenen Webhook-Zustellungen. Routen: GET /system/overview, GET /system/job-runs?job=&limit=1..100 (Standard 50), GET /system/domain-events/failed, POST /system/domain-events/:id/retry (204), GET /system/webhook-deliveries/failed, POST /system/webhook-deliveries/:id/retry (204). Wiederholen ist nur für FAILED möglich (409 sonst, 404 unbekannt): Versuche und availableAt werden in SQL (now()) zurückgesetzt, fehlgeschlagene Handler-Zustellungen eines Ereignisses wieder PENDING, ein Audit-Eintrag (UPDATE) geschrieben und der Worker per pg_notify geweckt. Die Ansichten enthalten nie Payload, Webhook-Body, Secrets oder vollständige URLs (nur den Host); lastError steht wie gespeichert (loggableError()). API-Schlüssel erreichen /system nie (Scope-Allowlist).
11. Logik-Ketten und Saison-Autopilot (T4a)
Regeln, Ketten und der Autopilot schreiben Vorschläge statt Aufgaben; ein Mensch übernimmt oder verwirft. Alles hängt am Ereignis-Rückgrat (Abschnitt 10, Handler-Übersicht) und am Job daily-evaluation. Entwurf: Spec docs/superpowers/specs/2026-10-13-t4-logik-ketten-design.md. Nicht Teil dieser Lieferung sind die Bestätigung beim Wassergehalt (Regel HONEY_WATER_CONTENT, T4b), Wetter und gemeldete Pflanzenphasen, die Autopilot-Regeln A4–A10 und die Telegram-Zustellung (T4c).
11.1 Vorschläge
Modell. Suggestion hat u. a. Quelle (RULE, CHAIN, AUTOPILOT), ruleKey, Absicht, Art (PLAN, DO), Priorität, Status (OPEN, ACCEPTED, DISMISSED, RESOLVED, EXPIRED, OBSOLETE), dedupeKey, reasonCode, evidence, dueDate, expiresAt, requiredPermission, decidedAt und decidedByUserId. Absichten: INSPECT, SWARM_CHECK, QUEEN_CHECK, QUEEN_ACCEPTANCE, QUEEN_REPLACE, SPLIT_PLAN, VARROA_MEASURE, VARROA_RECHECK, VARROA_TREAT, SUMMER_TREATMENT, FEED, HONEY_SUPER, WINTER_CHECK, HEALTH_CHECK, FOLLOW_UP, STOCK_REORDER, CLEANUP. evidence trägt nur IDs, Zahlen und Tage. Anker, Dubletten und die Regel „eine offene Sache je Absicht“: Annahme 31.
PLAN und DO. PLAN hat mehrere Termine oder ist heute fällig; Übernehmen legt je Schritt eine Aufgabe an (Basisdatum aus dem Beleg plus Versatz, Titel aus PLAN_STEP_TITLES, ein Fensterschritt trägt „Zeitfenster bis TT.MM.“). Ein gewähltes Datum verschiebt den ganzen Plan, sodass Schritt 1 darauf liegt; ohne Plan und Datum ist die Aufgabe heute (Berliner Tag) fällig. DO ist genau eine Aufgabe und braucht ein Datum (eigenes oder dueDate des Vorschlags), sonst 400 „Bitte ein Datum wählen.“. „Als Aufgabe planen“ ist Übernehmen mit Datum. Titel und Beschreibung kommen aus renderSuggestion mit dem actionTemplate der Regel. Die Aufgabe entsteht über TasksService.createInTx (Audit, TaskCreated, danach Kalender und Webhook). sourceInspectionId wird nur gesetzt, wenn die Durchsicht aus dem Beleg noch existiert (unter FOR SHARE geprüft). Übernehmen für ein beendetes oder unbekanntes Volk: 409 „Das Volk ist nicht mehr aktiv.“; ein assignedUserId, der nicht existiert oder inaktiv ist: 400 mit issues.
Übergänge. Jede Entscheidung sperrt die Zeile Suggestion (Sammelrouten in id-Reihenfolge), liest „vorher“ danach, prüft den Status (409 { message: 'Der Vorschlag ist nicht mehr offen.', status }) und setzt ihn per Compare-and-Set in SQL gegen now(); der Audit-Eintrag (Suggestion UPDATE) steht in derselben Transaktion. Entscheidungen sperren nie das Volk; proposeIn sperrt zuerst die Volkszeile, dann die Regelzeile (LOCK_ORDER in row-lock.ts: Colony, Suggestion, Task, Queen, AutomationRule, …, NotificationPreference).
| von | nach | Auslöser |
|---|---|---|
| – | OPEN |
Handler, Job oder Abgleich (proposeIn) |
OPEN |
ACCEPTED |
„Übernehmen“ (legt Aufgaben an) |
OPEN |
DISMISSED |
„Verwerfen“ |
DISMISSED |
OPEN |
„Rückgängig“ oder „Wieder öffnen“, bis 30 Tage nach decidedAt; sonst 409 |
OPEN |
RESOLVED |
Bedingung entfällt (Nachführen, Abgleich, Kette), Aufräumen ausgeführt; gibt den Schlüssel frei |
OPEN |
EXPIRED |
Schritt suggestions-expiry; gibt den Schlüssel frei |
OPEN |
OBSOLETE |
Volk beendet (Abschnitt 11.7) |
Texte. Titel und Gründe werden beim Lesen aus reasonCode und evidence gerendert (renderSuggestion, src/suggestions/suggestion-texts.ts mit den Registern texts/rule-texts.ts, chain-texts.ts, autopilot-texts.ts, cleanup-texts.ts): höchstens 2 Gründe zu je 90 Zeichen, deutsch formatiert; ein doppelter Code im Register wirft, ein Test prüft jede Vorlage. Der actionTemplate einer Regel überschreibt nur den Titel. Eine Liste rendert alle Zeilen mit einer Abfrage je Modell (buildEvidenceLookup).
API (alle @Idempotent(); derselbe Idempotency-Key gibt die erste Antwort wieder, ein zweites Übernehmen mit anderem Schlüssel antwortet 409):
| Route | Recht | Zweck |
|---|---|---|
GET /suggestions |
tasks:read |
Liste; Filter status (Standard OPEN; DISMISSED nur 30 Tage nach decidedAt, Datenbankuhr), colonyId, apiaryId, limit 1–500 (200), offset; Reihenfolge dueDate (ohne Datum zuletzt), Priorität, createdAt, id |
GET /suggestions/summary |
tasks:read |
Zähler (offen, je Priorität); Liste und Zähler teilen den Sichtbarkeitsfilter in der Abfrage (Annahme 32) |
POST /suggestions/:id/accept, /bulk-accept |
tasks:create |
Übernehmen mit optional dueDate (JJJJ-MM-TT) und assignedUserId; Antwort { suggestion, tasks }; Sammelroute: 1–50 verschiedene IDs, alles oder nichts, 400 mit den DO-IDs ohne Datum (issues[{ field: 'ids', ids }]) |
POST /suggestions/:id/dismiss, /bulk-dismiss, /:id/reopen |
tasks:update |
Verwerfen (Sammelroute { dismissed }), Wieder öffnen (409, wenn für die Absicht schon etwas offen ist oder das Volk nicht mehr lebt) |
POST /suggestions/:id/apply-cleanup |
tasks:update |
Aufräumen (Abschnitt 11.7) |
Die Antworten sind die gerenderte SuggestionView; evidence, dedupeKey, requiredPermission, decidedByUserId, reasonCode und sourceEventId verlassen den Server nicht. Backup: Suggestion gehört zum versiegelten Bundle (Abschnitt 8).
Oberfläche. Seite /suggestions (Navigation „Arbeit“, Recht tasks:read, Zähler aus summary.open, 60 s Aktualisierung in Seitenleiste und „Mehr“). Gruppen je Stand (nach Name), dann „Betrieb“; gleiche Absicht je Stand ist eine Zeile („X · n Völker“). „Runde starten“ (nur INSPECT) beginnt eine Standrunde mit den fälligen Völkern in Code-Reihenfolge, „Alle übernehmen“ gibt es nur, wenn jede Zeile der Gruppe PLAN ist (ein bulk-accept, bei mehr als 50 IDs in Teilen; bei Teilerfolg meldet die Seite „n von m angelegt“). Die Seite arbeitet nur online: Offline zeigt sie „Keine Verbindung – nichts gespeichert.“. Übernehmen und Menü brauchen tasks:create, Verwerfen und Wieder öffnen tasks:update. Verwerfen lässt sich 8 s per Toast und dauerhaft über „Verworfene (30 Tage)“ rückgängig machen. Jede Aktion sendet einen eigenen Idempotency-Key; nach jeder Entscheidung, auch im Fehlerfall, werden suggestions, tasks und dashboard neu geladen; die Fehlertexte des Servers erscheinen deutsch (404: „Der Vorschlag ist nicht mehr da.“). Nach einer Entscheidung wandert der Fokus zur nächsten Karte. „Erfassen“ öffnet das Formular zur Absicht (captureHref: Varroa → /varroa/new, FEED → /feedings/new, WINTER_CHECK → /winter?colonyId=…&season=AUTUMN|SPRING ab Juli Herbst, sonst Frühjahr, alles andere /inspections/new). Das Abzeichen an Aufgaben heißt „Aus Vorschlag“.
11.2 Regel-Werte
Zu den 13 bisherigen Regeln kommen neun Trigger: INSPECTION_INTERVAL, QUEEN_CELLS_SWARM_CHECK, QUEEN_ACCEPTANCE_CHECK, SPLIT_SCHEDULE, COLONY_ENDED_CLEANUP, WITHDRAWAL_ENDED, HONEY_WATER_CONTENT, SEASON_AUTOPILOT, PHENOLOGY; VARROA_THRESHOLD behält den Namen, die Config wird zum Bänder-Format. Jede Regel hat einen Standard (default-rules.ts) und einen Parser (src/automation/rule-config/, Leser readRule). Ein gespeicherter ungültiger Wert fällt auf den Standard zurück. Eine alte Varroa-Config { thresholdMitesPerDay } wird als naturalFall.redAbove gelesen (liegt der Wert unter 5, sinkt das Gelb auf den Rotwert). PATCH /automation-rules/:id läuft in einer Transaktion unter der Zeilensperre der Regel, prüft die Config und antwortet bei ungültigen bekannten Schlüsseln mit 400 { message: 'Die Einstellung ist ungültig.', issues: [{ field, message }] } (Pfad-Notation a.b[1].c). Unbekannte Schlüssel bleiben erhalten. Für null gilt Annahme 20. Verletzt eine Gruppe wie PHENOLOGY.calendarFallback oder swarmSeason ihre Grenzen, fällt die ganze Gruppe auf den Standard; ein fehlerhafter Eintrag in einer SPLIT_SCHEDULE-Liste setzt diese Liste zurück.
Verwaltung > Automatisierung hat für die zehn Regeln mit eigenen Werten je ein Formular. Jede Zahl zeigt ihre Quelle als Text („LWG Veitshöchheim“, „Bieneninstitute (LWG, LLH Kirchhain)“, „Erfahrungswert“, „Annahme“). Da der Server beim PATCH die ganze Config ersetzt, sendet das Formular die vollständige Config (gespeicherte Werte über den Standards, nicht bearbeitete Teile unverändert); 400-issues erscheinen unter dem Feld, Meldungen ohne Feld als Hinweis. Alle Regeln mit Meldung haben den Schalter „Zusätzlich melden“ (notify).
11.3 Saison und Pflanzenentwicklung
SeasonService.statesFor liefert je Stand den Zustand aus der Regel PHENOLOGY (SeasonModule). Heute entsteht der Abschnitt allein aus dem Kalender-Rückfall (Annahme 36; ein nicht aufsteigender calendarFallback fällt auf den Standard). springSince ist der Tag, an dem SALWEIDE dieses Jahres erreicht ist, sonst null.
- Durchsicht fällig (
inspectionDueOn): letzte Durchsicht (ohne Eintrag die Anlage des Volkes) plus das Intervall des heutigen Abschnitts ausINSPECTION_INTERVAL(Schwarmzeit 7 Tage; die Oberfläche prüft 7–9 Tage für die Schwarmzeit, sonst 7–35, der Server entscheidet). Wer vor Beginn vonFRUEHJAHRzuletzt durchgesehen wurde, ist ab Abschnittsbeginn fällig („Erste Durchsicht im Jahr“). InWINTERRUHEist nie etwas fällig. Fällt der Termin in den Winter, rückt er auf den Frühjahrsbeginn. - Varroa-Messung fällig (
varroaMeasurementDue): letzte Messung (sonst Anlage) plusvarroaMeasureIntervalDays(28); inWINTERRUHEeinmal im Fenster 15.11.–20.12., wenn seit 01.11. nicht gemessen wurde (Winterfenster = Annahme nach LWG-Konzept, Restentmilbung im brutfreien Volk). Ein Fälligkeitstag, der in den Winter gefallen ist, rückt aufspringSince. - Die Tagesauswertung und die Autopilot-Regeln A1/A2 nutzen dieselben Funktionen (Abschnitt „Jobs“, 11.5).
11.4 Varroa-Ampel nach Jahreszeit
Quelle der Grenzen ist die LWG Veitshöchheim; die Zahlen stehen als Bänder in der Regel VARROA_THRESHOLD und sind änderbar (src/automation/varroa-bands.ts).
- Natürlicher Milbenfall, März–Oktober: gelb ab 5, rot über 10 Milben/Tag; die Grenze selbst ist gelb (10,0 gelb, rot erst darüber). November–Februar: rot über 0,5, kein Gelb. Die Gültigkeit ab März ist eine Annahme.
- Puderzucker und Waschmethode: Mai–Juli gelb ab 1 %, August–Oktober gelb ab 2 %, rot über 5 %; November–April keine Ampel. Drohnenbrutentnahme: nie eine Ampel.
- Jungvölker (
youngColonyTypes): die Werte des natürlichen Milbenfalls mal 0,5, im Winter also rot über 0,25; die Prozent-Bänder bleiben unverändert. - Behandlungseffekt: Von Behandlungsbeginn bis
(endDate ?? date) + recheckAfterDays(14) hat eine Messung keine Ampel. Der Text lautet „Behandlungseffekt – Nachkontrolle ab TT.MM.“. Alle Fenster (Wartezeit, Behandlungseffekt, Tagesregeln) rechnen übersrc/treatments/withdrawal-window.tsundtreatment-windows.tsin Berliner Tagen (Annahme 41). - Probe:
VarroaMeasurement.sampleBees(1–2000) leitet bei Puderzucker und WaschmethodecalculatedValue = Milben / Probe × 100ab, auf zwei Stellen gerundet (7203 Milben in 720 Tagen = 10 = gelb). Beim Ändern wird ein abgeleiteter Prozentwert gelöscht, wenn die Probe entfällt; ein von Hand gesetzter bleibt. Die Sammelerfassung erbt das Feld. - API:
GET /varroa-measurements/thresholds?colonyId=&date=liefert die Bänder des Tages samtpostTreatmentUntilundactive(Regel aus:bands {},active false); mitfromundto(höchstens 731 Tage, nicht zusammen mitdate) liefert sie zusammenhängende Segmente mit gleichen Bändern. Rechtvarroa:readodervarroa:create; der Behandlungshinweis ist damit bewusst für die Messung sichtbar (keine Standrechte im System). Unbekanntes Volk 404, ungültiger Tag 400. Die Route/varroa-measurements/thresholdund der alte Schwellen-Dienst entfallen.GET /colonies/:id/varroa-measurementsträgt je Messung einrating. - Regel: Nur rot (außerhalb des Behandlungseffekts) schlägt
VARROA_REDvor, jetzt auch für die Prozent-Methoden.VARROA_TREATentsteht nur aus dieser Regel, nicht aus dem Autopilot. - Oberfläche: Die Erfassung zeigt Wert, Wort (Gut, Achtung, Kritisch) und Grenze mit Quelle in einem durchgehenden Statusbereich; das Probenfeld (Chip „500 Bienen (≈ 50 g)“, nie vorbelegt, Zeitraum entfällt bei Probe-Methoden) prüft 1–2000 vor dem Senden und der Warteschlange. Der Trend zeichnet die Bänder als Stufenlinien (eine Bereichsabfrage für bis zu 24 Monate; Messungen liegen am Berliner Kalendertag). Die Bänder-Anfragen (
["varroa","thresholds"]) werden nach Behandlungsänderungen und dem Speichern der Regel neu geladen. Offline gibt es keine Bänder, nur den Wert.
11.5 Saison-Autopilot und „Die nächsten Schritte“
Abgleich. AutopilotService.syncColonyIn(tx, colonyId, today) (AutopilotModule) sperrt die Volkszeile, liest die Eingaben in fester Abfragefolge (loadAutopilotInputIn) und die Regeln (SEASON_AUTOPILOT, INSPECTION_INTERVAL, VARROA_THRESHOLD nur recheckAfterDays, PHENOLOGY). Ein beendetes Volk wird übersprungen (das Aufräumen ist suggestions.colony-ended). Ist SEASON_AUTOPILOT aus, werden alle offenen Autopilot-Vorschläge RESOLVED („Autopilot aus“). Sonst: Offene Autopilot-Vorschläge, deren dedupeKey nicht mehr vorkommt, werden RESOLVED („Bedingung entfällt“); bei gleichem Schlüssel frischt refreshIn Belege, Fälligkeit, Priorität und reasonCode auf; ein neuer Kandidat geht über proposeIn (verworfen, wenn für das Volk schon etwas derselben Absicht offen ist). Regel- und Kettenvorschläge fasst der Abgleich nicht an. Kandidaten kommen immer in der Reihenfolge A1 bis A10; rules.A<n> = false schaltet eine Regel ab, INSPECTION_INTERVAL.active = false nur A1. Der Handler autopilot.colony-sync und der Job-Schritt autopilot rufen ihn auf (Abschnitte 10 und „Jobs“).
Regeln A1–A3 (src/autopilot/autopilot-rules.ts, reine Funktion evaluateAutopilot; A4–A10 folgen später):
| Nr | Absicht | Wann | Anker | Fälligkeit, Priorität |
|---|---|---|---|---|
| A1 | INSPECT |
Abschnitt ≠ WINTERRUHE, inspectionDueOn ≤ heute + 2 (INSPECTION_DUE, bzw. INSPECTION_FIRST_OF_YEAR) |
ID der letzten Durchsicht oder none |
inspectionDueOn; überfällig = HIGH, sonst NORMAL |
| A2 | VARROA_MEASURE |
varroaMeasurementDue ≤ heute (VARROA_MEASURE_DUE, im Winterfenster VARROA_WINTER_MEASURE) |
ID der letzten Messung oder winter-<Jahr> |
Fälligkeitstag; wird von A3 unterdrückt, solange A3 gilt |
| A3 | VARROA_RECHECK |
Behandlungsende + recheckAfterDays ≤ heute, keine Messung nach dem Ende, Ende höchstens 60 Tage her (Annahme 38); requiredPermission varroa:read |
Behandlungs-ID | Tag, ab dem die Bedingung gilt |
Als „letzte Behandlung“ zählt das späteste Ende (endDate ?? date); eine Messung am Endetag zählt nicht, am Tag danach schon. Ablauf expiresAt siehe Annahme 38.
Nächste Schritte. NextStepsService liefert GET /colonies/:id/next-steps (ColonyNextSteps, höchstens maxStepsPerColony = 3) und GET /autopilot/today (AutopilotToday: dringlichster Schritt über alle lebenden Völker mit demselben Vergleich, fällige Durchsichten je Stand, Zahl offener Vorschläge, Abschnitt je Stand); beide colonies:read, 404 bei unbekanntem Volk. Die Zahl der Abfragen ist unabhängig von der Zahl der Völker.
- Schritte = offene Aufgaben (
OPEN,IN_PROGRESS) plus sichtbare offene Vorschläge, nur mittasks:read(ohne: keine Schritte). Ein offener Vorschlag entfällt, wenn für das Volk eine offene Aufgabe derselben Absicht besteht. - Rang (deterministisch): Dringlichkeit (
IN_PROGRESS, überfällig oder heute = 0; in ≤ 3 Tagen = 1; ≤ 14 = 2; später = 3; ohne Datum = 4; ohne Datum undURGENT= 0), Priorität, feste Absichtsreihenfolge (INTENT_ORDER:VARROA_TREAT,QUEEN_CHECK,QUEEN_ACCEPTANCE,SWARM_CHECK,SPLIT_PLAN,FEED,INSPECT,VARROA_RECHECK,HEALTH_CHECK,SUMMER_TREATMENT,VARROA_MEASURE,WINTER_CHECK,HONEY_SUPER,QUEEN_REPLACE,FOLLOW_UP,CLEANUP,STOCK_REORDER; eine Aufgabe ohne Vorschlag zählt wieFOLLOW_UP), Fälligkeit,createdAt,id. Dringlichkeitsstufen im Schritt:OVERDUE,TODAY(auch eine laufende Aufgabe),SOON,LATER,NONE. Zeitstempel zählen als Berliner Tage. - Zusammenlegen (Tabelle nach Absicht, nicht nach Formular): Die Durchsicht-Familie (
INSPECT,SWARM_CHECK,QUEEN_CHECK,QUEEN_ACCEPTANCE) und die Varroa-Familie (VARROA_MEASURE,VARROA_RECHECK) werden je Volk zu einem Schritt, wenn mindestens ein MitgliedDOist. Alle anderen Absichten (z. B.HONEY_SUPER,FEED,VARROA_TREAT,WINTER_CHECK) bleiben eigene Schritte, auch wenn sie dasselbe Formular öffnen; Aufgaben legen nie zusammen. Der Schritt sitzt beim bestplatzierten Mitglied, trägt die früheste Fälligkeit, zeigt höchstens zwei Gründe (das spezifische Mitglied zuerst), Titel „ mit “ (z. B. „Durchsicht mit Schwarmkontrolle“) undsuggestionIdsaller Mitglieder;dismissibleist wahr, wenn ein Mitglied ein Vorschlag ist (Verwerfen sendet alle IDs). - Aktion:
CAPTURE(Formular-Link; auch Ersatz, wenntasks:createfehlt und die Absicht ein Formular hat),ACCEPT(nur mittasks:create),CLEANUP,OPEN_TASK(/tasks). Ohne mögliche Aktion entfällt der Schritt.withdrawalUntil(nur mittreatments:read) undinspectionDueOn(nur mitinspections:read,nullbei abgeschalteter Regel oder in der Winterruhe) sind Hinweise, keine Schritte. Das Erfassen einer Durchsicht erledigt Ketten-Vorschläge (chains.queen-cells) und führt Regel-Vorschläge nach (Annahme 12). GET /apiaries/:id/colony-overviewträgt je Volk zusätzlichoccupiedFrames,inspectionDueOn,segment(odernull) undnextStepTitle; der Offline-Pack enthältnextStepsje Volk.
Oberfläche. Volksseite: „Als Nächstes“ (bis 3 Schritte mit Gründen, Fälligkeitschip, Aktion, Verwerfen; „Wartezeit bis TT.MM.“ als Hinweis; Fehlerzustand mit Wiederholen). Startseite „Heute“: der dringlichste Schritt, Karten „Durchsicht fällig – “ mit Saison-Chip und „Runde starten“, Chips „Vorschläge “ (nur mit tasks:read) und „: “; bei Fehler, offline oder ohne Recht greift die bisherige Aufgabenlogik. Stand-Seite: je Volk „Durchsicht fällig in 2 Tagen“ und „Als Nächstes: …“. Offline zeigt die Volksseite die Schritte aus dem Paket nur lesend (ohne Übernehmen, Verwerfen, Aufräumen). Aufräumen öffnet das Sheet aus Abschnitt 11.7; Übernehmen aus Schritten zeigt „Aufgabe angelegt“.
11.6 Biotechnik-Ketten
Drei Ketten schlagen die nächsten Prüfschritte vor (Handler chains.*, Tabelle in Abschnitt 10; Vorschlagsquelle CHAIN, Art PLAN, Priorität NORMAL, Meldung nur bei notify der Regel). Titel der Schritte (PLAN_STEP_TITLES): „Nachschaffungszellen prüfen“, „Begattung prüfen“, „Stifte prüfen“, „Annahme der Königin prüfen“. Gründe: QUEEN_CELLS_SWARM_CHECK, QUEEN_ASSIGNED_ACCEPTANCE, SPLIT_SCHEDULE_RAISE_OWN („Ableger-Plan übernehmen“), SPLIT_SCHEDULE_QUEEN_CELL („Stifte prüfen“), SPLIT_SCHEDULE_QUEEN.
- Das Teilen eines Volkes (
ColoniesService.split) nimmtdate(JJJJ-MM-TT, nicht in der Zukunft: 400 „Das Datum liegt in der Zukunft.“; Standard heute) undqueenSource(RAISE_OWN,QUEEN_CELL,QUEEN). Die Herkunft der Königin ist eine Beobachtung und wird nie vorbelegt; ohne sie entsteht keine Kette („Weiß ich noch nicht“). Die Oberfläche fragt Datum und Herkunft im Teilen-Dialog und nennt die Folge („Es entsteht ein Plan: Zellen prüfen, Begattung, Stifte.“). - Ein Ableger mit
queenSource = QUEENund eine spätere Zuordnung (QueenAssigned) haben dieselbe AbsichtQUEEN_ACCEPTANCE: Wer zuerst behandelt wird, gewinnt, der zweite schlägt nichts vor. chains.queen-cellsräumt bei jeder Durchsicht eines lebenden Volkes erledigte Ketten-Vorschläge ab, auch wenn die Regel abgeschaltet ist (er schließt nur); eine rückdatierte Durchsicht löst nichts auf (ein offenerSWARM_CHECKlöst sich nur, wenn seine Basis-Durchsicht älter ist als die aktuelle).- Der Plan der Kette
SPLIT_SCHEDULEkommt aus den Schritten der Variante; das früheste Datum istsplitOn+ kleinster Versatz.
11.7 Volk beendet und Aufräumen
Jeder Weg, auf dem ein Volk endet (Auflösen, Vereinigen, Bearbeiten auf SOLD/DECEASED, Auswinterungsverlust mit markColonyDeceased), beendet in derselben Transaktion die Königin-Zuordnung über releaseQueenIn (src/colonies/colony-lifecycle.ts): Die offene QueenColonyHistory bekommt endDate, Colony.currentQueenId wird null, der Status der Königin ändert sich nicht. Das Enddatum ist der Berliner Tag der Änderung (bei der Auswinterung das Datum der Prüfung). Beim Bearbeiten greift das nur beim Übergang von lebend nach beendet. Ein Wächter (colony-end-releases-queen.spec.ts, TypeScript-Compiler-API) fordert, dass jede Stelle, die einen beendeten Status schreibt, vorher releaseQueenIn( aufruft. ColonyEnded trägt { status, endedOn, releasedQueenId, via } (via: DISSOLVE, MERGE, EDIT, WINTER_CHECK); ältere Ereignisse ohne diese Felder fallen auf den Berliner Tag des Ereignisses und EDIT zurück.
Der Handler suggestions.colony-ended setzt offene Vorschläge des Volkes auf OBSOLETE und legt den Aufräum-Vorschlag an (Regel COLONY_ENDED_CLEANUP; Quelle CHAIN, Absicht CLEANUP, Art DO, Priorität NORMAL, colonies:read). Anker ended-<endedOn>-<Ereignis-ID>, Beleg { colonyId, status, endedOn, via, openTaskIds, queenId }; die Königin zählt nur, solange sie ACTIVE ist. Wird ein Volk reaktiviert (reactivate), werden seine offenen Aufräum-Vorschläge OBSOLETE; endet es am selben Tag erneut, entsteht ein neuer.
POST /suggestions/:id/apply-cleanup (CleanupController/CleanupModule, nur API, weil QueensService den Webhook-Dienst braucht und JobsModule ihn nicht hat) prüft zuerst die Rechte (Aufgaben „Erledigt“ brauchen tasks:complete, die Königin queens:update) und läuft dann in einer Transaktion: Vorschlag sperren und lesen (404 unbekannt oder unsichtbar, 409 nicht offen oder Volk wieder aktiv „Das Volk ist wieder aktiv.“, 400 falsche Absicht), IDs prüfen (nur Aufgaben und Königin aus dem Beleg, sonst 400 „Nur Aufgaben und Königin aus dem Vorschlag.“), Aufgaben gesperrt je CANCEL (TasksService.cancelIn), DONE (completeIn, wie eine normale Erledigung) oder KEEP (eine nicht mehr offene Aufgabe zählt als behalten; fremde relatedType ebenso), Königinnenstatus über QueensService.updateStatusIn (409, solange ein Volk sie noch hält und der neue Status sie aus dem Betrieb nimmt), dann resolveIn(…, 'Aufgeräumt'). Kalender und Webhooks folgen aus den Aufgaben-Ereignissen.
Oberfläche: Das Aufräum-Sheet (von Vorschlagsliste, Schritten und „Heute“) listet die offenen Aufgaben (Standard „Abbrechen“, umschaltbar auf „Erledigt“ oder „Behalten“) und den Königinnenstatus („verstorben“ bei DECEASED, „verkauft“ bei SOLD, sonst unverändert); ein Knopf „Aufräumen“. Ohne tasks:complete ist „Erledigt“ gesperrt, ohne queens:update entfällt die Königin, jeweils mit Hinweis. Offline gibt es das Aufräumen nicht.
11.8 Wartezeit-Kette
Ein einziger Fenster-Helfer (src/treatments/withdrawal-window.ts, Annahme 10) bestimmt die Wartezeit. Die Prüfung läuft bei Ernte, Abfüllung und nachträglich erfasster oder geänderter Behandlung (Schleuderung folgt mit T4b).
- Warnungen.
POST/PATCH /harvestsund/bottling-batchesantworten mitwarnings(FormatPlausibilityWarning: CodeWITHDRAWAL_ACTIVE, StufeWARNUNG; StufenHINWEIS,WARNUNG,BESTAETIGEN) und nie mit 4xx wegen der Wartezeit. Text: „B3: Wartezeit nach Oxuvar endet am 20.08.2026.“. Überdecken mehrere Fenster den Erntetag, nennt die Warnung das längste. Die Prüfung läuft in der Transaktion nach dem Schreiben. - Meldung. Pro Ernte und Volk höchstens eine Meldung (WARNUNG, Kategorie
WITHDRAWAL, an Inhaber vonharvests:readundtreatments:read, Link/harvests/<id>, Schlüsselwithdrawal-conflict:<Ernte>:<Volk>), auch wenn Abfüllung oder Behandlung erneut warnen; die erste Meldung gewinnt. Wurde die Ernte schon abgefüllt, hängt sie „Bereits abgefüllt: Los L-1, L-2.“ an (Rückruf-Relevanz). Korrekturen, die aus dem Fenster herausführen, nehmen eine frühere Meldung nicht zurück. - Ende.
WithdrawalEnded(Jobtime-events) löstnotifications.withdrawal-endedaus: INFO „ ist wieder erntefähig“ (KategorieWITHDRAWAL,harvests:create+treatments:read, Link/colonies/<id>, Schlüsselwithdrawal-ended:<Behandlung>:<endedOn>). Der Handler schweigt, wennWITHDRAWAL_ENDED.notifyaus ist, das Volk nicht lebt, die Behandlung fehlt, ihr Ende nicht mehrendedOnist (veraltetes Ereignis) oder ein weiteres Fenster des Volkes an diesem Tag läuft. - Kalender. Die Ansicht bekommt den Typ
WITHDRAWALals Spanne (date= erster Tag,endDate= letzter Tag einschließlich, idwithdrawal:<Behandlung>), und zwar wie die Behandlungstermine nur mittreatments:read; eine ungültigefrom/to-Angabe antwortet 400. Der M365-Kalender bekommt einen ganztägigen Mehrtagestermin „Wartezeit · “ (date= erster Tag,endDateexklusiv = erster erntefähiger Tag); ohne Wartezeittage oder bei einem Fenster von null Tagen (withdrawalSpanOf) entfällt der Termin (EintragSKIPPED, ein vorhandenes Graph-Event wird gelöscht). Abschaltbar übersyncWithdrawal(Standard an). - Oberfläche. Die Formulare zeigen Warnungen nach dem Speichern als Toast „Ernte gespeichert · n Warnung(en)“ bzw. „Abfüllung angelegt · n Warnung(en)“ mit einer Zeile je Warnung; Warnungen bleiben stehen, bis sie bestätigt werden („Verstanden“), Hinweise laufen aus. Offline gespeicherte Ernten haben keine Warnungen; die Abfüllung arbeitet nur online. Die Bestätigungsliste (
WarningList) ist für die Wassergehalt-Bestätigung vorbereitet. Der Kalender zeigt „Wartezeit“ an jedem Tag der Spanne.
11.9 Meldungen je Nutzer, Erinnerungen und Morgenübersicht
Meldungen. Annahme 39. GET /notifications (eigene, nach aktuellen Rechten gefiltert, höchstens 100), POST /notifications/read-all (vor :id/read deklariert), POST /notifications/:id/read (404 für fremde, unbekannte oder verborgene). Kategorien (NotificationCategory) u. a. SUGGESTION, SYSTEM, WITHDRAWAL, TASK_DUE, DIGEST; die Glocke zeigt das Kategoriewort, „Alle gelesen“ und öffnet interne link-Pfade (nur mit führendem /, nie // oder /\). Einstellungen je Nutzer in NotificationPreference: GET/PATCH /profile/notification-settings (@AnyAuthenticatedUser, API-Schlüssel erhalten 403): taskDueEnabled, digestEnabled, digestTime (05:00–12:00 einschließlich), Ruhezeit nur als Paar (beide leer oder beide gesetzt); telegramEnabled wird nie geschrieben. Profil > Benachrichtigungen bietet „Erinnerung je Aufgabe“, „Morgenübersicht“ (Uhrzeit, Standard 06:30) und „Ruhezeit von/bis“; gesendet wird nur Geändertes.
Erinnerung je Aufgabe. Handler notifications.task-due (RemindersModule, von AppModule, WorkerModule und JobsModule importiert) schreibt zu TaskDue je Empfänger eine Meldung (HINWEIS, TASK_DUE, Link /tasks, Schlüssel task-due:<Aufgabe>:<dueOn>:<userId>): „Heute fällig: “, bei einem Nachhol-Ereignis „Fällig seit TT.MM.: “. Empfänger ist der Zuständige, sonst alle aktiven Nutzer mit tasks:complete; immer nur mit tasks:read und eingeschaltetem taskDueEnabled (fehlende Einstellung = an). Nur offene Aufgaben (OPEN, IN_PROGRESS); eine inzwischen verschobene oder fällig-lose Aufgabe wird übersprungen.
Morgenübersicht. Der Schritt digest (DigestService.runFor) schreibt je aktivem Nutzer mit aktiver Rolle und digestEnabled höchstens eine Meldung je Tag (INFO, DIGEST, Link /dashboard, Schlüssel digest:<Tag>:<userId>). Zeilen nur mit dem jeweiligen Recht: Aufgaben „n Aufgabe(n) fällig, k davon überfällig.“ (tasks:read), „Durchsicht fällig: n Völker am Stand …“ (colonies:read und inspections:read), „n Vorschläge warten.“ (tasks:read, gezählt werden die zum Schreibzeitpunkt sichtbaren), „Stand …: Schwarmzeit beginnt. Durchsicht alle n Tage.“ (apiaries:read, nur am Tag des Abschnittsbeginns). Ohne Zeile keine Übersicht. requiredPermissions der Übersicht enthalten nur die Grundrechte der Zeilen. Der Inhalt hat den Stand 04:30; später angelegte Aufgaben kommen erst am nächsten Tag hinein, die Erinnerung je Aufgabe kommt trotzdem. Scheitert ein Nutzer, schreibt der Schritt die übrigen und der Lauf wird FAILED.
Job-Ausfall. Ein gescheiterter Lauf benachrichtigt die Inhaber des Job-Rechts (Abschnitt 10, Jobs) einmal je Job und Berliner Tag (Kategorie SYSTEM).
11.10 Futter, besetzte Waben und Übernahme Durchsicht → Volkskarte
Futter und Waben. Die Annahmen 33 und 34 beschreiben Vokabular und Wertebereich. CreateInspectionDto.feedStatus wandelt vor @IsEnum über feedConditionFromInput (src/common/feed-vocabulary.ts; Enum-Namen in jeder Schreibweise, drei alte Wörter, leerer oder blanker Text → null); UpdateInspectionDto erbt das. Die Stockkarte (PDF) druckt FEED_CONDITION_LABELS_DE. In der Oberfläche bietet die Durchsicht „Ausreichend“, „Niedrig“, „Kritisch“ und „Nicht geprüft“ (ein gespeichertes „Mittel“ oder „Reichlich“ bleibt wählbar), Volkskarte und Bearbeiten-Dialog das volle Vokabular; alte Wörter in Entwürfen, Paketen und Warteschlangen mappt feedConditionFromInput, ein nicht abbildbarer alter Text bleibt als Hinweis stehen und überschreibt den Wert beim Bearbeiten nicht. „Besetzte Waben“ (0–40) steht auf Volk (anlegen, bearbeiten, leeren mit null), Volkskarte, CSV-Export (letzte Spalte), CSV-Import und im Offline-Pack; die Volksseite zeigt „n besetzte Waben“, sonst „Volksstärke (alt): 7 von 10“. Das Feld „Volksstärke“ ist auf 40 begrenzt (Hinweis „Höchstens 40“); eine Warteschlangenmeldung 400 zeigt „Besetzte Waben: höchstens 40 – bitte im Eintrag korrigieren.“. Die Migration 20261014100200_t4_feed_vocabulary wird mit dem Test test/utils/migration-harness.ts geprüft, der in einem Scratch-Schema mig_<id> bis zu einer Migration migriert.
Übernahme. POST /inspections nimmt applyToColony (höchstens 3 verschiedene Werte aus occupiedFrames, broodStatus, feedStatus). Durchsicht und Übernahme laufen in einer Transaktion: Der Server sperrt zuerst das Volk, speichert die Durchsicht, prüft dann, ob es eine neuere gibt (Datum, createdAt, id), und plant die Übernahme (planColonyTakeover) allein aus der gespeicherten Zeile, nie aus der Anfrage; ColoniesService.updateIn(tx, …) schreibt (inklusive Historie, Ereignissen und Audit-Eintrag mit Vorher/Nachher). Die Antwort trägt colonyTakeover { applied, skipped[{ field, reason }] } (leer, wenn nichts verlangt wurde). Gründe in der Prüfreihenfolge: NO_PERMISSION (braucht colonies:update, die Anfrage antwortet trotzdem 201), COLONY_ENDED (die Durchsicht bleibt gespeichert), NEWER_INSPECTION (blockiert jedes Feld), dann je Feld NO_VALUE, OUT_OF_RANGE. Eine Korrektur (PATCH) übernimmt nie etwas (applyToColony dort: 400).
Oberfläche. Der Block „Volkskarte“ steht über „Durchsicht speichern“, nur mit colonies:update und nur, wenn Felder gefüllt sind: je Feld ein Kontrollkästchen mit „Futter: Ausreichend → Niedrig“, „Besetzte Waben: 6 → 9“, „Brut: Mittel → Gut“. Angehakt wird nur, was in dieser Durchsicht selbst eingegeben wurde (ein Wert aus „Letztes Mal“ hakt nie von allein an; Tippen auf das Feld hakt an, Leeren nimmt zurück, ein manuelles Abhaken bleibt). Gesendet wird applyToColony nur mit Recht und nur für gefüllte Felder. Online meldet ein Toast die Gründe („Volkskarte nicht übernommen: Es gibt eine neuere Durchsicht.“, „…: Das Volk ist beendet.“); gelungene Felder laden die Volks-Abfragen neu. Offline reiht die Warteschlange applyToColony mit ein und der Server entscheidet beim Sync still (NEWER_INSPECTION, COLONY_ENDED, NO_PERMISSION); „bisher“-Werte kommen aus der zwischengespeicherten Volksliste, ohne sie steht „bisher unbekannt“.