Files
ApiCore/ARCHITECTURE.md
T
Nick SzaunigandClaude Opus 5.5 ec8c95056d
Backend Image / build-and-push (push) Successful in 6m43s
Frontend Image / build-and-push (push) Failing after 12m42s
Manifests / validate (push) Successful in 18s
docs: README im Root, Backend/Frontend-READMEs, Statuszeilen (2026-10-16)
Neues README im Root mit Zweck, Aufbau, Start, Prüfbefehlen, Betrieb und
Projektstand (Issues #44, #43). Backend- und Frontend-README ersetzen die
Framework-Vorlagen. CLAUDE.md: Ordnerstruktur und echte Arbeitsweise
(main, gebündelt pushen, Rollout nur mit Freigabe). ARCHITECTURE §7/§8:
Live-ConfigMap, ensure-secrets.sh, korrektes Admin-Seed-Verhalten.
.env.example um BACKUP_MAX_IMPORT_BYTES und SEED_ADMIN_* ergänzt.
Statuszeilen in Plänen Rückgrat, T3–T6 und Specs Fahrplan, T7.

Refs #42

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 08:12:49 +02:00

315 KiB
Raw Blame History

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
VeterinaryMedicinesModule Tierarzneimittel-Katalog, Packungen, Bewegungsbuch (T6, Abschnitt 12.2); exportiert VeterinaryMedicinesService und VeterinaryMedicineLotsService; die Behandlung bucht direkt über ihr tx und importiert das Modul nicht (12.2) –
ComplianceModule Compliance-Einstellungen, Bestandsbild und Behördenanzeigen (T6, Abschnitt 12.8); restricted-zone.ts für den Sperrbezirk (12.7); ohne Provider an PermissionResolverService –
ApiaryRestrictionsModule Sperrbezirke je Stand (T6, Abschnitt 12.7); von ColoniesModule importiert –
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–A10), „Die nächsten Schritte“, „Heute“ Season, Suggestions
SeasonModule Saisonabschnitt, Fälligkeit von Durchsicht und Varroa-Messung, Alter der Königin –
PhenologyModule Saisonzustand je Stand (GTS, Phase, Kalibrierung), Job-Schritt phenology, Meldungen (API, Neuberechnung, Handler), Abschnitt 11.11 Season, Weather, Notifications
WeatherHistoryModule Wetterquelle (WeatherHistorySource) und Rückholdienst für die Worker-Schleife phenology-weather, Abschnitt 11.11 –
TelegramModule TelegramClient, TelegramSender (Zustellung, nur Worker), Abschnitt 11.13 Notifications
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").

API-Client und Service Worker. apiFetch und apiFetchFormData nehmen die Option refreshOn401: false: Ein 401 wird dann weder erneuert noch wiederholt, die deutsche Meldung des Servers kommt unverändert an. Alle Nachweis-Aufrufe (Step-up, Abschnitt 11.13) setzen sie (Annahme 56). Der Service Worker legt /api/v1/profile/telegram, /api/v1/profile/notification-settings und /api/v1/system nie im Cache ab (NEVER_CACHED_API_PATHS in public/sw-routes.js; eine Änderung hebt SW_ROUTES_VERSION in public/sw.js, aktuell der Hash 0681ff4b095d; ein Spec prüft, dass die Version zum Inhalt von sw-routes.js passt, jede Änderung an der Datei braucht also eine neue Version). Seit T6 kommen dazu: /api/v1/compliance (Prüfbereitschaft, Anzeigen-Einstellungen), authority-notices, apiary-restrictions, apiaries/:id/restrictions, veterinary-medicines, veterinary-medicine-lots, die Etikett-Prüfung und der Druck (label-templates/check, label-templates/:id/check…, label-templates/:id/print…; sie tragen die Anschrift des Lebensmittelunternehmers), das Einzeldokument documents/:id (die Art steht nicht im Pfad) und die Dokumentlisten der drei eingeschränkten Arten MEDICINE_LOT, AUTHORITY_NOTICE und APIARY_RESTRICTION (die Art steht in der Query /documents?entityType=…, daher prüft isCacheableApiUrl die URL samt Query; Listen der Durchsicht-Fotos und documents/:id/download bleiben offline verfügbar, das Präfix /api/v1/documents wird nicht als Ganzes gesperrt). Der globale Header Cache-Control: no-store des Backends ist bewusst kein Signal für den Worker: Jede API-Antwort trägt ihn (Abschnitt 9, noStoreMiddleware), ein Header-Check würde das gesamte Offline-Verhalten abschalten. isKeepableApiAnswer prüft nur Erfolg und JSON; ob etwas behalten wird, entscheidet allein der Pfad (Annahme 87).

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

  1. 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.
  2. Frontend hält Access-Token im Memory/React-Context, Refresh-Token als httpOnly-Cookie (Secure, SameSite=Lax, Pfad /api/v1/auth).
  3. 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ätestens SESSION_MAX_AGE (7 Tage) nach dem Login ist neu anzumelden.
  4. Guards in dieser Reihenfolge: ThrottlerGuard → JwtAuthGuard (Authentifizierung, Gültigkeit frisch aus der DB je Anfrage) → PermissionsGuard (Berechtigung, fail-closed).
  5. 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ängige package.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:

  1. 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 HoneyBatchesModule im MVP mitgebaut statt auf v1.x verschoben.

  2. Treatment auf Einzelvolk statt "Völkergruppe": Abschnitt 4.11 erlaubt "Volk oder Völkergruppe". MVP modelliert Treatment pro Volk (colonyId Pflichtfeld); eine Gruppenbehandlung wird im MVP als mehrere Treatment-Datensätze mit gleicher batchNumber/Notiz erfasst statt eigener Gruppen-Entität. Spätere Migration kann eine TreatmentGroup-Entität nachziehen, falls das in der Praxis zu unhandlich ist.

  3. 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.

  4. API-Client-Rolle im MVP: kein vollwertiger OAuth2/Client-Credentials-Server. Ein API-Client ist ein User mit Rolle API_CLIENT und einem langlebigen Service-Refresh-Token; vollwertiges Scopes-/Client-Management ist SOLL/KANN.

  5. Backup-fähige Struktur: reproduzierbare Prisma-Migrationen (kein manuelles Schema-Drift), anwendungsseitiges Backup (BackupModule, Abschnitt 8) und der CronJob apicore-db-backup mit pg_dump (Abschnitt 7), /health-Endpoint für Betriebsüberwachung.

  6. 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.

  7. Dokumentenspeicher als lokales Volume (Modul 29): DocumentsModule speichert Uploads unterhalb von DOCUMENTS_STORAGE_PATH auf 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 aktuelle deploy/k8s-Deployment läuft mit replicas: 2 ohne geteiltes Volume – für konsistente Uploads über beide Pods hinweg muss vor Produktivbetrieb mit Dokumenten-Feature ein ReadWriteMany-PVC (z.B. NFS- oder EFS-basiert) gemountet und DOCUMENTS_STORAGE_PATH darauf gesetzt werden, sonst sieht nur der Pod, der die Datei entgegengenommen hat, sie auch beim Download wieder. Seit T6 gilt für die Arten MEDICINE_LOT, AUTHORITY_NOTICE und APIARY_RESTRICTION zusätzlich zum Dokumentenrecht das Fachrecht des Datensatzes (Abschnitt 12.3, Annahmen 69 und 70).

  8. Entra ID SSO ohne Auto-Provisioning: POST/GET /api/v1/auth/sso/* implementiert Authorization Code + PKCE gegen Entra ID über openid-client. Der E-Mail-Claim aus dem ID-Token wird ausschließlich gegen einen bereits existierenden, aktiven User-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_verifier wird 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_URI kommen ausschließlich aus Environment-Variablen; fehlen sie, meldet GET /api/v1/auth/sso/status enabled: false und die Login-Seite blendet den "Mit Microsoft anmelden"-Button aus, statt beim Start zu scheitern.

  9. 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 von OIDC_ISSUER_URL abgeleitet, statt eine weitere Konfigurationsvariable zu benötigen. Die Übertragung ist bewusst nur einseitig (ApiCore → M365-Gruppenkalender, adressiert über groupId); Änderungen, die direkt im M365-Kalender vorgenommen werden, fließen nicht zurück. Statt eines synchronen Push im Request-Pfad nutzt CalendarSyncOutboxEntry ein Outbox-Muster, das die Worker-Schleife calendar-outbox abarbeitet (alle 60 s und auf pg_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 Eintrag FAILED bis 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. revision sorgt 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 in buildEventInput nichts mehr anzuzeigen. Hat der Eintrag eine graphEventId, 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 auf SKIPPED mit graphEventId = null. Die API ruft Graph dafür nie auf; die Einreihung selbst erfolgt in der Handler-Transaktion der Domain-Events (calendar-sync.*-Handler über CalendarSyncService.enqueueIn(tx, …)). Auch die Königin-Zuordnung (QueenAssigned → calendar-sync.queen-assignment) und die Wartezeit (calendar-sync.withdrawal, Abschnitt 11.8) laufen so; CalendarSyncService.enqueue und das Einreihen nach dem Commit gibt es nicht mehr, nur enqueueIn. 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 eine transactionId (aus Eintrag, Revision und ersetztem Event), damit Graph eine Wiederholung nach einem Timeout nicht doppelt anlegt; „Jetzt synchronisieren“ startet den Lauf im Hintergrund (202, running im Status) und setzt FAILED-Einträge auf PENDING zurück. groupId muss eine Entra-Objekt-ID (GUID) sein; "" oder null lö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ützt POST /groups/{id}/events keine Application-Permission, nur delegiertes Group.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.

  10. Wartezeit manuell statt aus Wirkstoff-Datenbank abgeleitet: Treatment.withdrawalPeriodDays wird 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, ohne at gilt 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 und getWithdrawalStatus rechnen alle darüber, in Berliner Kalendertagen (Annahme 41). Die Kette beschreibt Abschnitt 11.8. Auch bei der Schleuderung warnt die App (WITHDRAWAL_ACTIVE, Abschnitt 11.12). Diese Warnung sieht jeder, der eine Schleuderung erfasst, auch ohne treatments:read: Lebensmittelsicherheit geht vor der Lese-Trennung.

  11. 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 einen Idempotency-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 über dist/cli.js, nie als Timer im Prozess: @nestjs/schedule und ScheduleModule gibt es nicht mehr, der Wächter src/runtime/no-in-process-schedulers.spec.ts hält das fest. runAfterCommit (src/common/after-commit.ts, best-effort: loggt auf Error-Level, wirft nie) bleibt nur für die Automatik SWARM_MOOD_HIGH und STOCK_ITEM_LOW (AutomationService.evaluateSwarmControlCheck und evaluateStockItem) und den Webhook colony.created. Nachzügler für eine spätere Stufe, die weiter über WebhooksService.dispatch laufen: Königinnen (queen.created; queen.replaced kommt seit T4 aus QueenAssigned) und Lager (product.stock_changed); batch.created kommt seit T4b aus HoneyBatchRecorded (Abschnitt 10, Webhook-Zustellung), Schleuderungen und Honigchargen haben keine Post-Commit-Seiteneffekte mehr. Der Wächter src/common/after-commit-usage.spec.ts hält die Aufrufer fest. Die früheren AutomationService.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 Spec docs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md.

  12. 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); optional config.dueInDays je 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_EGGS und FEED_STATUS_CRITICAL die Durchsicht-ID; VARROA_THRESHOLD die Mess-ID (nur rot, nicht im Behandlungseffekt, Abschnitt 11.4); SWARM_MOOD_HIGH die Kontroll-ID; COLONY_HEALTH_CRITICAL die Ereignis-ID; STOCK_ITEM_LOW die jüngste IN-Bewegung (sonst none); RAW_MATERIAL_LOW die jüngste Charge der Sorte (sonst none); TREATMENT_FOLLOW_UP und FEEDING_FOLLOW_UP die 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, mit suggestionId) entstehen in einer Transaktion: Scheitert ein Teil, entsteht nichts davon. Eine Meldung (Kategorie SUGGESTION, WARNUNG, bei URGENT KRITISCH) schreibt ein Vorschlag nur, wenn die Regel config.notify hat. Empfänger sind Inhaber von tasks:read und 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 Webhook task.created (webhooks.forward) folgen erst aus TaskCreated beim Übernehmen; die Regel ruft WebhooksService nicht selbst auf. Eine neuere Durchsicht führt die offenen Vorschläge von QUEENLESS_NO_EGGS und FEED_STATUS_CRITICAL nach: Zeigt die tatsächlich jüngste Durchsicht des Volkes den Befund nicht mehr, wird der Vorschlag RESOLVED („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ür VARROA_THRESHOLD bei VarroaCorrected. Eine abgeschaltete Regel lässt offene Vorschläge unberührt. Ausgewertet wird in den Handlern automation.* (src/automation/automation.handlers.ts) in der Zustell-Transaktion, bei Fehler mit Wiederholung; sie rufen evaluate…In(tx, …), überspringen beendete Völker und prüfen unter der Volkssperre erneut. Nur SWARM_MOOD_HIGH und STOCK_ITEM_LOW laufen dauerhaft über runAfterCommit in eigener Transaktion (Annahme 11); sie rufen dieselben evaluate…In, werfen aber nie.

  13. API-Schlüssel für Maschinen-Zugriffe: Integrationen (Wabenhain-Shop, ERPNext) nutzen einen Schlüssel ack_… im Authorization: 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: JwtAuthGuard setzt request.apiKey statt request.user, PermissionsGuard lä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 mit api-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:read bzw. shop-sync:write; erp-inventory:read zählt als Lesen, Buchungsrecht ist nur shop-sync:write, Liste API_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.

  14. Lebende vs. beendete Völker: Nur DISSOLVED, SOLD, DECEASED, MERGED sperren Umsetzen/Teilen/Vereinigen/Königinzuweisung (colonies/colony-status.ts). WEAK, CONSPICUOUS, QUEENLESS bleiben bearbeitbar.

  15. Bestandsbuch aus Behandlungen, nicht als eigenes Register: Das Bestandsbuch nach Art. 108 VO (EU) 2019/6 wird aus Treatment erzeugt (export/treatment-log.ts); Lieferant, Erwerbsbeleg und verschreibender Tierarzt sind Freitextfelder der Behandlung, Name/Anschrift des Halters liegen in SiteSettings. Der Stand am Behandlungstag wird beim Anlegen als Treatment.apiaryId festgehalten (Umsetzen ändert ihn nicht nachträglich); für Altbestand wird er aus ColonyApiaryHistory abgeleitet und im PDF bei Unsicherheit markiert. Fehlende Pflichtangaben werden als „FEHLT“ gedruckt statt still weggelassen. Keine Wartezeit-Automatik (siehe 10). Ergänzung T6: Wird eine Behandlung aus einer Packung erfasst, kommen Lieferant, Beleg, Ch.-B. und Tierarzt live aus der Packung und stehen nie als Kopie auf der Behandlung (Abschnitt 12.2).

  16. Ü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 mit markColonyDeceased beendet auch die Königin-Zuordnung und schreibt ColonyEnded mit via: WINTER_CHECK (Abschnitt 11.7).

  17. 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.

  18. 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.

  19. 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 aus JWT_ACCESS_SECRET) bleiben lesbar, solange JWT_ACCESS_SECRET unverä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-Liste LEGACY_UNSEALED_TABLES ergä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, Pflanzenentwicklungs-Meldungen 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.

  20. Bearbeiten-Regel für null (Grundsatz „alles bearbeitbar“): In jeder Bearbeiten-Anfrage bleibt ein weggelassenes Feld unverändert, null leert ein Feld, das leer sein darf, und null auf einem Pflichtfeld wird mit 400 abgelehnt (statt still ignoriert oder als 500). Umgesetzt über PartialType(..., { skipNullProperties: false }) in den Update-DTOs, @IsOptionalNotNull() (src/common/validation/is-optional-not-null.ts) für weglassbare, aber nicht leerbare Felder und optionalDate() (src/common/patch-values.ts) für leerbare Datumsfelder; update-dto-null-handling.spec.ts hält den Vertrag je DTO fest. Das Frontend sendet geleerte optionale Felder über emptyToNull (ApiCore-Frontend/src/lib/forms/empty-to-null.ts) als null. Für Regel-Configs gilt dasselbe Muster: null löscht einen optionalen Schlüssel (z. B. dueInDays, windowDays, minGrams, einzelne GTS-Schwellen), ein Pflichtschlüssel (z. B. queenMaxAgeYears, notify, die Gruppe swarmSeason) lehnt null mit 400 ab (Abschnitt 11.2). Neu leerbar sind Colony.occupiedFrames, VarroaMeasurement.sampleBees und Inspection.feedStatus.

  21. Lebende Völker: „Lebend“ heißt jeder Status außer DISSOLVED, SOLD, DECEASED, MERGED – also auch WEAK, CONSPICUOUS, QUEENLESS. Backend-Abfragen filtern über LIVING_COLONY_WHERE (colonies/colony-status.ts), das Frontend über isLivingColony (lib/colonies/status.ts); status = ACTIVE steht nur dort, wo wirklich genau dieser Status gemeint ist.

  22. 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 von JWT_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 aus JWT_ACCESS_SECRET zurück. Format k<8 hex>:<iv>.<tag>.<data> (Base64-Teile, AES-256-GCM, 128-Bit-Tag Pflicht); AES-Schlüssel = HKDF-SHA256(Secret, Salt apicore-key-ring, Info apicore-<zweck>-v1), Schlüssel-ID = k + die ersten 8 Hex-Zeichen von sha256(Schlüssel). Backups behalten Dateiformat v3 und ihre bisherige Ableitung (keyId 12 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 gesetztes OIDC_CLIENT_SECRET und das Passwort in DATABASE_URL; auch *_PREVIOUS muss von den JWT-Secrets verschieden sein. Außerhalb der Produktion meldet jeder Prozess einmal je Variable, dass ein aus JWT_ACCESS_SECRET abgeleiteter Entwicklungsschlüssel einspringt.

  23. Öffentliche Herkunftsseite ohne Standort: Sie nennt nur Apiary.publicLocationLabel (je Stand, z. B. „Nordhessen“) und SiteSettings.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), Job rekey laufen lassen; danach muss rekey --dry-run bei rekeyed, plaintextSealed, changedMeanwhile und failed 0 melden, erst dann *_PREVIOUS entfernen. Backup-Dateien werden nicht umgeschlüsselt: BACKUP_ENCRYPTION_KEY_PREVIOUS bleibt gesetzt, solange ältere Dateien einspielbar sein sollen. Altchiffrate sind nur lesbar (nie geschrieben), und nur, solange JWT_ACCESS_SECRET unverändert ist: MFA-Secrets ohne Präfix und Webhook-Secrets mit enc:v1: (beide AES-Schlüssel sha256(JWT_ACCESS_SECRET)), Webhook-Secrets im Klartext, Backup-Dateien mit JWT-abgeleiteter keyId. 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). Job rekey (src/jobs/rekey.job.ts, nur von Hand: kubectl create job --from=cronjob/apicore-job-rekey bzw. …-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 Webhooks secret), damit überschreibt der Job nie, was die App inzwischen geschrieben hat (z. B. eine neue MFA-Einrichtung). Solche Zeilen zählt er als changedMeanwhile; changedMeanwhile > 0 ist 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: dryRun und je Zweck mfa.*/webhook.* mit total, 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 bei failed > 0 immer FAILED (JobRun, Zähler im Fehlertext, das Log nennt nur Zeilen-ID und Grund, z. B. unknown key id). rekey --dry-run schreibt 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 als failed). Andere Argumente lehnen dist/cli.js (Exit 2) und der Job selbst ab. Werte, Chiffrate und Schlüssel-IDs erscheinen nie in Log oder JobRun. updatedAt umgestellter Zeilen ändert sich. Den Klartext-Rückfall in openWebhookSecret kann man entfernen, sobald der Dry-Run in Produktion webhook.plaintextSealed: 0 zeigt.

  24. 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 Volk occupiedFrames und die nächsten Schritte nextSteps, 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 mit invalidColonyIds), 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 als application/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/apiaries und /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).

  25. 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_CACHES mit 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“.

  26. 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 (mit createdAt, höchstens 3 Tage alt, je Benutzer) verhindert, dass ein bereits gespeicherter Entwurf samt Fotos ein zweites Mal gesendet wird. Offen: die Prüfung mit exiftool auf einem echten Telefon (auch älteres iOS Safari).

  27. Sammelerfassung am Stand: POST /treatments|feedings|varroa/bulk schreibt alles oder nichts in einer Transaktion (@Idempotent()). Die Völker werden sortiert mit lockRowsForShare (FOR SHARE, src/prisma/row-lock.ts) gesperrt, der Ablauf steckt im Helfer runBulk; das Ereignis je Volk trägt bulkId. Entscheidung K1: Die Chargennummer des Arzneimittels (batchNumber) wird einmal eingegeben und nie erzeugt (rechtlich relevantes Bestandsbuch); gruppiert wird über bulkId. 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 als invalidColonyIds. Der Idempotenz-Schlüssel bindet Benutzer, Schlüssel und Pfad, nicht den Inhalt: Der Client nimmt darum je abweichender Absendung einen neuen Schlüssel.

  28. 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 in colonyStrength und neu broodFrames, das Altfeld broodAmount nur im Bearbeiten-Dialog. colonyStrength zä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.

  29. Ampeln: Die Varroa-Ampel kommt vom Server aus Bändern nach Methode, Monat und Volkstyp (Abschnitt 11.4); VARROA_WARN_FRACTION gibt 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 bei QUEENLESS, kritischem Gesundheits-, Futter- oder Brutzustand oder offener kritischer Meldung; gelb bei WEAK/CONSPICUOUS, schwachem Zustand, offener Warnung oder überfälliger Durchsicht. „Überfällig“ heißt inspectionDueOn überschritten („Durchsicht seit 3 Tagen fällig“, Abschnitt 11.3); das ersetzt die festen 14 Tage INSPECTION_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. openWarnings und openCritical der Volksübersicht zählen offene Vorschläge der Priorität HIGH bzw. URGENT, die der Nutzer sehen darf (tasks:read und das Recht des Vorschlags), nicht mehr Benachrichtigungen. Die Farbe steht nie allein (Symbol und Wort); die Kontraste der Farbtöne prüft ein Test.

  30. 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 lang 429 (src/api-keys/failed-attempt-limiter.ts, Grenzen API_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-Tabelle ThrottlerHit (über PrismaThrottlerStorage: eigener Throttler-Name api-key-failures, Schlüssel-Präfix api-key-failures:, eine Upsert-Anweisung, nur DB-Uhr now()) 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.

  31. Vorschläge, Anker und Dubletten: Regel-, Ketten- und Autopilot-Vorschläge sind ein Modell mit den Arten PLAN und DO (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 ist sourceSuggestion.intent, sonst die der Altregel, sonst FOLLOW_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 nach decidedAt wieder öffnen. Systemübergänge (Auffrischen, Auflösen, Veralten, Ablaufen) schreiben keinen Audit-Eintrag, weil Status und decidedAt sichtbar 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.

  32. Vorschläge verbergen ihr Dasein: Liste, Zähler und nächste Schritte zeigen nur Vorschläge, deren requiredPermission leer ist oder vom Nutzer gehalten wird. Übernehmen, Verwerfen und Wiederöffnen eines unsichtbaren Vorschlags antworten 404 statt 403, damit weder Titel noch Dasein durchsickern.

  33. Futter-Vokabular: Inspection.feedStatus ist ColonyCondition wie auf der Volkskarte (EXCELLENT Reichlich, GOOD Ausreichend, AVERAGE Mittel, WEAK Niedrig, CRITICAL Kritisch). 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 → NULL und Futter (alt): <Text> wird nach einem Zeilenumbruch an notes gehängt (der Lauf meldet die Zahl der verschobenen Texte per RAISE 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, wird NULL mit Notiz Futter (alt): <JSON>). Die API akzeptiert bei POST/PATCH /inspections die Enum-Namen in jeder Schreibweise und die drei alten Wörter (so bleiben Einträge der Offline-Warteschlange alter Clients sendbar); leerer Text wird NULL, anderer Freitext 400. Die Regel FEED_STATUS_CRITICAL feuert bei feedStatus = CRITICAL; der Webhook inspection.created trägt feedStatus als Enum-Wert (der Shop nutzt das Ereignis nicht).

  34. Volksstärke in besetzten Waben: Colony.occupiedFrames und Inspection.colonyStrength zählen besetzte Waben, 0 bis 40 (CHECK in der Datenbank; 400 „Höchstens 40 besetzte Waben.“). Das alte Colony.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 Migration 20261014100700_t4_occupied_frames die Abfrage nach Inspection.colonyStrength > 40.

  35. Übernahme Durchsicht → Volkskarte: Siehe Abschnitt 11.10. Annahme: Das Brutbild wird zum Brutzustand der Volkskarte (5 EXCELLENT, 4 GOOD, 3 AVERAGE, 2 WEAK, 1 CRITICAL); diese Zuordnung ist nicht aus dem Lastenheft abgeleitet.

  36. Saison aus Pflanzenentwicklung: Die Saison (Abschnitte WINTERRUHE, FRUEHJAHR, SCHWARMZEIT, SOMMER, SPAETSOMMER, HERBST) leitet sich aus der Pflanzenentwicklung ab (Abschnitt 11.3). Der Kalender-Rückfall (PHENOLOGY.calendarFallback, Standard SALWEIDE 20.03., KIRSCHE 15.04., APFEL 28.04., HOLUNDER 30.05., LINDE 20.06., TRACHTENDE 01.08., HERBST 15.09., WINTER 15.11.). Die Tage sind eine Annahme für Schwaben und per Regel anpassbar. Ohne gespeicherte Zeile für das laufende Jahr gilt der reine Kalenderzustand (Verhalten von T4a); dort steht vor SALWEIDE noch WINTER des Vorjahres. Seit T4b kommen gemeldete Phasen und die Grünlandtemperatursumme (GTS, Wetter) dazu; sie wirken über SeasonService.statesFor, die Aufrufer bleiben gleich. Der Zustand eines Stands steht in ApiaryPhenology (apiaryId, year); der Job-Schritt phenology und jede Meldung oder Rücknahme berechnen ihn neu (PhenologyStateService.recomputeIn, unter Zeilensperre auf Apiary). Die Phase ist das Maximum aus eigener Meldung (Meldungen für alle Stände zählen für jeden Stand), GTS-Schätzung und Kalender. Sie läuft im Jahr nur vorwärts, außer nach einer Korrektur oder Rücknahme einer Meldung (allowBackward, gesetzt nur vom Handler bei PhenologyCorrected; eine neue Meldung schiebt die Phase nie zurück). GTS = Summe der positiven Tagesmittel ab 1. Januar bis gestern, Januar × 0,5, Februar × 0,75, ab März × 1 (nach DWD). Annahme: 200 °C gelten als SALWEIDE (Standardschwelle PHENOLOGY.gtsThresholds). Weitere Schwellen werden nie erfunden, sondern als Median der GTS am ersten Meldetag der letzten bis zu 3 Vorjahre kalibriert (Standard KIRSCHE und APFEL); ein einzelnes Vorjahr genügt und ist dann der Median. Ohne Vorjahresmeldung gilt für diese Phasen der Kalender. Fehlen mehr als maxGapShare (10 %) der Tage, steht „Wetterdaten lückenhaft“; ist der letzte Wettertag älter als 3 Tage, „Wetterdaten veraltet“; ohne Koordinaten „Keine Koordinaten“. In allen drei Fällen wird die GTS nicht benutzt und der Kalender entscheidet. Vor der ersten Phase des Jahres ist die gespeicherte Phase leer (Quelle Kalender), der Abschnitt WINTERRUHE. Ist die gespeicherte Zeile älter als 2 Tage (Job ausgefallen), gilt wieder der Kalenderzustand. springSince ist nicht gespeichert, sondern genähert: im Abschnitt FRUEHJAHR der Abschnittsbeginn, später im Jahr das Kalenderdatum von SALWEIDE (höchstens der Abschnittsbeginn). Weicht der GTS-Tag von SALWEIDE stark vom Kalender ab, verschiebt sich die Frist „Erste Durchsicht im Jahr“ um diese Differenz. Die Rückfall-Daten für Schwaben und die 200-°C-Schwelle sind Annahmen und noch nicht an regionalen Daten geprüft. Die Wetterquelle beschreibt Abschnitt 11.11. Meldungen und Rücknahmen stoßen die Neuberechnung über Ereignisse an, Saisonwechsel erscheinen nur in der Morgenübersicht (Annahme 47).

  37. Alter der Königin: Eine Königin gilt als gealtert, wenn aktuelles Jahr minus Schlupfjahr ≥ queenMaxAgeYears (Regel SEASON_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).

  38. 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ält expiresAt; 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, weil proposeIn Kandidaten bei offenem Vorschlag derselben Absicht verwirft).

  39. Benachrichtigungen je Nutzer: Jede Meldung gehört genau einem Empfänger (Notification.userId Pflicht). Rundmeldungen gibt es nicht mehr: NotificationsService.recordForPermission und recordForUsers schreiben 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-all markiert nur sichtbare). dedupeKey = "<Präfix>:<userId>" verhindert Doppelungen (createManyAndReturn mit skipDuplicates); link ist 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.userId und NotificationPreference.userId hängen mit ON DELETE CASCADE am Benutzer (Benutzer werden produktiv nur deaktiviert, RESTRICT schützte nichts und blockierte Aufräumen). Wächter: no-broadcast-notifications.spec.ts.

  40. Erinnerungen in der App und Morgenübersicht: Siehe Abschnitt 11.9. In der App steuern die Morgenzeit (05:00–12:00), die Ruhezeit und die Umstellung Sommer-/Winterzeit nichts; die Übersicht entsteht immer um 04:30 mit dem Stand dieser Stunde. Sie wirken auf die Telegram-Zustellung (Annahme 49). Die Saison-Zeile der Übersicht nennt die Abschnittswechsel seit dem Zeitfenster der letzten Übersicht, nicht als Einzelmeldung (Annahme 47).

  41. 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.

  42. Wetterquelle Open-Meteo (Entscheidung des Inhabers): Die Pflanzenentwicklung nutzt die freie Open-Meteo-API (Forecast-Endpunkt für die letzten 90 Tage, Archiv für ältere Lücken). Die nicht-kommerziellen Bedingungen und das damit verbundene Lizenzrisiko hat der Inhaber bewusst übernommen. Es wird kein API-Schlüssel verwendet (OPEN_METEO_API_KEY bleibt leer). Die Quelle liegt hinter der Schnittstelle WeatherHistorySource (src/weather/history); ein Wechsel geht über WEATHER_HISTORY_SOURCE und einen neuen Adapter. Koordinaten werden auf 0,01° gerundet. URL und Schlüssel erscheinen nie in Logs oder Fehlermeldungen. Details: Abschnitt 11.11.

  43. Pflanzenentwicklung: Tabellen und Fremdschlüssel: Abweichend vom Entwurf (Spec 4.2) verweisen ApiaryPhenology.apiaryId und ApiaryWeatherDay.apiaryId mit ON DELETE CASCADE auf Apiary (abgeleitete Daten und Cache, keine Waisen, ein Restore von Ständen wird nie blockiert). PhenologyReport.apiaryId verweist mit ON DELETE RESTRICT auf Apiary: Der Prisma-Standard für eine optionale Relation, SetNull, machte aus einer Meldung still eine Meldung „für alle Stände“. Die Migration 20261015100000_t4_phenology vergibt phenology:read, phenology:create und phenology:update (Gruppe „Pflanzenentwicklung“) an alle Systemrollen; read geht an Rollen, die colonies:read halten, create und update an Rollen mit inspections:create, jeweils nur Nicht-Systemrollen. Die Migration legt die Rechte selbst an, weil der Katalog-Abgleich beim Start neue Rechte nur für Zeilen vergibt, die er selbst anlegt (PermissionCatalogSyncService).

  44. Bewegungsbuch der Honigcharge: HoneyBatch.amount bleibt der laufende Rest in kg, HoneyBatch.initialAmount (kg) ist die Anfangsmenge. BatchMovement.quantity ist bei Honig eine vorzeichenbehaftete Menge in Gramm mit honeyBatchId. Invariante: amount = initialAmount + Summe(quantity) / 1000, auf das Gramm gerundet, nie unter 0, Toleranz 0,5 g. Eine Charge ohne amount und initialAmount wird nicht gebucht. Jede BatchMovement gehört zu genau einer Quelle aus Abfüllung, Herstellungscharge, Rohstoff-Charge und Honigcharge (CHECK BatchMovement_exactly_one_batch, num_nonnulls(…) = 1). Rückrechnung für Altbestand (Migration 20261015100100_t4_honey_ledger, Import alter Bundles): initialAmount = amount + Summe(Gramm der Abfüllungen) / 1000, je Abfüllung eine Buchung BOTTLING mit sourceBottlingBatchId, Datum und Benutzer der Abfüllung (Grammwerte halb aufgerundet, floor(x·1000 + 0,5)). Frühere Handkorrekturen von amount stecken damit in initialAmount. Die Migration endet mit einer Prüfung (RAISE NOTICE mit Zählern, RAISE EXCEPTION bei verletzter Invariante, dann Rollback). Laufzeit: HoneyBatchLedgerService (src/honey-batches/honey-batch-ledger.service.ts) ist der einzige Schreiber von HoneyBatch.amount (Wächter honey-batch-amount-writes.spec.ts). Buchungen laufen in der Transaktion des Aufrufers unter lockRowForUpdate('HoneyBatch'): bookIn (vorzeichenbehaftete Gramm; amount wird aus initialAmount + Summe(quantity) / 1000 auf das Gramm neu berechnet, nie unter 0), setInitialIn (erste Menge: amount und initialAmount zugleich, keine Buchung) und clearIn (Menge entfernen, nur ohne Buchungen, sonst 400 „Die Menge wird ueber das Bewegungsbuch gefuehrt. Bitte eine Korrektur buchen.“). Eine Abbuchung braucht genug Rest (Toleranz 0,5 g), sonst 400 (Abfüllung: „Abfuellmenge uebersteigt die verfuegbare Menge der Honigcharge“, sonst „Die Menge kann nicht unter 0 sinken.“). Gerundet wird überall gleich, floor(x·1000 + 0,5) Gramm, auch bei Mengen, die Nutzer eingeben (12,3456 kg werden 12,346 kg). Eine Abfüllung anlegen bucht BOTTLING negativ mit sourceBottlingBatchId, Ändern die Differenz (bei Chargenwechsel zurück auf die alte, von der neuen), Entfernen BOTTLING positiv mit der Notiz „Abfuellung entfernt“ (ohne Quelle: die Abfüllung wird danach gelöscht). PATCH amount bucht CORRECTION (neu minus aktuell; initialAmount bleibt, Startmenge und Summe der Buchungen weichen danach ab); der erste Wert einer Charge ohne Menge (null → Wert) setzt initialAmount. Eine Charge ohne initialAmount bucht nichts, die Abfüllung setzt trotzdem den Status BOTTLED. Eine Abfüllung, die dem Buch nie etwas entnommen hat (Heuristik drewFromLedger: keine BOTTLING-Buchung mit honeyBatchId und ihrer sourceBottlingBatchId, etwa weil die Charge beim Abfüllen noch keine Menge hatte), bucht bei Änderung und Entfernung nichts zurück; wird sie in eine andere Charge verschoben, entnimmt sie erst dort. Das folgt aus den Buchungen und ist kein gespeichertes Merkmal. Sperrreihenfolge je Pfad: Abfüllung → Honigcharge (zwei Chargen in einer Anweisung, aufsteigend; die Abfüllung sperrt die Charge schon vor dem Anlegen), Schleuderung → Honigcharge. API des Buchs: Abschnitt 11.12.

  45. Honig-Kette: Vorschläge, Warnungen, nie sperren: Vorbelegungen (Menge, Honigsorte, Wassergehalt, Glasgröße, MHD) sind Vorschläge mit Übernehmen-Knopf, nie stillschweigend eingetragen. Plausibilitätswarnungen (EXTRACTION_EXCEEDS_HARVEST, BATCHES_EXCEED_EXTRACTION, Hinweis HARVEST_NET_MISSING) und die Wartezeitwarnung sperren nie (kein 4xx). Die Wartezeitwarnung WITHDRAWAL_ACTIVE bekommt jeder, der eine Schleuderung erfasst oder vorbelegt, auch ohne treatments:read (Lebensmittelsicherheit vor der Lese-Trennung). Die Vorbelegungs-Endpunkte hängen an den Anlege-Rechten (extractions:create, honey-batches:create): Sie liefern dieselben Daten wie der Anlege-Ablauf. Die Honigsorte kommt aus den Ernten, weil Extraction kein Sortenfeld hat. Abschnitt 11.12.

  46. Telegram: Token nur im Worker, Zustellung über Outbox: Der Bot-Token (TELEGRAM_BOT_TOKEN) liegt nur in der Umgebung des Workers (optionales Secret apicore-telegram); der API-Pod kennt nur TELEGRAM_BOT_USERNAME für den Deep Link. Der Token erscheint nie in Logs oder Fehlern. Ein fehlerhaft geformter Token (oder in Produktion ein Platzhalter) lässt den Worker nicht abstürzen: Fehler-Log ohne Wert, Telegram bleibt aus. Gesendet wird nur aus Worker-Schleifen über NotificationDelivery, nie aus Anfragen, Handlern oder Jobs; die Zustellung läuft in der Schleife telegram-send, der Empfang in telegram-updates (Abschnitt 11.13). Der Sender nimmt genau ein Worker-Replikat an (replicas: 1): Chat-Abstand, Senderate und die Pausen nach 401 und 429 liegen im Speicher des Pods, mit mehreren Pods hätte jeder seine eigenen. Die SQL-Regel „eine Zustellung je Nutzer und Batch“ und Telegrams 429-Antworten schützen den Bot auch dann. Ohne FRONTEND_ORIGIN geht die Nachricht außerhalb der Entwicklung (NODE_ENV leer oder development) ohne Link hinaus (ein Warn-Log je Pod), nie mit einem localhost-Link. Abschnitt 11.13.

  47. Pflanzen-Meldungen, Neuberechnung und Saisonwechsel: Rechte: phenology:read (Liste), phenology:create (Melden), phenology:update (Bearbeiten und Zurückziehen); GET /phenology/state braucht apiaries:read und enthält nie Koordinaten (Abschnitt 11.11). Das Datum muss ein echter Kalendertag sein und darf nicht nach dem Berliner Tag von heute liegen (400). Ein unbekannter Stand ergibt 404 (in der Transaktion geprüft, Stand vor Meldung in der Sperrreihenfolge); apiaryId leer ("") ergibt 400, null heißt „alle Stände“. Wer phenology:update hält, darf auch fremde Meldungen bearbeiten und zurückziehen (kleines Team, bewusst); eine zurückgezogene Meldung lässt sich nicht mehr bearbeiten (409), erneutes Zurückziehen ebenso (409). Eine Änderung ohne tatsächliche Abweichung schreibt weder Datensatz noch Audit-Eintrag noch Ereignis. PhenologyReported und PhenologyCorrected (auch beim Zurückziehen) entstehen in der Schreib-Transaktion. PhenologyCorrected trägt previousApiaryId (beim Zurückziehen gleich apiaryId): Eine Verschiebung von Stand A nach B berechnet nur A und B neu; ist eine Seite null (Meldung erweitert oder eingeengt), gilt „alle Stände“. Der Handler phenology.recompute rechnet für den späteren von Berliner Tag des Ereignisses (occurredAt) und heute, damit eine verzögerte Zustellung keinen älteren Tag (GTS-Stand, Abschnittsbeginn) zurückschreibt; bei einem oder zwei Ständen direkt, in Id-Reihenfolge. Gilt „alle Stände“, schreibt er je aktivem Stand ein internes Ereignis PhenologyRecomputeRequested { apiaryId, allowBackward, day } (kein Schlüssel), und phenology.recompute-apiary rechnet einen Stand je Zustellung (übersprungen, wenn er nicht mehr aktiv ist). So läuft eine Meldung für alle Stände nicht in einer einzigen Zustell-Transaktion. Bewusste Abweichung von der Spec: Saisonwechsel erscheinen nur in der Morgenübersicht (Abschnitt 11.9), nie als Einzelmeldung: Meldungen für alle Stände ergäben sonst Stände × Nutzer Meldungen. SeasonPhaseChanged bleibt für den Autopiloten.

  48. Autopilot A4–A10: Annahmen: A4 (Sommerbehandlung planen) und A8 (Umweiseln planen) sind PLAN-Vorschläge ohne gespeicherten Plan: Übernehmen legt genau eine Aufgabe an, heute fällig (Spec 8.2). Der gespeicherte Saisonzustand kennt nur den Beginn des aktuellen Abschnitts. Im Abschnitt HERBST ist der Beginn von SPAETSOMMER daher genähert: der Kalendertag von TRACHTENDE (PHENOLOGY.calendarFallback), höchstens der Beginn von HERBST (lateSummerStart). Wurde die Phase viel früher oder später gemeldet als der Kalender sagt, zählt eine Fütterung zwischen beiden Tagen für A5 falsch (nur A5 betrifft das). Der genaue Weg ist eine Spalte lateSummerSince an ApiaryPhenology (Migration, späteres Schema-Fenster; im Code als TODO vermerkt). A6 ohne Durchsicht zählt Fütterungen seit dem späteren von Anlagetag des Volkes und heute minus 60 Tage; A7 nutzt nur eine Durchsicht ab springSince des laufenden Jahres (sonst ab 1. Januar). „Gemeldet am TT.MM.“ im Grund von A4 steht nur, wenn reportedOn vorliegt (bei gespeicherten Zeilen nie), sonst „Tracht vorbei (gemeldet)“, bei Schätzung oder Kalender „(geschätzt)“ bzw. „(Kalender)“. syncApiaryIn gleicht alle Völker eines Stands in einer Transaktion ab (etwa 10 Abfragen je Volk); für sehr große Stände wäre eine Aufteilung nötig. Ein Wächter (src/suggestions/suggesting-triggers.spec.ts) verlangt für jeden vorschlagenden Trigger eine Textvorlage und für jeden im Quelltext gefundenen reasonCode einen Registereintrag; ein neuer Trigger fällt durch, bis er eine Vorlage hat oder bewusst ausgenommen wird.

  49. Telegram-Zustellung: Zustellzeit, Zeitumstellung, Prüfung beim Senden: Zeiten rechnen auf der Berliner Wanduhr (Europe/Berlin, src/notifications/delivery-time.ts). Verfügbar ab dem späteren von jetzt und der Kategorie-Zeit (TASK_DUE: eine Minute nach der Morgenzeit, DIGEST: die Morgenzeit, jeweils heute); liegt das Ergebnis in der Ruhezeit (Standard 21:00 bis 06:30, über Mitternacht, Ende ausgeschlossen; fehlende oder gleiche Grenzen = keine Ruhezeit), rückt es an deren Ende. Eine Uhrzeit, die im Herbst doppelt vorkommt (Doppelstunde 02:xx), gilt als das erste Vorkommen (Sommerzeit); eine Uhrzeit in der Frühjahrslücke (02:30 existiert nicht) als der Zeitpunkt eine Stunde später (03:30 Sommerzeit). Antworten des Bots (templateCode) umgehen Ruhezeit und Prüfungen. Beim Senden gilt der Stand dieses Moments, nicht der der Planung: Nutzer und Rolle aktiv, Telegram an, Verbindung, Kategorie, jedes Recht aus requiredPermissions (frisch gelesen, nicht aus dem 30-s-Cache) und Frische. TASK_DUE und DIGEST sind nur am selben Berliner Tag frisch, alle anderen höchstens 24 Stunden; eine TASK_DUE-Zustellung braucht die Aufgabe noch offen. Sonst SKIPPED mit Grund. Der Batch ist auf 120 s begrenzt; der Rest geht ohne Fehlversuch zurück. Abschnitt 11.13.

  50. Sperrreihenfolge: Stand vor Volk: Die Karte LOCKABLE_TABLES (LOCK_ORDER, src/prisma/row-lock.ts) gilt für jede Transaktion, die Zeilen mehrerer Tabellen sperrt (Tabellen in dieser Reihenfolge, innerhalb einer Tabelle Id aufsteigend), Apiary steht seit T6 an erster Stelle der Karte, also vor Colony (Entscheidung zu Task 27, in T6 Task 1 in die Karte übernommen). Hinter NotificationDelivery stehen die T6-Tabellen LabelTemplate, SiteSettings, VeterinaryMedicine, VeterinaryMedicineLot, ComplianceSettings, AuthorityNotice, ApiaryRestriction, ComplianceAcknowledgement (Schlüssel jeweils id, Product stand schon in der Karte). Pfade, die Stand und Volk zusammen sperren, nehmen zuerst die Stände (aufsteigend), dann das Volk; row-lock.spec.ts hält die Reihenfolge und die Einmaligkeit jeder Tabelle fest. Der Abgleich je Stand (AutopilotService.syncApiaryIn) sperrt die Standzeile, liest danach die Völker, sperrt alle in einer Anweisung (lockRowsForUpdate, Id aufsteigend) und erst dann Vorschläge und Regeln; der Handler autopilot.colony-sync sperrt die Völker eines Ereignisses ebenso in einer Anweisung. Kein Pfad sperrt ein Volk und danach einen Stand (die Neuberechnung der Pflanzenentwicklung sperrt nur den Stand); ein künftiger Pfad Volk → Stand würde mit dem Abgleich je Stand verklemmen. Die Aufrufer (ColoniesService.create/update/move/merge, ImportService, ApiariesService, ApiaryRestrictionsService) sind umgebaut (Abschnitt 12.7); ein Umsetzen, das zwischen Lesen und Sperren den Stand wechselt, endet mit 409 „Der Stand wurde gerade geändert. Bitte neu laden.“ ohne automatischen Neuversuch (Abschnitt 12.1).

  51. Telegram: fehlerhaftes Update wird übersprungen (Annahme der Umsetzung): Ein Update, dessen Verarbeitung scheitert, wird bis zu 5 Mal oder höchstens 10 Minuten wiederholt (Zähler und erste Fehlzeit je update_id im Speicher des Pods; MAX_UPDATE_ATTEMPTS, MAX_UPDATE_RETRY_MS). Danach überspringt es eine eigene Transaktion: Offset weiter, lastError = „Telegram-Nachricht übersprungen“, eine Warnung ohne IDs und Text im Log, der Rest des Batches läuft weiter. Ohne diese Grenze würde ein dauerhaft scheiternder Befehl alle späteren Befehle (etwa jedes /start) sperren. Kosten bei falscher Annahme: ein verlorenes /start, der Nutzer erzeugt den Link neu. Eine verlorene Lease zählt nie als Fehlversuch, Verbindungsfehler ebenso wenig; die Zähler werden nach jedem Batch bereinigt. Abschnitt 11.13.

  52. Wassergehalt: Bestätigung gilt für den gemessenen Wert: Die Bestätigung über der gesetzlichen Grenze (acknowledgedWarnings: ['WATER_CONTENT_ABOVE_LEGAL']) trägt zusätzlich acknowledgedWaterContent mit dem Wert aus der 409-Antwort. Sie gilt nur, wenn der unter der Sperre geprüfte Wert genau gleich ist; sonst folgt eine frische 409. Wer bestätigt hat, hat also diesen Wert gesehen, und eine zwischenzeitlich neue Messung entwertet die Bestätigung (Lebensmittelsicherheit; Kosten bei falscher Annahme: ein zusätzliches Feld). Streng größer als die Grenze löst die Bestätigung aus, genau 20,0 % warnt nur. Abschnitt 11.12.

  53. Vorbelegungs-Hinweise: Speichern bleibt erlaubt: Laden die Hinweise einer Vorbelegung (Warnungen, etwa die Wartezeit) nicht, sperrt die Oberfläche das Speichern nicht; eine dauerhafte Zeile weist darauf hin, das Backend prüft beim Speichern. Kosten bei falscher Annahme: Ein Nutzer speichert, ohne die Wartezeitwarnung gesehen zu haben; die Antwort des Speicherns trägt die Warnung trotzdem. Abschnitt 11.12.

  54. Telegram: LINKED ohne Antwortgrenze: Die Grenze von 3 Antworten je Chat in 10 Minuten gilt für HELP, LINK_INVALID und STOPPED, nicht für LINKED. LINKED braucht ein gültiges Einmal-Token des angemeldeten Nutzers, ein Fremder kann es nicht auslösen. Abschnitt 11.13.

  55. Pflanzenmeldung: doppelte Meldung beim erneuten Öffnen des Blatts: Jede Meldung des Blatts „Entwicklung melden“ bekommt einen eigenen Schlüssel. Öffnet jemand das Blatt erneut und meldet dieselbe Phase noch einmal, entsteht eine zweite Meldung (auch aus der Offline-Warteschlange). Das ist bewusst hingenommen: Die Auswertung nimmt den frühesten Meldetag der Phase, die Zeile lässt sich zurückziehen (Kosten bei falscher Annahme: eine Zeile mehr). Abschnitt 11.11.

  56. Step-up: ein 401 wird nie mit Refresh wiederholt: Bei Aufrufen mit Nachweis (Telegram verbinden, Backup-Import, Passwort ändern, Konto deaktivieren, MFA bestätigen und abschalten) bedeutet ein 401 „Nachweis falsch“. Der Client erneuert dort das Token nicht und wiederholt nicht (refreshOn401: false), weil ein zweiter Versuch den Sperrzähler verdoppeln würde. Ist die Sitzung wirklich abgelaufen, erscheint statt Stille der deutsche Hinweis „Sitzung abgelaufen. Bitte die Seite neu laden und erneut anmelden.“ (der Client kennt den Ablauf des Tokens nicht und erneuert nicht vorab). Kosten bei falscher Annahme: Eine abgelaufene Sitzung zeigt bei einem Step-up einmal einen Fehler statt einer stillen Erneuerung. Abschnitt 11.13.

  57. Abfüllung: Produktwechsel setzt Artikelwerte zurück: Wechselt das Produkt, verwirft die Maske von Hand geänderte Glasgröße und MHD und nimmt die Werte des neuen Artikels; ein Datumswechsel lässt eigene Eingaben stehen. Sonst bliebe die Glasgröße eines anderen Artikels unbemerkt im Formular. Kosten bei falscher Annahme: Wer erst tippt und dann das Produkt wählt, muss neu eintragen. Abschnitt 11.12.

  58. „Alle fehlgeschlagenen erneut versuchen“: die Zahl kann zu niedrig sein: Die API liefert keine Gesamtzahl der FAILED-Zeilen, die Rückfrage nennt daher nur „Mindestens N“ (größerer Wert aus failed24h und den FAILED-Zeilen der Liste) und immer die Grenze „höchstens 100, neueste zuerst“. Kosten bei falscher Annahme: Es gehen mehr (bis 100) Nachrichten erneut hinaus als angezeigt. Abschnitt 10, System-Seite.

  59. T6-Schema: Prüfungen in der Datenbank, Rechtswerte als Einstellung: CHECK-Constraints sichern VeterinaryMedicineLot.quantity > 0, VeterinaryMedicineMovement.quantity <> 0 und (type = 'TREATMENT') = ("treatmentId" IS NOT NULL), ApiaryRestriction.endsOn >= startsOn sowie LabelTemplate.minXHeightMm zwischen 0,5 und 5. Katalog, Packung, Behandlung und Stand hängen mit ON DELETE RESTRICT aneinander. AuthorityNotice.openKey ist nullbar-eindeutig (höchstens ein offener Entwurf je Art und Jahr). Die Vorgabe ComplianceSettings.netQuantityDigitRules (bis 50 g: 2 mm, bis 200 g: 3 mm, bis 1000 g: 4 mm, darüber 6 mm) und minXHeightMm 1,2 mm sind Annahme – vom Inhaber zu prüfen; im Code stehen keine Rechtswerte. Abschnitt 12.1.

  60. Tierarzneimittel: eigener Katalog und eigenes Bewegungsbuch: Ergänzung zu Annahme 10. Mittel (VeterinaryMedicine), Packungen (VeterinaryMedicineLot) und Bewegungen (VeterinaryMedicineMovement) sind nicht StockItem/BatchMovement. Bewegungen werden nie geändert oder gelöscht, Fehler werden gegengebucht (es gibt keine Route dafür). Der Rest einer Packung ist quantity plus Summe der Bewegungen, auf Tausendstel gerundet, immer berechnet und nie gespeichert; ein negativer Rest ist erlaubt (die Behandlung hat stattgefunden) und erscheint in der Prüfbereitschaft. Der Katalog startet leer, der Seed legt nichts an. Kosten bei falscher Annahme: Wer einen Buchungsfehler erwartet zu löschen, muss gegenbuchen. Abschnitt 12.2.

  61. Ein inaktives Mittel nimmt keinen neuen Bestand: Eine Packung lässt sich weder zu einem inaktiven Mittel anlegen noch auf ein solches umhängen (400 „Das Mittel ist deaktiviert.“, active wird unter der Mittelsperre gelesen). Kosten bei falscher Annahme: Wer eine Lieferung eines stillgelegten Mittels buchen will, muss es erst wieder aktivieren.

  62. Packung und Mittel müssen dieselbe Einheit haben: Beim Anlegen und beim Umhängen einer Packung auf ein anderes Mittel gilt 400 „Die Einheit des Mittels passt nicht zur Packung.“; die Einheit eines Mittels ist nur änderbar, solange keine Packung existiert, das Mittel einer Packung nur, solange keine Bewegung existiert (jeweils 409).

  63. Wiederherstellung ersetzt das Packungsbuch: Eine versiegelte Datei überschreibt Mittel, Packungen und Bewegungen nach Id; sie führt das Buch nicht mit dem vorhandenen zusammen. Aus unversiegelten Dateien entsteht kein Packungsbezug einer Behandlung (Abschnitt 8). Kosten bei falscher Annahme: Buchungen, die nach der Sicherung entstanden sind, fehlen nach dem Einspielen und müssen nachgetragen werden.

  64. Beim Wiederherstellen gewinnt der offene Entwurf im Zielsystem: Hält dort ein Entwurf (openKey, etwa der vom Tagesjob angelegte TSK-Entwurf) den Schlüssel unter anderer Id, entfällt die Anzeige der Datei mit ihren Dokumenten; der Restore scheitert nicht am Unique-Constraint. Kosten bei falscher Annahme: Der Entwurf aus der Datei fehlt und muss neu erfasst werden (die Ids stehen in summary.skippedNoticeIds).

  65. Etikett-Pflichtangaben prüft eine reine Funktion am aufgelösten Inhalt: checkLabel (src/label-templates/label-particulars.ts) bewertet alle Text-Elemente mit eingesetzten Platzhaltern, normalisiert mit NFKC, Kleinschreibung und „alles außer Buchstaben und Ziffern wird zum Leerzeichen“. Ein Text, der nicht in sein Feld passt (gemessene Höhe größer als Feldhöhe plus 0,5 pt), zählt nicht und löst TEXT_CLIPPED aus. Nicht geprüft werden die „gleiches Sichtfeld“-Regel und die Nährwertdeklaration (für Honig befreit – Annahme, vom Inhaber zu prüfen). Das Datum auf dem Etikett und das erwartete MHD der Prüfung kommen beide aus labelDayOf (Berliner Kalendertag, unabhängig von der Prozess-Zeitzone; ein Quelltext-Wächter sichert das). Eine Zahl direkt vor der Menge („1,250 g“) gilt nicht als Treffer für 250 g; der Ziffern-Wächter gilt je Element, nie im verbundenen Text. Eine Anschrift wird auch erkannt, wenn sie auf mehrere Elemente verteilt ist (Annahme 75), weil eine unerkannte Anschrift den Druck sperrt. Abschnitt 12.4.

  66. Füllmengen-Höhe: jede Nennung zählt: NET_QUANTITY_HEIGHT ist erfüllt, wenn irgendeine Nennung der Füllmenge hoch genug ist (größte Versalhöhe aller Träger); eine zusätzliche kleine Nennung warnt nicht. Die Warnung sperrt nie. Kosten bei falscher Annahme: Eine große Nennung neben einer zu kleinen wird nicht bemängelt.

  67. Verkehrsbezeichnung: „honig“ in einem beliebigen Wort genügt: Für LEGAL_NAME reicht ein Wort mit „honig“ („Blütenhonig“, auch „Honigtau“); ob die Bezeichnung inhaltlich stimmt, prüft die Software nicht. Kosten bei falscher Annahme: Ein falsch benannter Honig sperrt den Druck nicht.

  68. Losnummer mit „L“ am Anfang erfüllt die Präfix-Regel: Beginnt die Losnummer schon mit „L“ („L-001“), stellt der Platzhalter lotLine kein weiteres „L“ voran und LOT_PREFIX warnt nicht. Die Warnung sperrt nie.

  69. compliance:manage schließt compliance:read nicht ein: Die Rechte sind getrennt; die Rollenvergabe koppelt sie (nur ADMIN und BEEKEEPER haben manage, und diese haben auch read). Ein Nutzer mit nur manage darf bei Dokumenten hochladen, aber die Liste nicht lesen (403). Kosten bei falscher Annahme: Eine handgebaute Rolle mit nur manage sieht nichts, was sie angelegt hat.

  70. Dokumente gelöschter Datensätze bleiben bestehen: Sie bleiben durch das Recht der Art geschützt und werden nicht mitgelöscht; bisher löscht kein Pfad einen Datensatz mit Dokumenten (Behandlungen, Sperrbezirke und Behördenanzeigen haben keine Löschroute; Sperrbezirke enden über das Enddatum, Entwürfe werden verworfen, Annahme 76). Ein künftiger Löschpfad entfernt die Dokumente seines Datensatzes selbst. Kosten bei falscher Annahme: Dateien ohne Datensatz bleiben im Speicher.

  71. Der CSV-Import befolgt den Sperrbezirk ohne Ausnahme: Der Import legt in einem Stand mit aktivem Sperrbezirk kein Volk an; die Zeile wird als Fehlerzeile gemeldet (Text des Sperrbezirks), nichts wird für sie geschrieben. Der Import kennt keine Ausnahmebegründung. Kosten bei falscher Annahme: Wer Altbestand in einen gesperrten Stand importieren muss, legt die Völker einzeln mit Begründung an. Abschnitt 12.7.

  72. Eine Ausnahmebegründung ohne compliance:manage ist immer 403: Wer restrictionOverrideReason sendet, ohne compliance:manage zu halten, bekommt 403, bevor gelesen oder gesperrt wird, auch ohne aktive Sperre. Mit dem Recht wird eine Begründung ohne aktive Sperre ignoriert. Kosten bei falscher Annahme: Ein Client, der die Begründung immer mitsendet, braucht das Recht. Abschnitt 12.7.

  73. Der Bestandsbuch-Export braucht zusätzlich veterinary-medicines:read: GET /export/treatment-log.pdf verlangt export:read und veterinary-medicines:read, weil es Lieferant, Beleg und Tierarzt aus der Packung zeigt (die Prüfung steckt im ExportController, nur für diesen Bericht). Kosten bei falscher Annahme: Ein Nutzer mit nur export:read verliert das Bestandsbuch; das Frontend (Task 15) blendet den Export ohne das Recht aus. Abschnitt 12.2.

  74. „500“ und „g“ in zwei Elementen zählen nur als anliegendes Paar: Die Füllmenge gilt in zwei Elementen nur, wenn sich die Boxen senkrecht überlappen, die Einheit rechts der Zahl liegt (x nicht kleiner) und der waagerechte Abstand höchstens 10 mm beträgt; vor der Zahl darf keine Ziffer stehen. Sonst ist es eine Lücke (sperrt den Druck, der Probedruck bleibt möglich). Abschnitt 12.4.

  75. Eine verteilte Anschrift zählt nur mit höchstens 10 mm Abstand: Name und Anschrift des Lebensmittelunternehmers dürfen über mehrere Elemente verteilt sein, wenn der senkrechte Abstand jedes Elements zur bisherigen Gruppe höchstens 10 mm beträgt (symmetrisch, gegen die laufende Ober- und Unterkante der Gruppe; die Lesereihenfolge kommt allein aus den Positionen). Der waagerechte Abstand zählt dort nicht. Kosten bei falscher Annahme: Eine sehr weit auseinandergezogene Anschrift gilt als Lücke; zwei nahe, fremde Elemente mit der passenden Wortfolge gelten als Anschrift. Abschnitt 12.4.

  76. Verwerfen einer Behördenanzeige behält die Dokumente: cancel setzt den Entwurf auf CANCELLED und gibt den openKey frei; es gibt keine Löschroute (Spec 7), angehängte Dokumente (AUTHORITY_NOTICE) bleiben bestehen und durch das Recht der Art geschützt (Annahme 70). Kosten bei falscher Annahme: Wer einen Entwurf „löschen“ erwartet, findet ihn als verworfen in der Liste. Abschnitt 12.8.

  77. Etikett-Rechte: derzeit nur ADMIN (Frage an den Inhaber offen): label-templates:read|create|update|deactivate|print vergibt keine Migration, nur die Katalog-Synchronisation an die damaligen Systemrollen; in der Test-Datenbank halten sie nur ADMIN. Prüfen und Drucken brauchen zusätzlich bottling:read. Offen ist, ob BEEKEEPER (und für read STAFF und READONLY) Etiketten ansehen und drucken dürfen; vor dem Rollout die tatsächliche Rollenvergabe in Produktion prüfen. Abschnitt 12.6.

  78. Eine Bestandsänderung meldet nur, sie legt keine Anzeige an: compliance.herd-change schreibt eine Meldung (HINWEIS, COMPLIANCE) an jeden aktiven Nutzer mit compliance:manage, nie einen Entwurf (Spec 5.5). Kosten bei falscher Annahme: Wer einen Entwurf erwartet, legt die Änderungsanzeige selbst an. Abschnitt 12.9.

  79. Die TSK-Erinnerung geht auch bei einem offenen Entwurf: Existiert für das Jahr nur ein Entwurf, geht die Meldung weiter (Link auf den Entwurf, höchstens eine ungelesene je Nutzer); nur eine gesendete Anzeige hält sie still. Kosten bei falscher Annahme: Eine Erinnerung zu viel, solange der Entwurf nicht gesendet ist. Abschnitt 12.9.

  80. Ein Umschalter der Benachrichtigung wirkt erst beim nächsten Ereignis: Wer noticeOnColonyCountChange oder noticeOnApiaryChange einschaltet, bekommt eine bereits bestehende Abweichung erst beim nächsten Herd-Ereignis gemeldet. Kosten bei falscher Annahme: Die Meldung kommt später; die Prüfbereitschaft (12.10) zeigt die Abweichung sofort. Abschnitt 12.9.

  81. Maskierte Aufrufer der Prüfbereitschaft sehen die Zählungen: Ohne subjectPermission bleiben subjects leer, more und Sätze wie „N Stände liegen im Sperrbezirk“ bleiben sichtbar; Namen, Anschriften und Aktenzeichen stehen nie in Titel und Detail. Kosten bei falscher Annahme: Die Anzahl ist für alle mit compliance:read sichtbar. Abschnitt 12.10.

  82. Das TSK-PDF ist A4 quer: Der Zählnachweis hat viele Spalten (eine je Völkerart plus Summe); im Hochformat blieben der Lage-Spalte etwa 49 pt. Erst- und Änderungsanzeige bleiben Hochformat. Kosten bei falscher Annahme: Das Blatt muss gedreht abgelegt werden. Abschnitt 12.8.

  83. Der Ort im Briefkopf kommt aus der Postleitzahl-Zeile: Aus „…, 34117 Kassel“ wird „Kassel“; ohne Treffer steht nur das Datum, der Ort bleibt leer (kein FEHLT). Kosten bei falscher Annahme: Der Ort fehlt neben dem Datum. Abschnitt 12.8.

  84. Die Summe im Anzeigen-PDF ist das gespeicherte totalColonies: Weicht es von der Summe der Stände ab, steht „Gesamtzahl berichtigt (Summe der Stände: n)“; es wird nicht live neu gezählt. Kosten bei falscher Annahme: Ein veraltetes totalColonies erscheint im Ausdruck, bis recount im Entwurf läuft. Abschnitt 12.8.

  85. Keine der ersten sieben Prüfregeln ist quittierbar: Weder Pflicht- noch Prüfen-Punkte dieser Stufe lassen sich wegquittieren (Spec 5.7); die Quittung ist Mechanik für spätere weiche Regeln (Task 13). Kosten bei falscher Annahme: Der Inhaber muss die Ursache beheben statt zu quittieren. Abschnitt 12.10.

  86. Die Kurzfassung der Prüfbereitschaft kann bis zu 60 s veraltet sein: GET /compliance/readiness/summary (Dashboard-Zeile) wird je Prozess 60 s aus dem Speicher bedient und nach Quittieren oder Zurücknehmen im selben Prozess sofort verworfen. Änderungen an den Daten und Quittungen eines anderen Prozesses erscheinen dort bis zu 60 s später; Liste, PDF-Bericht und Quittierpfad rechnen immer live. Kosten bei falscher Annahme: Die Dashboard-Zeile zeigt kurz einen alten Stand. Abschnitt 12.10.

  87. Der Service Worker entscheidet über das Zwischenspeichern nach dem Pfad, nicht nach Cache-Control: Das Backend sendet Cache-Control: no-store an jede API-Antwort (Abschnitt 9); würde der Worker den Header als Signal lesen, verwürfe er das gesamte Offline-Verhalten. Nie gespeichert wird, was NEVER_CACHED_API_PATHS und die Dokumentarten-Regel benennen; ein neuer schutzwürdiger Pfad muss dort eingetragen werden und hebt SW_ROUTES_VERSION. Kosten bei falscher Annahme: Ein vergessener Pfad kann offline gespeichert werden, obwohl der Server no-store sendet. Abschnitt 2 und 12.3.

  88. „1.000“ wird als mehrdeutig abgelehnt: Die Mengenfelder der Tierarzneimittel-Oberfläche lesen das Komma als Dezimalzeichen und lehnen einen einzelnen Punkt mit genau drei Nachkommastellen („1.000“, „12.345“) sowie jeden Text mit mehreren Trennzeichen ab, weil er Tausender oder Dezimalstelle meinen könnte. Kosten bei falscher Annahme: Wer „1.000“ als eins meint, muss „1“ oder „1,0“ schreiben. Abschnitt 12.11.

  89. Eine neue, ungespeicherte Etikettvorlage prüft nichts, bis sie einen Wert hat: Der Check-Endpunkt verlangt die Mindest-x-Höhe; das Frontend kennt keinen eigenen Vorgabewert (kein Rechtswert im Code). Eine neue Vorlage startet mit leerem Feld, sendet beim Anlegen keinen Wert (der Server setzt seinen Standard) und zeigt das Panel erst nach Eingabe oder nach dem ersten Speichern. Kosten bei falscher Annahme: Im Designer fehlt bei einer ganz neuen Vorlage die Prüfung, bis der Wert gesetzt ist. Abschnitt 12.11.

  90. Das Prüfbericht-PDF druckt Zählungen, keine Namen: Es nennt je Punkt nur die Zahl der betroffenen Objekte, nie Stände, Völker, Mittel, Packungen oder Aktenzeichen, damit der Bericht ohne Rechteprüfung der Namen weitergegeben werden kann. Kosten bei falscher Annahme: Wer die Objekte braucht, öffnet die Seite /compliance mit den Rechten. Abschnitt 12.10.

  91. Eine Sperre, die heute endet, gilt bis Tagesende; ein Aufheben am selben Tag gibt es nicht: endsOn ist inklusive (aktiv bei startsOn ≤ Berliner Tag ≤ endsOn), das Backend lehnt endsOn vor startsOn ab. Die Oberfläche schlägt „gestern“ als Endtag vor; wer heute oder später wählt, bekommt „Die Sperre gilt noch bis einschließlich …“. Kosten bei falscher Annahme: Wer eine Sperre heute am Vormittag aufheben will, hat sie bis Mitternacht Berliner Zeit weiter am Stand. Abschnitt 12.11.

  92. Beim Bearbeiten einer Behandlung wählt die Oberfläche nie selbst eine Packung vor: Nur das Anlegen schlägt die Packung mit dem frühesten Verfall vor; im Bearbeiten-Dialog steht nach einem Mittelwechsel „Ohne Packung“, bis der Nutzer wählt. Kosten bei falscher Annahme: Wer eine Abbuchung erwartet, muss die Packung ausdrücklich wählen. Abschnitt 12.11.

  93. Die Glocke zeigt für COMPLIANCE das Kurz-Etikett „Behörden“: Die Badges der Glocke sind Einwort-Etiketten („Aufgabe“, „Vorschlag“); „Behörden & Prüfung“ würde die Zeile umbrechen und steht darum nur in lib/notifications/labels.ts und der Telegram-Kategorienauswahl. Kosten bei falscher Annahme: Zwei Bezeichnungen für dieselbe Kategorie. Abschnitt 12.11.

  94. Die Dosis ist auf drei Nachkommastellen begrenzt: Das Eingabefeld lehnt mehr als drei Nachkommastellen mit „Höchstens 3 Nachkommastellen.“ ab (Packungsmengen rechnen auf Tausendstel, roundToThousandths). Kosten bei falscher Annahme: Eine feinere Dosis lässt sich nicht erfassen. Abschnitt 12.11.

  95. Die Liebig-Zander-Zarge misst außen 525 × 430 mm: Der Wert stammt aus dem Innenmaß des Edelstahldeckels und steht im Katalog (Migration 20261017100100_hive_catalog_zander_liebig, test/fixtures/hive-ops/catalog.json). Kosten bei falscher Annahme: Die 3D-Maße stimmen nicht; der Inhaber misst nach, der Katalogwert wird mit hive-systems:manage geändert. Abschnitt 13.

  96. Die Styropor-Einlage misst 465 × 365 × 20 mm: Der Inhaber misst nach. Kosten bei falscher Annahme: Die Dämmung sitzt im Modell zu groß oder zu klein; Katalogwert ändern, kein Code. Abschnitt 13.

  97. Der Nassenheider Professional liegt in einer Leerzarge auf den Rähmchen der Zarge darunter: Im Modell ist er ein Einsatz der Leerzarge (EVAPORATOR in einer BOX mit 0 Plätzen). Kosten bei falscher Annahme: Die Lage im Aufbau weicht vom Stock ab; der Inhaber bestätigt mit Foto, die Regel steht in hive-validate.ts. Abschnitt 13.

  98. Rähmchen-Plätze zählen von links, mit Blick auf das Flugloch: Platz 1 ist links, Platz frameSlots rechts. Kosten bei falscher Annahme: Alle gespeicherten Platznummern sind gespiegelt; das lässt sich nur mit einer Datenmigration über alle Aufbauten beheben. Abschnitt 13.

  99. Den Aufbau ändert nur, wer colonies:update hat; den Katalog pflegt nur hive-systems:manage (ADMIN): STAFF hat colonies:update nicht und kann die Beute daher nicht ändern, auch nicht in der Durchsicht (403 HIVE_FORBIDDEN). hive-systems:manage kommt nur über die Katalog-Synchronisierung zur Rolle ADMIN, ohne Rechte-Migration. Kosten bei falscher Annahme: Mitarbeiter müssen den Aufbau über einen Administrator ändern lassen; der Inhaber vergibt colonies:update in der Rollenverwaltung, das ist kein Code. Abschnitt 13.

  100. Die Bausperre ist eine Einlage ohne Zustand; „Winter“ ist kein eigener Modus: Die Bausperre (FLOOR_INSERT) hat kein Zustandsfeld, Einlegen und Entfernen sind die Operationen addInsert und removePart. Kosten bei falscher Annahme: Wer einen Winter-Modus erwartet, muss Einlagen einzeln setzen; ein Modus wäre eine spätere Erweiterung des Katalogs. Abschnitt 13.

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/ (Deployment apicore-backend, Service, PVC apicore-data), frontend/ (Deployment, Service), postgres/ (StatefulSet, headless Service apicore-postgres, CronJob apicore-db-backup mit pg_dump). Die Vorlagen configmap.example.yaml und secret.example.yaml stehen nicht unter resources; 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, Postgres app.kubernetes.io/name: apicore-postgres (Pod zusätzlich encry.de/tier: database, Service-Port postgres). Bis 2026-10-13 wich das Repo bei Postgres ab (app: apicore-postgres, ohne Härtung, volumeClaimTemplates). Seit 2026-10-13 ist base/postgres der Live-Stand: StatefulSet mit Härtung (uid/gid 70, nur lesbares Root-Dateisystem, begrenzte emptyDirs für Socket, /tmp und /dev/shm, kein Service-Account-Token, enableServiceLinks: false), Probes, Ressourcen und Affinität wie live, Service und das statisch gebundene PVC apicore-postgres-data (PV pv-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 per kubectl diff prüfen.
  • components/, je Rollout-Schritt einschaltbar. Das Auskommentieren im Overlay ist nur lokal für kubectl diff/apply gedacht und wird nicht committet.
    • network-policies: Default-Deny und die Freigaben aus der Tabelle unten. Setzt selbst das Label db-client: "true" an der api, damit Schritt 2 sie nicht von Postgres abschneidet.
    • pod-hardening: Frontend mit readOnlyRootFilesystem, emptyDir auf /tmp und /app/.next/cache, ohne Service-Account-Token, Ressourcen laut Spec §7.
    • backend-roles: APICORE_ROLE=api samt initContainern migrate und seed im Deployment apicore-backend, Deployment apicore-worker, CronJobs apicore-job-<name>. Das neue Backend-Image (CMD nur node dist/main.js) geht nur zusammen mit dieser Komponente live. Ohne APICORE_ROLE bricht es den Start in Produktion ab, und ohne die initContainer liefe keine Migration.
    • api-hpa: HPA apicore-backend (autoscaling/v2, 1–2 Pods bei 70 % CPU); entfernt replicas aus dem Deployment.
  • overlays/encry/: Namespace apicore, ConfigMaps, Ingresses (Traefik, Middleware apicore-cloudflare-only@kubernetescrd, cert-manager letsencrypt-prod mit HTTP-01), Image-Digests. Die ConfigMap-Werte sind seit 2026-10-15 die Live-Werte aus dem Cluster (Runbook Schritt 1, Commit 6e3e9ae). Hosts und TLS in ingress-*.yaml und die Image-Digests sind bis zum Rollout noch Platzhalter (example.com, sha256:000…).
  • scripts/ensure-secrets.sh: ergänzt fehlende Schlüssel in apicore-backend-secret und apicore-telegram, ohne vorhandene Werte zu ändern, und gibt nur Schlüsselnamen aus (--dry-run zeigt nur an). Die Secrets selbst legt der Inhaber an.
  • tests/: manifest-policy.test.mjs prü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.mjs rendert die Schritte 1, 2, 3 und 5 aus dem Overlay. Die CI (.gitea/workflows/manifests.yml, nur push und workflow_dispatch, kein pull_request, weil der Runner im Cluster läuft) rendert mit kubectl kustomize, validiert mit kubeconform -strict und führt den Test aus; kubectl, kubeconform und yq mit festen Versionen und SHA-256.
  • Telegram-Secret. apicore-telegram (Schlüssel TELEGRAM_BOT_TOKEN) bindet nur apicore-worker ein: als einzelne secretKeyRef-Variable mit optional: true am Haupt-Container, ohne envFrom, ohne Volume, nicht im initContainer. Die api bekommt das Secret nie. Fehlt es, startet der Worker normal, nur die Telegram-Schleifen laufen nicht. TELEGRAM_BOT_USERNAME steht in der ConfigMap apicore-backend-config (api und Worker lesen sie per envFrom; leerer Platzhalter, bis der Bot angelegt ist); das Token steht nie in einer ConfigMap. manifest-policy.test.mjs prüft für den Zielstand und jede Rollout-Stufe mit Worker: genau ein Worker-Pod, genau eine Token-Variable ohne Klartextwert, kein anderer Pod mit TELEGRAM_BOT_TOKEN. Einrichtung bei BotFather: je Umgebung ein eigener Bot, Gruppen aus (/setjoingroups → Disable), kein Webhook (der Worker ruft getUpdates). Der Worker läuft mit replicas: 1, weil der Versand das annimmt (Annahme 46); Claims und Empfangs-Lease bleiben auch mit mehr Pods korrekt.

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 von db-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-traefik lä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-ingress ist neben dem neuen default-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. Stand 2026-10-16: Im Cluster wurde noch kein Schritt angewendet; vorbereitet sind nur die ConfigMap-Werte aus Schritt 1. Live laufen das Backend-Image sha-f5b1964 und ein Frontend-Image vom 2026-10-12, im Namespace gelten nur die drei Policies aus rke2-cluster. Fortschritt: Gitea-Issue „Rollout in den Cluster“ (#28).

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 einen Administrator an, wenn es noch keinen Benutzer mit der Adresse SEED_ADMIN_EMAIL gibt (Standard [email protected]). Das Passwort kommt aus SEED_ADMIN_PASSWORD; fehlt es, erzeugt der Seed ein zufälliges (kein fest eingebautes Standardpasswort). Er 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 nach colony, vor task, weil Task.sourceSuggestionId darauf zeigt; decidedByUserId wie andere Benutzer-IDs, null bleibt null; nur aus versiegelten Dateien, sonst kämen verworfene oder übernommene Vorschläge nach einem Restore als neue zurück) und NotificationPreference. Seit T4b zusätzlich PhenologyReport (eigene Beobachtungen; Import nach winterCheck, Benutzer-IDs wie bei anderen Pflicht-Benutzern umgesetzt; nur aus versiegelten Dateien). ApiaryPhenology (abgeleiteter Saisonzustand, der Job daily-evaluation berechnet ihn neu) und ApiaryWeatherDay (Wetter-Cache, der Worker holt ihn neu) sind ausgenommen. Alte Bundles, deren HoneyBatch-Zeilen kein initialAmount tragen, werden beim Import wie in Migration t4_honey_ledger zurückgerechnet (Annahme 44, src/backup/legacy-honey-ledger.ts): initialAmount wird gesetzt; die BOTTLING-Buchungen mit der stabilen ID legacy-bottling:<Abfüllung> entstehen nur aus versiegelten Dateien, nach dem Abschnitt batchMovement, und werden bei einem erneuten Import nie überschrieben. Eine unversiegelte Datei setzt nur initialAmount bei Chargen, die es noch nicht gibt. Nach dem Honig-Teil des Imports prüft eine SQL-Abfrage die Invariante der importierten Chargen (honeyLedgerViolations) in der Import-Transaktion: bei Treffern Warnung im Log mit den IDs und imported.honeyLedgerViolations = <Anzahl> (Schlüssel nur bei > 0); der Import scheitert deshalb nicht. Meldungen: Ein Eintrag eines im Zielsystem unbekannten Nutzers oder mit einem dedupeKey, der dort unter anderer ID existiert, wird übersprungen und in imported.notificationSkipped gezählt (nie umgehängt); Einträge alter Bundles ohne userId werden je aktivem Nutzer kopiert (abgeleitete ID <id>:<userId>, wiederholbar), ohne category nach der Regel von Migration 100800 (relatedType 'JobRun' oder leer → SYSTEM, sonst SUGGESTION). Ein alter Bundle-Wert für Inspection.feedStatus wird auf das Futter-Vokabular abgebildet (Annahme 33). Seit T6 (Abschnitt 12) gehören VeterinaryMedicine, VeterinaryMedicineLot, VeterinaryMedicineMovement, ComplianceSettings, AuthorityNotice, ApiaryRestriction und ComplianceAcknowledgement zum versiegelten Bundle; keine dieser Tabellen steht in MODELS_EXCLUDED_FROM_BACKUP oder LEGACY_UNSEALED_TABLES, eine unversiegelte Altdatei überspringt sie und meldet sie unter skipped. Beim Einspielen stehen Mittel und Packung vor den Behandlungen, die Buchungen der Packung danach, Sperrbezirke folgen den Ständen. Behandlungen einer unversiegelten Datei verlieren medicineId, medicineLotId und doseQuantity (ohne Bewegungsbuch gäbe es eine Behandlung mit Packung, aber ohne Buchung, also eine Fälschung des Bestands); die Zahl steht als imported.treatmentMedicineLinksDropped in der Zusammenfassung, dazu eine Log-Warnung. Eine versiegelte Wiederherstellung ersetzt das Packungsbuch (Annahme 63). Hält im Zielsystem ein Entwurf den openKey einer Behördenanzeige unter anderer Id, gewinnt dieser Entwurf: Die Anzeige der Datei wird samt ihren Dokumenten (AUTHORITY_NOTICE) übersprungen und als imported.authorityNoticeSkipped bzw. imported.authorityNoticeDocumentsSkipped gezählt, die Ids stehen in summary.skippedNoticeIds (Annahme 64). Die Wiederherstellung schreibt keine Domänenereignisse, also auch kein ColonyCreated (Abschnitt 12.5). 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 Telegram-Tabellen werden nie exportiert: TelegramLink (Chat-Bindung an den Bot dieses Deployments; Nutzer verknüpfen nach einem Restore neu, wie bei ApiKey), TelegramLinkToken (Einmal-Tokens, nur Minuten gültig), NotificationDelivery (Zustell-Warteschlange; ein Restore schickte alte Nachrichten erneut) und TelegramPollState (Update-Offset und Abfrage-Lease dieses Deployments). Die Liste mit Begründung steht in MODELS_EXCLUDED_FROM_BACKUP, ein Test erzwingt sie für jedes neue Prisma-Modell. Import ist upsert-basiert (Rollen/Berechtigungen über key/resource+action statt roher id, 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ündlich node 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 den JobRun fehlschlagen (Benachrichtigung an die Inhaber von backup: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/schedule liefert zusätzlich lastJobRun (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 weder idle_in_transaction_session_timeout noch 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 auf FAILED; 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 CronJob apicore-job-cleanup (Abschnitt 10, „Jobs“).
  • Restore: Über POST /backup/import (bzw. den Import-Dialog unter Verwaltung > Backup) eine zuvor exportierte Bundle-Datei einspielen. Import-Limit BACKUP_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 und Telegram verbinden verlangen aktuelles Passwort (+ TOTP); das Verbinden ist auf 10 pro Minute gedrosselt
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()
Wetter und Telegram Wetter (Open-Meteo) nur aus der Worker-Schleife phenology-weather und der Wetter-Anzeige, über src/weather/: nur https-URLs aus der Konfiguration, keine Weiterleitungen (redirect: 'error'), 8 s Frist, Antwort höchstens 1 000 000 Byte, Fehlertexte fest (kein URL, keine Ursache, kein Schlüssel). Telegram (api.telegram.org) nur aus Worker-Schleifen über TelegramClient (src/telegram/): Klartext ohne parse_mode, Linkvorschau aus, Text höchstens 600 Zeichen ohne Steuer- und Richtungszeichen, keine Weiterleitungen, Antwort höchstens 256 kB; der Token steht nie in Logs oder Fehlern (TelegramApiError trägt Methode, Status, bereinigte Beschreibung, retry_after 1–3600 s, keine URL). Wächter src/common/no-network-outside-loops.spec.ts: Wetter-Hosts nur in src/weather/, api.telegram.org nur in src/telegram/, fetch( und Netzwerkpakete nur in den erlaubten Dateien (Graph-Client, Passwort-Prüfung, Webhook-Zustellung, Worker-Health, Wetter, Telegram), nie in Handlern oder Jobs; handler-contract.spec.ts verbietet den Import von telegram-client in Handlern
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

Umgebungsvariablen seit T4b/T4c (Vorlagen in .env.example, configmap.example.yaml, secret.example.yaml; alle optional):

Variable Wo Bedeutung
WEATHER_HISTORY_SOURCE ConfigMap, API und Worker Wetterquelle, Standard open-meteo; eine unbekannte Quelle stoppt den Start (Meldung nennt nur den Namen der Variable)
OPEN_METEO_FORECAST_URL, OPEN_METEO_ARCHIVE_URL ConfigMap Endpunkte, Standard die öffentlichen Open-Meteo-URLs; nur https:
OPEN_METEO_API_KEY Secret Bleibt leer (Annahme 42); falls gesetzt, wird er als apikey gesendet
WEATHER_USER_AGENT_CONTACT ConfigMap Kontakt für den Header User-Agent: ApiCore/<Version> (+<Kontakt>); ohne Wert (self-hosted beekeeping app); kein persönlicher Standard, auf druckbares ASCII reduziert
TELEGRAM_BOT_USERNAME ConfigMap, API und Worker Benutzername des Bots (ohne @, 5–32 Zeichen, ^[A-Za-z][A-Za-z0-9_]{4,31}$), erzeugt den Deep Link; ungültig = nicht gesetzt, mit Warnung in jeder Rolle
TELEGRAM_BOT_TOKEN Secret apicore-telegram, nur Worker Bot-Token von BotFather. Der API-Pod darf das Secret nicht mounten. Fehlt er, ist Telegram aus; ein Benutzername ohne Token oder umgekehrt warnt im Log

Die Manifeste binden das Secret nur am Worker ein (Abschnitt 7). Ohne Token laufen die Worker-Schleifen telegram-send und telegram-updates nicht (Abschnitt 10).

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:

  1. DomainEventDelivery (eventId, handler) anlegen, falls es sie noch nicht gibt (skipDuplicates).
  2. Die Zeile FOR UPDATE sperren. Ist sie DONE, wird der Handler übersprungen.
  3. Den Handler mit dem Transaktions-Client aufrufen; die Korrelations-ID steht im domainEventContext.
  4. Die Delivery auf DONE setzen, 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). Neu sind seit T6 ColonyCreated, ColonyMoved, ApiaryChanged und TskReferenceDayReached (Abschnitt 12.5); ColonyCreated schreiben auch Teilen und CSV-Import. 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 in rawMaterialKinds.
  • 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öschung BottlingCorrected { honeyBatchId, removed }, jeweils zusammen mit StockChanged in einem recordAll, also mit einer Korrelations-ID.
  • Herstellungschargen schreiben StockChanged mit 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. Die Reihenfolge der Sperr-Karte (LOCK_ORDER) gilt für alle Tabellen; Apiary steht seit T6 an erster Stelle, also vor Colony (Annahme 50).

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).

Ereignisse der Honig-Kette und der Pflanzenentwicklung (T4b). Sieben Typen kommen in den Katalog (domain-event-types.ts), Aggregattypen Extraction, HoneyBatch, PhenologyReport und Apiary dazu; die Lieferung der Meldungs-API und der Autopilot-Regeln A4–A10 ergänzt PhenologyRecomputeRequested und WinterCheckRecorded (Aggregattyp WinterCheck):

  • ExtractionRecorded und ExtractionCorrected (Aggregat Extraction, Payload harvestIds, bei der Korrektur nach der Änderung) schreibt ExtractionsService in der Transaktion des Schreibens, zusammen mit dem Audit-Eintrag.
  • HoneyBatchRecorded und HoneyBatchCorrected (Aggregat HoneyBatch, Payload extractionId) schreibt HoneyBatchesService ebenso. update sperrt zuerst die Zeile und liest „vorher“ danach (Abschnitt 11.12).
  • PhenologyReported und PhenologyCorrected (Aggregat PhenologyReport) schreibt PhenologyService mit Datensatz und Audit-Eintrag in einer Transaktion: Anlegen → PhenologyReported, Bearbeiten und Zurückziehen → PhenologyCorrected mit previousApiaryId (Annahme 47, Abschnitt 11.11). Ändert ein Bearbeiten nichts, entsteht kein Ereignis.
  • SeasonPhaseChanged (Aggregat Apiary, Payload apiaryId, phase, segment, previousPhase, previousSegment, source) schreibt PhenologyStateService, wenn sich Phase oder Abschnitt eines Stands ändern: höchstens eines je Stand und Tag über dailyDedupeKey. Beim ersten Zustand eines Jahres wird gegen den Kalenderzustand verglichen; ein unveränderter Zustand erzeugt kein Ereignis. Der Handler autopilot.apiary-sync hört darauf; eine Einzelmeldung je Saisonwechsel gibt es bewusst nicht (Annahme 47).
  • PhenologyRecomputeRequested { apiaryId, allowBackward, day } (Aggregat Apiary, ohne Schlüssel) schreibt der Handler phenology.recompute je aktivem Stand, wenn eine Meldung für alle Stände gilt; phenology.recompute-apiary rechnet damit einen Stand je Zustellung.
  • WinterCheckRecorded { colonyId } (Aggregat WinterCheck) schreiben WinterChecksService.create und update in ihrer Transaktion (ein Verschieben auf ein anderes Volk meldet auch das alte); autopilot.colony-sync löst damit A9/A10 sofort auf. PhenologyReport, Extraction und HoneyBatch stehen hinter NotificationPreference am Ende der Sperr-Karte (LOCKABLE_TABLES, Schlüssel id); Apiary steht seit T6 an erster Stelle (Annahme 50). NotificationDelivery folgt danach, ihr schließen sich die T6-Tabellen an (Abschnitte 11.13 und 12.1).

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 20 Typen: InspectionRecorded/Corrected, VarroaMeasured/Corrected, TreatmentRecorded/Corrected, FeedingRecorded/Corrected, HarvestRecorded/Corrected, ColonyStatusChanged, ColonySplit, QueenAssigned, TaskCompleted, TaskUpdated, WithdrawalEnded, QueenAged, InspectionOverdue, VarroaMeasurementDue, WinterCheckRecorded Betrifft ein Ereignis mehrere Völker, sperrt der Handler sie zuerst in einer Anweisung (lockRowsForUpdate, Id aufsteigend); dann ruft er 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, bei WinterCheckRecorded das Volk der Prüfung (A9/A10 lösen sich sofort auf); Aufgaben-Ereignisse nur mit relatedType = COLONY (Abschnitt 11.5).
autopilot.apiary-sync SeasonPhaseChanged Gleicht alle lebenden Völker des Stands ab (AutopilotService.syncApiaryIn(tx, apiaryId, berlinToday())): Stand sperren, Völker lesen, alle in einer Anweisung sperren (Id aufsteigend), Regeln und Saison einmal lesen, dann je Volk syncColonyIn. Ergänzt autopilot.colony-sync; es entsteht höchstens ein Ereignis je Stand und Tag, spätere Wechsel fängt der tägliche Schritt autopilot auf (Annahmen 48, 50).
phenology.recompute PhenologyReported, PhenologyCorrected Berechnet den Saisonzustand der betroffenen Stände in der Zustell-Transaktion neu (PhenologyStateService.recomputeIn, am späteren von Ereignistag und heute); bei PhenologyCorrected darf die Phase zurückfallen (allowBackward). Betroffen: Stand und, bei einer Korrektur, previousApiaryId; gilt eine Seite für alle Stände, schreibt er je aktivem Stand ein PhenologyRecomputeRequested. Fehler gehen als Wiederholung durch (Annahme 47).
phenology.recompute-apiary PhenologyRecomputeRequested Rechnet genau einen Stand neu; ein nicht mehr aktiver oder gelöschter Stand wird übersprungen.
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.
withdrawal.extraction-check ExtractionRecorded, ExtractionCorrected Prüft die Ernten der Schleuderung gegen die Wartezeitfenster (conflictsForExtraction) und meldet mit demselben Schlüssel wie Ernte und Abfüllung (withdrawal-conflict:<Ernte>:<Volk>): Ein Konflikt wird einmal gemeldet, egal welche Stufe ihn zuerst sieht (Abschnitt 11.8, 11.12).
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, HoneyBatchRecorded queen.replaced, batch.created (siehe „Webhook-Zustellung“). HoneyBatchCorrected sendet nichts.
compliance.herd-change ColonyCreated, ColonyMoved, ColonyStatusChanged (lebend ↔ beendet), ApiaryChanged HINWEIS „Bestand geändert …“ (Kategorie COMPLIANCE, höchstens eine ungelesene je Nutzer), legt nie eine Anzeige an (Abschnitt 12.9).
compliance.tsk-reference-day TskReferenceDayReached TSK-Entwurf (falls keiner offen) und WARNUNG „TSK-Stichtag …“; still bei gesendeter Meldung (Abschnitt 12.9).

Worker-Schleife phenology-weather (T4b). Takt 3 600 000 ms, kein Weckkanal (keine Outbox); der erste Durchlauf folgt 10 s nach dem Start des Workers (WEATHER_FIRST_TICK_DELAY_MS). Ein Takt ruft WeatherHistoryService.syncOnce(berlinToday(), signal) auf und holt die fehlenden Tagesmittel der aktiven Stände mit Koordinaten (Ablauf und Regeln: Abschnitt 11.11). Die Schleife schreibt nur ApiaryWeatherDay: kein Ereignis, keine Meldung. Die Loops liegen in einer Karte nach Namen; WorkerLoop.channel ist optional. Zu den LISTEN-Kanälen kommt notification_delivery (Abschnitt 11.13). Der Test test/worker-runtime.e2e-spec.ts ersetzt WEATHER_HISTORY_SOURCE durch eine Attrappe, weil die Schleife sonst für jeden Stand der geteilten Test-DB den echten Dienst aufriefe.

Zustellplanung. Kein Handler: NotificationsService.write plant nach dem Einfügen der Meldungen (dieselbe Transaktion; ohne Aufrufer-Transaktion öffnet es eine eigene, damit Meldungen und Zustellungen zusammen bestehen) über den NotificationDeliveryPlanner je neu eingefügter Meldung höchstens eine NotificationDelivery für Telegram und weckt den Worker per pg_notify('notification_delivery', ''), nur wenn Zeilen entstanden sind. Eine wiederholte Meldung (gleicher dedupeKey) plant nichts. Regeln: Annahme 49, Abschnitt 11.13.

Worker-Schleife telegram-send. Takt 5 000 ms und auf notification_delivery; sie ist nur registriert, wenn TELEGRAM_BOT_TOKEN gültig gesetzt ist (TelegramClient.isConfigured()). Ein Takt ruft TelegramSender.releaseStaleClaims() und dann drain(stopping.signal) auf. Mit telegram-updates ist sie der einzige Netzzugriff zur Bot-API. Rechte kommen aus dem globalen JobPermissionsModule des Workers, TelegramModule importiert kein PermissionsModule.

Worker-Schleife telegram-updates. Takt 30 000 ms (die Wartezeit ohne Lease, nach 409 und nach Fehlern), kein Weckkanal; wie telegram-send nur mit gültigem TELEGRAM_BOT_TOKEN registriert. Ein Takt ruft TelegramUpdatesService.pollOnce(stopping.signal); nach 'polled' wird die Schleife sofort wieder geweckt, das Stop-Signal bricht einen laufenden Abruf ab. Störungen stehen nur in TelegramPollState.lastError (Verwaltung > System) und im Log; sie machen den Worker nicht ungesund. Ablauf und Regeln: Abschnitt 11.13.

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 nach DOMAIN_EVENT_WEBHOOKS: InspectionRecorded → inspection.created, VarroaMeasured → varroa.measured, TreatmentRecorded → treatment.created, FeedingRecorded → feeding.created, HarvestRecorded → harvest.created, BatchBottled und BottlingCorrected → batch.updated, StockChanged → stock.changed, TaskCreated → task.created, TaskCompleted → task.completed, QueenAssigned → queen.replaced, HoneyBatchRecorded → batch.created. data ist wie bisher der Datensatz, gelesen über tx: die Ernte mit ihren Völkern, bei batch.created und batch.updated die Honigcharge (bei batch.created mit honeyBatchInclude, src/honey-batches/honey-batch-include.ts, sodass die Form von GET /honey-batches/:id gleich bleibt; HoneyBatchCorrected sendet nichts), bei queen.replaced die Königin, bei stock.changed nur { 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, queen.created, product.stock_changed), reiht nach dem Commit über runAfterCommit ein 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. APICORE_ROLE=job node dist/cli.js <job> [args], etwa APICORE_ROLE=job node dist/cli.js daily-evaluation (src/cli.ts; die Rolle muss job sein, all und api werden abgelehnt, und eine Form wie cli.js jobs daily-evaluation scheitert mit „Unknown job“; im Cluster setzt das CronJob-Manifest die Rolle) 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(); PROCESSING-Einträge bleiben immer stehen, PENDING ebenso, mit der einen Ausnahme der Telegram-Zustellungen unten.

  • Läufe, die seit mehr als 6 h RUNNING sind (JOB_RUN_STALE_HOURS, weit über jeder activeDeadlineSeconds), werden FAILED mit error: '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 und DELIVERED-Zustellungen nach 30 Tagen (occurredAt bzw. createdAt), FAILED-Einträge beider Tabellen nach 90 Tagen; die Deliveries eines Ereignisses gehen per ON DELETE CASCADE mit. 30 Tage liegen bewusst über dem Nachholfenster der täglichen Auswertung, sonst würde ein gelöschter dedupeKey ein Vorkommen erneut erzeugen.
  • JobRun-Zeilen nach 90 Tagen.
  • NotificationDelivery (nach createdAt, auch Antworten des Bots ohne Nutzer): DELIVERED und SKIPPED nach 30 Tagen, FAILED nach 90 Tagen, PENDING nach 7 Tagen (PENDING_DELIVERY_MAX_AGE_DAYS; eine so alte Zustellung ist hängen geblieben, sie wird mit Warn-Log samt Zahl gelöscht). TelegramLinkToken, die seit mehr als 24 h abgelaufen oder verbraucht sind.

Das Ergebnis zählt jede Kategorie (idempotencyRecords, consumedTokens, throttlerHits, domainEvents.done, domainEvents.failed, webhookDeliveries.delivered, webhookDeliveries.failed, jobRuns.aborted, jobRuns.deleted, notificationDeliveries.done, notificationDeliveries.failed, notificationDeliveries.pending, telegramLinkTokens).

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 (PhenologyStep, Abschnitt 11.11) berechnet den Saisonzustand ApiaryPhenology für jeden aktiven Stand, je Stand eine Transaktion. Zähler phenologyApiaries, seasonChanges; ein gescheiterter Stand hält die anderen nicht auf (JobPartialFailure, Lauf FAILED mit Zählern). Er steht vor time-events, damit alle späteren Schritte den frischen Zustand sehen. 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 in src/jobs/daily-evaluation.rules.ts, eine Zeile je Typ in DAILY_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 wirft JobPartialFailure mit den Zählern der anderen. Das Ergebnis zählt die neuen Ereignisse je Typ.
    • WithdrawalEnded (Aggregat Treatment, Payload colonyId, 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): dueDate offener Aufgaben (OPEN, IN_PROGRESS).
    • QueenAged (Queen, hatchYear, colonyId): jeden Jobtag für lebende Völker, deren aktuelle Königin ACTIVE und gealtert ist (isQueenAged, Annahme 37); Schlüssel QueenAged:<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, optional kind, anchor): am Fälligkeitstag aus varroaMeasurementDue (Abschnitt 11.3).
    • InspectionOverdue (Colony, dueOn, lastInspectedOn, optional firstOfYear): am Tag nach inspectionDueOn; dueOn im Payload ist der echte Fälligkeitstag, nicht der Ereignistag (Abschnitt 11.3). Eine feste Märzmeldung entfällt.
    • Die Ereignisse sind Tatsachen: active einer 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), mit dedupeKey = "<type>:<aggregateId>:<YYYY-MM-DD>" (dailyDedupeKey). Jeder Lauf im Fenster wiederholt denselben Schlüssel, der eindeutige dedupeKey (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-ended und autopilot.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ähler autopilotColonies, suggestionsOpened, suggestionsResolved. Scheitert ein Volk, läuft der Schritt weiter und endet mit JobPartialFailure.
  • suggestions-expiry: OPEN mit expiresAt vor heute → EXPIRED, gibt den dedupeKey frei. Zähler suggestionsExpired; zweimal am selben Tag ändert nichts.
  • digest: schreibt die Morgenübersicht (Abschnitt 11.9). Zähler digestsWritten und, nur wenn mindestens eine Übersicht geschrieben wurde, digestWindowEnd (Beginn des Schritts, ISO-Zeit; Zeitfenster der Saison-Zeile, Abschnitt 11.9); ein Wiederholungslauf am selben Tag schreibt 0 und merkt kein Fensterende.

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).

Telegram-Karte (nur mit system:manage; Betrieb des Bots: Abschnitt 11.13). GET /system/notification-deliveries?status= liefert telegram und deliveries. telegram.botNameSet heißt nur, dass der Bot-Name gesetzt ist: Ob der Versand läuft, weiß die api nicht, weil sie das Token nie hat; er läuft nur im Worker, wenn das Secret vorhanden ist. receiver: leaseOwner (nur bei laufender Lease), lastPollAgeSeconds (DB-Uhr), lastError. sender: pending, oldestPendingAgeSeconds (längste Überfälligkeit; wegen der Ruhezeit Wartende zählen nicht), failed24h (in den letzten 24 h angelegt und fehlgeschlagen), lastErrors (letzte 5, mit createdAt = angelegt am). deliveries: die neuesten 100 (Status, Kategorie, Vorlage, Versuche, availableAt, Statuscode, Fehler, angelegt/zugestellt), nie Chat-ID, Nutzer oder Text. POST /system/notification-deliveries/:id/retry (204): nur FAILED (409 sonst, 404 unbekannt); setzt PENDING, Versuche 0, availableAt = now(), schreibt ein Audit und weckt den Worker. POST /system/notification-deliveries/retry-failed (200 { retried }): dasselbe für die neuesten FAILED, höchstens 100 je Aufruf (bei mehr erneut aufrufen), unter Zeilensperre, ein Audit je Zeile.

Telegram-Karte (Oberfläche). Die Karte erscheint nur mit system:manage, weil GET /system/notification-deliveries dieses Recht verlangt (die Seite selbst braucht system:read). Sie zeigt Bot (botNameSet, Name, Hinweis, dass der Versand nur im Worker läuft), Empfang (Lease oder „Kein Empfänger aktiv“, Alter des letzten Abrufs, lastError im Wortlaut des Servers), Versand (wartend, ältestes Wartendes mit Warnung ab 600 Sekunden, FAILED der letzten 24 Stunden, letzte fünf Fehler mit HTTP-Code) und die neuesten 100 Zustellungen (Status immer mit Wort). Liegt der letzte Abruf länger als 120 Sekunden zurück, erscheint auch mit Lease die feste Warnung „Kein Abruf seit über 2 Minuten“ (role="status"), das Alter steht getrennt daneben. „Erneut versuchen“ gibt es nur an FAILED-Zeilen; „Alle fehlgeschlagenen erneut versuchen“ fragt per ConfirmDialog nach („Mindestens N sind fehlgeschlagen.“, immer „Es werden höchstens 100 gesendet, neueste zuerst.“; Annahme 58). Offline sind die Knöpfe mit sichtbarem Grund „Nur mit Verbindung“ gesperrt. Die Ereignisnamen der Warteschlange stehen in DOMAIN_EVENT_LABELS.

11. Logik-Ketten und Saison-Autopilot (T4a bis T4c)

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. T4b und T4c ergänzen Pflanzenentwicklung und Wetter (11.11), Honig-Kette und Bewegungsbuch (11.12) sowie Telegram (11.13); T4 ist vollständig (Stand 2026-10-15).

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; davor seit T6 Apiary, Annahme 50).

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). Der Abschnitt entsteht aus der gespeicherten Zeile ApiaryPhenology des laufenden Jahres (Phase aus Meldung, GTS und Kalender, Annahme 36; ein nicht aufsteigender calendarFallback fällt auf den Standard). Ohne Zeile oder bei einer Zeile, die älter als 2 Tage ist, gilt der reine Kalenderzustand. springSince ist der Tag, an dem SALWEIDE dieses Jahres erreicht ist (genähert, siehe Annahme 36), sonst null.

  • Durchsicht fällig (inspectionDueOn): letzte Durchsicht (ohne Eintrag die Anlage des Volkes) plus das Intervall des heutigen Abschnitts aus INSPECTION_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 von FRUEHJAHR zuletzt durchgesehen wurde, ist ab Abschnittsbeginn fällig („Erste Durchsicht im Jahr“). In WINTERRUHE ist 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) plus varroaMeasureIntervalDays (28); in WINTERRUHE einmal 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 auf springSince.
  • 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 über src/treatments/withdrawal-window.ts und treatment-windows.ts in Berliner Tagen (Annahme 41).
  • Probe: VarroaMeasurement.sampleBees (1–2000) leitet bei Puderzucker und Waschmethode calculatedValue = Milben / Probe × 100 ab, 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 samt postTreatmentUntil und active (Regel aus: bands {}, active false); mit from und to (höchstens 731 Tage, nicht zusammen mit date) liefert sie zusammenhängende Segmente mit gleichen Bändern. Recht varroa:read oder varroa: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/threshold und der alte Schwellen-Dienst entfallen. GET /colonies/:id/varroa-measurements trägt je Messung ein rating.
  • Regel: Nur rot (außerhalb des Behandlungseffekts) schlägt VARROA_RED vor, jetzt auch für die Prozent-Methoden. VARROA_TREAT entsteht 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“). Wechselt ein Stand seine Saison (SeasonPhaseChanged), gleicht autopilot.apiary-sync alle seine lebenden Völker ab (syncApiaryIn, Sperrreihenfolge Stand, dann alle Völker in einer Anweisung: Annahme 50). Eine Winterprüfung (WinterCheckRecorded) gleicht das betroffene Volk sofort ab, sodass A9/A10 ohne Warten auf den Tagesjob verschwinden.

Regeln A1–A10 (src/autopilot/autopilot-rules.ts, reine Funktion evaluateAutopilot, Annahme 48):

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
A4 SUMMER_TREATMENT (PLAN) Abschnitt SPAETSOMMER, keine Behandlung seit Abschnittsbeginn (SUMMER_TREATMENT_DUE) Jahr heute; HIGH
A5 FEED (DO) SPAETSOMMER oder HERBST, keine Fütterung seit Beginn von SPAETSOMMER (AUTUMN_FEED_MISSING); in HERBST gilt der Kalendertag von TRACHTENDE, höchstens der Beginn von HERBST (Annahme 48) Jahr heute
A6 FEED (DO) nicht WINTERRUHE; Futter der letzten Durchsicht WEAK oder CRITICAL, sonst Volkskarte CRITICAL (FEED_LOW, Beleg source INSPECTION oder COLONY_CARD); keine Fütterung seit der Durchsicht (ohne Durchsicht: seit dem späteren von Anlagetag und heute − 60 Tage) ID der Durchsicht oder none heute
A7 HONEY_SUPER (DO) Phase ab KIRSCHE bis vor LINDE (Reihenfolge PHENOLOGY_PHASES), colonyStrength einer Durchsicht ab springSince des Jahres (sonst ab 1. Januar) ≥ honeySuperMinFrames (HONEY_SUPER_CHECK) Jahr heute
A8 QUEEN_REPLACE (PLAN) SCHWARMZEIT oder SOMMER, Königin gealtert (isQueenAged, queenMaxAgeYears; QUEEN_OLD) <Königin>-<Jahr> heute
A9 WINTER_CHECK (DO) HERBST, kein WinterCheck AUTUMN mit winterYear = Jahr (WINTER_CHECK_AUTUMN_MISSING) <Jahr>-AUTUMN heute
A10 WINTER_CHECK (DO) FRUEHJAHR, Einwinterung des Vorwinters vorhanden, SPRING fehlt (WINTER_CHECK_SPRING_MISSING) <Jahr−1>-SPRING heute

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; alle Regeln sind einzeln über config.rules schaltbar. Der Grund von A4 und A5 nennt die Quelle der Phase aus SeasonState.phaseSource (gemeldet, geschätzt, Kalender).

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 mit tasks: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 und URGENT = 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 wie FOLLOW_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 Mitglied DO ist. 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“) und suggestionIds aller Mitglieder; dismissible ist wahr, wenn ein Mitglied ein Vorschlag ist (Verwerfen sendet alle IDs).
  • Aktion: CAPTURE (Formular-Link; auch Ersatz, wenn tasks:create fehlt und die Absicht ein Formular hat), ACCEPT (nur mit tasks:create), CLEANUP, OPEN_TASK (/tasks). Ohne mögliche Aktion entfällt der Schritt. withdrawalUntil (nur mit treatments:read) und inspectionDueOn (nur mit inspections:read, null bei 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-overview trägt je Volk zusätzlich occupiedFrames, inspectionDueOn, segment (oder null) und nextStepTitle; der Offline-Pack enthält nextSteps je 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) nimmt date (JJJJ-MM-TT, nicht in der Zukunft: 400 „Das Datum liegt in der Zukunft.“; Standard heute) und queenSource (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 = QUEEN und eine spätere Zuordnung (QueenAssigned) haben dieselbe Absicht QUEEN_ACCEPTANCE: Wer zuerst behandelt wird, gewinnt, der zweite schlägt nichts vor.
  • chains.queen-cells rä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 offener SWARM_CHECK löst sich nur, wenn seine Basis-Durchsicht älter ist als die aktuelle).
  • Der Plan der Kette SPLIT_SCHEDULE kommt aus den Schritten der Variante; das früheste Datum ist splitOn + 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 sowie seit T4b bei der Schleuderung (Handler withdrawal.extraction-check, Abschnitt 11.12).

  • Warnungen. POST/PATCH /harvests und /bottling-batches antworten mit warnings (Format PlausibilityWarning: Code WITHDRAWAL_ACTIVE, Stufe WARNUNG; Stufen HINWEIS, 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 von harvests:read und treatments:read, Link /harvests/<id>, Schlüssel withdrawal-conflict:<Ernte>:<Volk>), auch wenn Schleuderung, 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 (Job time-events) löst notifications.withdrawal-ended aus: INFO „ ist wieder erntefähig“ (Kategorie WITHDRAWAL, harvests:create + treatments:read, Link /colonies/<id>, Schlüssel withdrawal-ended:<Behandlung>:<endedOn>). Der Handler schweigt, wenn WITHDRAWAL_ENDED.notify aus ist, das Volk nicht lebt, die Behandlung fehlt, ihr Ende nicht mehr endedOn ist (veraltetes Ereignis) oder ein weiteres Fenster des Volkes an diesem Tag läuft.
  • Kalender. Die Ansicht bekommt den Typ WITHDRAWAL als Spanne (date = erster Tag, endDate = letzter Tag einschließlich, id withdrawal:<Behandlung>), und zwar wie die Behandlungstermine nur mit treatments:read; eine ungültige from/to-Angabe antwortet 400. Der M365-Kalender bekommt einen ganztägigen Mehrtagestermin „Wartezeit · “ (date = erster Tag, endDate exklusiv = erster erntefähiger Tag); ohne Wartezeittage oder bei einem Fenster von null Tagen (withdrawalSpanOf) entfällt der Termin (Eintrag SKIPPED, ein vorhandenes Graph-Event wird gelöscht). Abschaltbar über syncWithdrawal (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, SEASON und seit T6 COMPLIANCE („Behörden & Prüfung“, für die Hinweise „Bestand geändert“ und „TSK-Stichtag“, Abschnitt 12.1); 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 schreibt dieser Endpunkt nie (nur der Telegram-Sender setzt es auf aus, Abschnitt 11.13). 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, Saison-Zeile, siehe unten). 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.

Saison-Zeile. Saisonwechsel kommen nur hier vor, nie als Einzelmeldung (bewusste Abweichung von der Spec, Annahme 47). Die Zeile entsteht aus den SeasonPhaseChanged-Ereignissen mit occurredAt im Fenster [von, bis): bis ist der Beginn des Schritts, von das digestWindowEnd des jüngsten abgeschlossenen daily-evaluation-Laufs (SUCCEEDED oder FAILED, unter den letzten 10), der eines gemerkt hat, sonst bis − 24 Stunden. Der nächste Lauf beginnt damit, wo der vorige aufhörte; jeder Wechsel steht genau einmal da, auch einer, den eine Meldung tagsüber nach 04:30 ausgelöst hat. Ein Lauf, der keine Übersicht schrieb (Wiederholung am selben Tag, kein Inhalt), merkt kein Fensterende, sodass das nächste Fenster weiter zurückreicht. Je Stand werden die Ereignisse zusammengezogen (erster vorheriger bis letzter Abschnitt); reine Phasenwechsel, der erste Zustand eines Jahres (kein vorheriger Abschnitt) und Wechsel ohne Nettoänderung ergeben keine Zeile, ebenso Stände ohne lebende Völker. Ein Wechsel vorwärts lautet „Stand {Name}: {Abschnitt} beginnt. Durchsicht alle {n} Tage.“, ein Rückschritt (zurückgezogene Meldung; HERBST → WINTERRUHE zählt als vorwärts) neutral „Stand {Name}: wieder {Abschnitt}.“. Bekannte Ränder: Ein manueller Wiederholungslauf am selben Tag schreibt nichts, ein Ereignis, das nach bis committet, aber davor gestempelt ist (eine über 04:30 offene Transaktion), kann fehlen; beides ist selten und hingenommen.

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“.

11.11 Pflanzenentwicklung und Wetter

Stand: Schema, Wetterquelle, Berechnung, Job-Schritt, die Meldungs-API (Melden, Bearbeiten, Zurückziehen mit Neuberechnung) und die Oberfläche zum Melden (Offline-Warteschlange eingeschlossen) sind fertig.

Tabellen (Migration 20261015100000_t4_phenology, Annahme 43). PhenologyReport hält eine gemeldete Phase (Stand oder alle Stände, Datum, Rücknahme per revokedAt; Index (apiaryId, phase, date)). ApiaryPhenology (apiaryId, year) ist der abgeleitete Saisonzustand. ApiaryWeatherDay ist der Wetter-Cache (Tagesmittel, source forecast oder archive, fetchedAt). Rechte phenology:read, phenology:create, phenology:update (Gruppe „Pflanzenentwicklung“). Backup: Abschnitt 8.

Wetterquelle. Open-Meteo, Annahme 42. Die Schnittstelle WeatherHistorySource (src/weather/history/) kapselt den Adapter OpenMeteoHistorySource. Das Tagesmittel ist temperature_2m_mean mit timezone=Europe/Berlin, also der Mittelwert der Stundenwerte des Berliner Kalendertags, auch an den Tagen der Zeitumstellung mit 23 und 25 Stunden. Die letzten 90 Tage kommen vom Forecast-Endpunkt (past_days=92, forecast_days=1), ältere Lücken vom Archiv (start_date, end_date). Das Fenster bestimmt der Dienst, nicht der Adapter. Werte außerhalb −50…50 °C und Nicht-Zahlen werden null. Jeder Fehler ist ein WeatherSourceError mit festem Text; er enthält weder URL noch Schlüssel noch Ursache. Konfiguration: Abschnitt 9. Jede Anfrage trägt User-Agent: ApiCore/<Version> (+<WEATHER_USER_AGENT_CONTACT>), auch die Wetter-Anzeige der API (WeatherService, die dieselbe Konfiguration und Rundung nutzt).

Schleife phenology-weather (Abschnitt 10; WeatherHistoryService.syncOnce):

  • Takt stündlich, erster Durchlauf 10 s nach dem Start des Workers.
  • Aktive Stände mit Koordinaten, gruppiert nach dem auf 0,01° gerundeten Punkt: Stände am selben Punkt teilen eine Anfrage.
  • Fehlend heißt: keine Zeile vom 1. Januar bis gestern (Berliner Tag), oder meanTempC ist in den letzten 90 Tagen null (ältere null-Werte werden nicht erneut geholt). Ein Stand, der nach dem 1. Januar angelegt wird, bekommt die Tage ab 1. Januar unabhängig vom Anlagedatum. Frühere Jahre für die Kalibrierung werden nicht nachgeholt; die Aufbewahrung beträgt 4 Jahre.
  • Beide Bereiche eines Punkts werden zuerst geholt, dann in Blöcken von höchstens 200 Upserts je Transaktion geschrieben (WEATHER_WRITE_CHUNK_SIZE). Ein Fehler schreibt für den Punkt nichts, wird ohne URL geloggt und im nächsten Takt wiederholt.
  • Abstand 300 ms zwischen den Punkten. Bei HTTP 429 endet der Durchlauf, und bis Retry-After (Sekunden oder HTTP-Datum, höchstens 24 h) wird auch über Takte hinweg nichts angefragt. Ein 503 mit Retry-After wirkt ebenso, ein 503 ohne ist ein gewöhnlicher Fehler. Drei fehlgeschlagene Punkte hintereinander beenden den Durchlauf (endedEarly); ein Erfolg setzt die Zählung zurück.
  • Korrektur: Eine forecast-Zeile, die älter als das 90-Tage-Fenster ist, gilt als fehlend und wird durch den Archivwert ersetzt, höchstens einmal in 7 Tagen (FORECAST_RECHECK_MS, nach fetchedAt). Hat das Archiv keinen Wert, bleibt der gespeicherte stehen und nur fetchedAt wird aktualisiert; ein null aus dem Archiv überschreibt nie einen gespeicherten Wert.
  • Je Durchlauf ein Log mit points, daysWritten, failedPoints, endedEarly.
  • Der Job cleanup löscht ApiaryWeatherDay vor dem 1. Januar von (Berliner Jahr − 4); Ergebniszähler weatherDays.

Berechnung. Annahme 36 beschreibt Phase, GTS, Kalibrierung und Rückfall. Der Job-Schritt phenology (Abschnitt 10) und künftig jede Meldung oder Rücknahme rufen PhenologyStateService.recomputeIn. Die Funktion sperrt zuerst die Zeile Apiary und liest dann die gespeicherte Zeile, lädt die Meldungen der letzten 3 Jahre (Filter in SQL: eigener Stand oder alle Stände, nicht zurückgezogen) und das Wetter des Jahres sowie früherer Jahre mit Kalibrierungsmeldung, kalibriert die Schwellen und schreibt ApiaryPhenology per Upsert. Die Kalibrierung eines Vorjahres nimmt die GTS bis zum Vortag des ersten Meldetags, also das, was der Morgen jenes Tages gezeigt hätte. recomputeAll liest Regel und Meldungen einmal vorab und rechnet dann je aktivem Stand in einer eigenen Transaktion; ein gescheiterter Stand wird gezählt und geloggt, der Lauf geht weiter. Eine nach dem Vorab-Lesen zurückgezogene Meldung sieht nur die Neuberechnung dieser Meldung selbst. ApiaryPhenology speichert keine Notiz, kein reportedOn und kein springSince; seasonStateFromRow (src/phenology/phenology-row.ts) leitet sie ab (Notiz aus den Koordinaten, reportedOn leer). Die gespeicherte GTS ist nur gesetzt, wenn sie nutzbar war. Beim ersten Zustand eines Jahres wird gegen den Kalenderzustand verglichen, damit ein Rollout mitten in der Saison kein falsches SeasonPhaseChanged erzeugt. Der Handler bei PhenologyCorrected ruft recomputeIn mit allowBackward: true auf.

Meldungen (PhenologyModule, Annahme 47). Routen: GET /phenology/state (apiaries:read; je aktivem Stand Zustand ohne Koordinaten, reportedOn nur bei Quelle REPORT der früheste Meldetag der geltenden Phase), GET /phenology/reports?year= (phenology:read, Standard laufendes Jahr, auch zurückgezogene mit revokedAt, Stand- und Nutzername), POST /phenology/reports (phenology:create, @Idempotent(); Phase, Datum JJJJ-MM-TT, apiaryId oder null für alle, Notiz höchstens 500 Zeichen), PATCH /phenology/reports/:id (phenology:update; null auf Phase oder Datum: 400, auf Stand oder Notiz: leert), POST /phenology/reports/:id/revoke (phenology:update, @Idempotent(), schon zurückgezogen: 409). Datum in der Zukunft: 400 „Das Datum liegt in der Zukunft.“, ungültiger Tag: 400 „Das Datum ist ungültig.“, unbekannter Stand: 404 „Stand nicht gefunden.“, Bearbeiten einer zurückgezogenen Meldung: 409 „Die Meldung wurde schon zurückgezogen.“. Jede Änderung ist eine Transaktion aus Sperre auf die Meldung (lockRowForUpdate, danach „vorher“ neu lesen), Schreiben, Audit-Eintrag mit Vorher-Wert und Ereignis; Handler phenology.recompute und phenology.recompute-apiary (Abschnitt 10) berechnen danach die Stände neu. Saisonwechsel erscheinen in der Morgenübersicht (Abschnitt 11.9).

Oberfläche (Seite eines Standes). Der Kopf ApiarySeason zeigt Saisonabschnitt (SeasonChip), Phase und Quelle („gemeldet am TT.MM.“, „geschätzt“, „Kalender“) und bei schwacher Wetterbasis einen Hinweis. Das Intervall im Chip („alle 7 Tage“) kommt aus GET /phenology/state (intervalDays je Stand): das jetzt geltende Durchsichts-Intervall des Segments aus der Regel INSPECTION_INTERVAL, über dieselbe Funktion inspectionIntervalOf (src/season/inspection-due.ts) wie inspectionDue[].intervalDays und der Digest. Es ist null in der Winterruhe und bei inaktiver Regel; dann zeigt der Chip nur die Saison. Der Knopf „Pflanze blüht“ (phenology:create) öffnet das Blatt „Entwicklung melden“ (PhenologyReportSheet): acht Zeilen (Icon und Wort, 56 px), ein Tipp meldet die Phase für heute (Berliner Tag), vorgewählt „Für alle Stände“, wahlweise „Nur “. Jede Phase und jeder Bereich ist eine eigene Meldung mit eigenem Idempotency-Key. Ein Serverfehler bleibt im Blatt sichtbar; während einer Meldung oder Rücknahme lässt es sich nicht schließen. Darunter stehen die Meldungen des Jahres (phenology:read; eigener Stand und „alle Stände“, zurückgezogene durchgestrichen) mit „Zurückziehen“ (phenology:update, nur online, sonst „Nur mit Verbindung“); das Zurückziehen einer Meldung für alle Stände fragt über den ConfirmDialog nach. Offline landet die Meldung im IndexedDB-Speicher pending-phenology-reports (apicore-offline, Version 4; gleiche Form wie die übrigen Einzel-Warteschlangen mit Schlüssel und Besitzer; Anzeige „Pflanzenmeldung“). Der Sync sendet ihn mit den anderen Warteschlangen (POST /phenology/reports, originaler Schlüssel; abgelehnte Einträge bleiben mit der Antwort des Servers stehen). Danach lösen Offline-Band und Warteschlangen-Sheet ein globales invalidateQueries() aus, sodass Saisonzustand und Meldungsliste neu laden. Eine Meldung für einen früheren Tag bietet die Oberfläche nicht an (nur die API per PATCH). Dass ein erneutes Öffnen des Blatts dieselbe Phase doppelt melden kann, ist Annahme 55.

11.12 Honig-Kette und Bewegungsbuch

Stand: Ereignisse, Vorbelegung, Warnungen, Wartezeit bei der Schleuderung, der Webhook, die Laufzeit-Buchungen des Bewegungsbuchs und die Qualitätsprüfung der Abfüllung (API) sind fertig, ebenso die Oberfläche von Schleuderung, Honigcharge und Bewegungsbuch sowie die Abfüllungsmaske mit Artikelwerten und der Wassergehalt-Bestätigung (409).

Ereignisse. ExtractionRecorded/ExtractionCorrected und HoneyBatchRecorded/HoneyBatchCorrected entstehen in der Schreib-Transaktion mit dem Audit-Eintrag (Abschnitt 10). Der Webhook batch.created kommt über webhooks.forward aus HoneyBatchRecorded; HoneyBatchCorrected sendet nichts, wie vorher beim Ändern.

Sperren. ExtractionsService.update und HoneyBatchesService.update sperren die Zeile zuerst und lesen „vorher“ danach. Reihenfolge Schleuderung vor Honigcharge: create einer Charge sperrt die Schleuderung vor dem Schreiben (lockRowForUpdate). update liest die Schleuderungs-ID vorab, sperrt die Schleuderungen (bisherige und neue, lockRowsForUpdate), dann die Charge und liest sie neu; ist die Charge inzwischen zu einer anderen Schleuderung gewandert, antwortet die Anfrage 409. Die Mengenprüfung der Chargen einer Schleuderung läuft so unter der Schleuderungs-Sperre.

Vorbelegung (nur Vorschläge, Annahme 45; die Antwort-Klassen tragen in Swagger die Beschreibung „Vorschlag, keine Messung – nie stillschweigend übernehmen“):

  • GET /extractions/prefill?harvestIds=a,b (Recht extractions:create, 1–50 verschiedene IDs, Komma-Liste oder wiederholter Parameter; vor :id deklariert): amountKg = Summe der Nettogewichte, nur wenn alle bekannt sind; honeyType nur, wenn alle Ernten dieselbe nicht leere Sorte haben; sources mit Berliner Tag; Hinweis HARVEST_NET_MISSING; Warnung WITHDRAWAL_ACTIVE. Unbekannte IDs: 400 mit derselben Meldung wie beim Anlegen.
  • GET /honey-batches/prefill?extractionId= (Recht honey-batches:create, vor :id): remainingKg = Menge der Schleuderung minus Summe der vorhandenen Chargen (nie unter 0, null ohne Menge der Schleuderung), honeyType der Ernten, waterContent der Schleuderung, Warnungen. Unbekannte Schleuderung: 404.
  • Beide Routen hängen an den Anlege-Rechten, weil sie dieselben Daten liefern wie der Anlege-Ablauf.

Plausibilitätswarnungen (src/honey-batches/honey-plausibility.ts; Antwort warnings bei POST und PATCH, nie 4xx; Toleranz 0,5 g; Zahlen deutsch formatiert):

  • EXTRACTION_EXCEEDS_HARVEST (WARNUNG): Menge der Schleuderung größer als die Summe der Nettogewichte; nur wenn alle Nettogewichte bekannt sind.
  • HARVEST_NET_MISSING (HINWEIS): mindestens eine Ernte ohne Nettogewicht, ein Vergleich ist nicht möglich.
  • BATCHES_EXCEED_EXTRACTION (WARNUNG): Summe der Anfangsmengen der Chargen größer als die Menge der Schleuderung. Eine Charge ohne initialAmount zählt nicht (auch nicht für den Rest der Vorbelegung); der frühere Rückfall auf amount ist entfernt.
  • WITHDRAWAL_ACTIVE bei der Schleuderung: Der Handler withdrawal.extraction-check und die Antwort prüfen über WithdrawalCheckService.conflictsForExtraction. Die Warnung sieht jeder, der eine Schleuderung erfasst, auch ohne treatments:read. Die Meldung nutzt denselben Schlüssel wie Ernte und Abfüllung (Abschnitt 11.8), ein Konflikt wird also nur einmal gemeldet.

Bewegungsbuch. Annahme 44. Schema und Rückrechnung: Migration 20261015100100_t4_honey_ledger, BatchMovement.honeyBatchId (Fremdschlüssel Restrict, Index) und sourceBottlingBatchId (Fremdschlüssel SetNull, Index). Die Invariante amount = initialAmount + Summe(quantity) / 1000 prüfen die Migration und der Import alter Bundles (Abschnitt 8) mit einer SQL-Abfrage (INVARIANT_SQL im Test-Helfer test/utils/honey-ledger.ts). Haltbarkeit: Product.shelfLifeMonths (Ganzzahl, optional; Migration 20261015100200_t4_product_shelf_life). Laufzeit-Buchungen: Annahme 44.

API des Buchs. GET /honey-batches/:id/movements (honey-batches:read; 404 für eine unbekannte Charge) antwortet { items, hasMore } mit höchstens 200 Buchungen, neueste zuerst (id, type, quantityGrams, reason, notes, sourceBottlingBatchId, createdAt, userId). POST /honey-batches/:id/movements (honey-batches:update, @Idempotent()) nimmt { type: 'WITHDRAWAL' | 'CORRECTION', deltaKg, reason?, notes? } (deltaKg endlich, ungleich 0, Betrag höchstens 100 000; Notiz höchstens 500 Zeichen) und antwortet mit der Buchung. Eine WITHDRAWAL braucht einen Grund („Bitte einen Grund angeben.“) und eine negative Menge („Eine Entnahme braucht eine negative Menge.“); bei einer CORRECTION ist ein Grund erlaubt und wird gespeichert. Eine Charge ohne erfasste Menge ergibt 400 „Die Menge dieser Honigcharge ist nicht erfasst. Bitte zuerst die Menge eintragen.“; ein widersprüchlicher Bestand (initialAmount gesetzt, amount leer) 409. Die Buchung schreibt Audit HoneyBatch UPDATE und HoneyBatchCorrected. Die Oberfläche zeigt die Startmenge getrennt von der Liste der Buchungen und liest .items.

Antwort-Form. Antworten von POST/PATCH /extractions und /honey-batches tragen warnings. Eine Bestätigung (acknowledgedWarnings, 409) gibt es hier nicht; sie gehört zur Abfüllung (nächster Absatz).

Qualität vor der Abfüllung (Annahmen 52 und 53; src/common/quality/water-content.ts, src/bottling/bottling-defaults.ts). Der Wassergehalt der Honigcharge wird gegen die Regel HONEY_WATER_CONTENT geprüft (Standard: Warnschwelle 18 % nach D.I.B. „Echter Deutscher Honig“, Bestätigungsgrenze 20 % nach HonigV Anlage 2; je Honigsorte überschreibbar, etwa { "Heidehonig": { confirmAbovePercent: 23 } }, Sorte ohne Beachtung der Groß-/Kleinschreibung, sonst exakt; ein Eintrag darf nur eine der beiden Grenzen setzen). Es zählt die neueste HoneyMeasurement (nach measuredAt), sonst HoneyBatch.waterContent.

  • WATER_CONTENT_HIGH (WARNUNG): über der Warnschwelle. WATER_CONTENT_MISSING (HINWEIS): kein Wert. WATER_CONTENT_ABOVE_LEGAL (BESTÄTIGEN): streng über der Bestätigungsgrenze; genau 20,0 % warnt nur mit WATER_CONTENT_HIGH. JAR_SIZE_DIFFERS (HINWEIS): die gesendete Glasgröße weicht von Product.packagingSize ab.
  • POST /bottling-batches antwortet bei WATER_CONTENT_ABOVE_LEGAL mit 409 { statusCode, code: 'CONFIRMATION_REQUIRED', message, waterContent, warnings }, solange die Bestätigung fehlt. warnings enthält die Qualitätsbefunde der Anfrage (Wassergehalt und Glasgröße), nicht die Wartezeitwarnungen. Die Prüfung läuft in der Transaktion nach der Sperre der Honigcharge und vor dem Schreiben: Bei 409 entstehen weder Abfüllung noch Buchung, Ereignis oder Audit-Eintrag; eine zwischenzeitlich erfasste Messung zählt.
  • Die bestätigte Wiederholung sendet denselben Body plus acknowledgedWarnings: ['WATER_CONTENT_ABOVE_LEGAL'] (jeder andere Inhalt: 400) und acknowledgedWaterContent (Zahl 0–100) mit dem waterContent der 409-Antwort. Die Bestätigung gilt nur, wenn der unter der Sperre geprüfte Wert genau diesem Wert entspricht; sonst (neue Messung, Code ohne Wert, anderer Wert) folgt eine frische 409 mit dem neuen Wert. Der Idempotenz-Schlüssel wird bei Fehlern freigegeben, die Wiederholung darf also denselben Idempotency-Key nutzen; eine weitere Anfrage mit diesem Schlüssel nach dem 201 gibt die gespeicherte Antwort wieder.
  • Die 201-Antwort trägt zuerst die Qualitätsbefunde (auch den bestätigten WATER_CONTENT_ABOVE_LEGAL), dann die Wartezeitwarnungen. PATCH /bottling-batches/:id und die Messungs-Routen der Honigcharge (POST und PATCH …/measurements, Antwort mit warnings) liefern nie 409. Das PATCH meldet Qualitätsbefunde nur, wenn sich Honigcharge oder Glasgröße ändern.
  • Vorbelegung GET /bottling-batches/prefill?honeyBatchId=&productId=&date= (bottling:create, vor :id deklariert): { jarSize, jarSizeSource: 'PRODUCT' | null, bestBeforeDate, shelfLifeMonths, waterContent, warnings }. Die Glasgröße kommt vom Artikel (packagingSize), das MHD aus Product.shelfLifeMonths (1–60, beim Anlegen und Ändern des Artikels setzbar, null löscht): Abfülltag plus Monate, gekürzt auf das Monatsende (31.01. + 1 Monat = 28.02.); ein Zeitstempel zählt als sein Berliner Tag, date ist standardmäßig heute. honeyBatchId fehlt: 400; unbekannte Charge oder Artikel: 404; Artikel nicht BOTTLED_HONEY: 400. Beim Anlegen gewinnt ein gesendeter Wert, sonst der Artikelwert; fehlt beides, antwortet die API mit 400 („Glasgröße und MHD fehlen. …“, „Ohne Haltbarkeit am Artikel bitte ein MHD eintragen.“, „Ohne Packungsgröße am Artikel bitte eine Glasgröße eintragen.“). Unmögliche Tage (30.02.) in date, bestBeforeDate und der Vorbelegung ergeben 400 „Das Datum ist ungültig. …“.

Oberfläche der Honig-Kette. Vorbelegungen heißen „Vorschlag aus …“ („aus 2 Ernten“, „aus den Ernten: Raps“, „aus der Schleuderung (Rest)“) und werden nur durch einen Knopf übernommen. Laden die Hinweise nicht, zeigt PrefillStatus (feste aria-live-Zeile) „Hinweise (z. B. Wartezeit) konnten nicht geladen werden.“ mit „Erneut versuchen“; Speichern bleibt erlaubt, das Backend prüft (Annahme 53). lib/honey-batches/errors.ts (describeSaveError, saveErrorText) ist der einzige Ort, der einen fehlgeschlagenen Speichervorgang liest (400-issues je Feld, Nest-message[] durch deutschen Text ersetzt, 409-Meldung durchgereicht, Netzfehler). Das Anlegen einer Honigcharge bietet „Wassergehalt (%)“ mit dem Vorschlag aus der Schleuderung. Die Detailseite hat die Karte „Bewegungen“ (Buchungen mit Link zur Abfüllung, Grund und Korrektur mit Vorzeichen; bei hasMore der Hinweis „Ältere Buchungen werden nicht angezeigt“). „Entnahme“ (nur WITHDRAWAL, Grund Pflicht und nicht vorgewählt, Menge mindestens 1 g und höchstens der Rest) und „Menge korrigieren“ (Ausgangswert beim Öffnen gesperrt, Differenz sichtbar, bei 0 „Keine Abweichung“, Grund optional, Buchung als „Korrektur“ beschriftet) gibt es mit honey-batches:update und erfasster Menge. Beide Auslöser sind offline gesperrt („Nur mit Verbindung“) und buchen mit eigenem Schlüssel je Buchung. Alle Abfragen der Detailseite liegen unter dem Schlüsselpräfix ["honey-batches"], das beide Dialoge ungültig machen. Die Bestätigung beim Wassergehalt (409) gehört zur Abfüllungsmaske (nächster Absatz), nicht zu diesen Dialogen.

Abfüllungsmaske (/bottling/new, Annahme 57). Sie fragt GET /bottling-batches/prefill je Honigcharge, Produkt und Datum ab; die letzte Antwort bleibt beim Nachladen stehen, zählt für Beschriftung, Differenz-Hinweise und Wassergehalt aber nur für genau diese Charge, dieses Produkt und diesen Tag. Glasgröße und MHD stammen als Stammdaten vom Artikel (keine Beobachtung) und nennen ihre Herkunft („aus Artikel: 24 Monate“, „aus Artikel: 250 g“, „aus Entwurf“, bei fehlgeschlagenem Abruf „nicht aus Artikel“). Eine eigene Eingabe gewinnt, bis sich das Produkt ändert (dann werden die Eingaben zurückgesetzt); ein Datumswechsel lässt sie stehen. Ein wiederhergestellter Entwurf behält die Kennzeichnung getippter Werte. Weichen Glasgröße oder MHD vom Artikel ab, erscheint der Hinweis schon vor dem Speichern („Glasgröße weicht vom Artikel ab (Artikel: 250 g).“). Wassergehalt der Charge und Server-Hinweise stehen in einem dauerhaften Statusbereich (Symbol plus Wort); PrefillStatus meldet Laden und Fehler mit „Erneut versuchen“, und „Abfüllung anlegen“ ist gesperrt, solange die Artikelwerte laden („Artikelwerte werden geladen …“). Die Abfüllung wird nicht offline eingereiht: Offline ist der Knopf mit sichtbarem Grund „Nur mit Verbindung“ gesperrt.

Antwortet das Anlegen mit 409 CONFIRMATION_REQUIRED, öffnet ein Dialog mit Fokusfalle den gemessenen Wert („Wassergehalt 20,5 % über der gesetzlichen Grenze. Trotzdem abfüllen?“). „Trotzdem abfüllen“ (genau eine Anfrage, auch bei Doppelklick) sendet denselben Body mit acknowledgedWarnings und acknowledgedWaterContent unter demselben Idempotency-Key; eine frische 409 mit neuem Wert fragt erneut. „Abbrechen“ schreibt nichts, behält das Formular, gibt den Fokus zurück und erneuert den Schlüssel. Der Schlüssel wird außerdem erneuert, sobald der Body vom zuletzt gesendeten abweicht (auch durch eine geänderte Vorbelegung), und nach einem Erfolg. Fehler beim Speichern liest allein describeSaveError.

Haltbarkeit und Gewicht am Artikel. Das Artikelformular (neu und bearbeiten) hat „Haltbarkeit (Monate)“ (ganze Monate 1–60, leerbar) und „Gewicht pro Glas (g)“ (packagingSize, ganze Gramm 1–10000). Im Bearbeiten-Dialog wird das Gewicht nur geprüft, wenn es geändert wurde; ein alter Dezimalwert wird nie umgerechnet. Die Prüfung von packagingSize im Backend (@IsInt, 1–10000) ist ein Folgepunkt, zuvor wird die Produktionsdatenlage geprüft (Rollout-Schritt mit Freigabe).

11.13 Telegram

Stand: Schema, Client, Prüfung beim Start, Planung, Senden, Verbinden, Empfangen, die Betriebsansicht und die Oberfläche (Profil-Karte, Step-up, Systemkarte) sind fertig (TelegramModule importiert WorkerRuntimeModule und WorkerModule). NotificationPreference.telegramEnabled schaltet das Verbinden ein, das Trennen (und der Sender bei 403) aus (Abschnitt 11.9).

Tabellen (Migration 20261015110000_t4_telegram). Enums NotificationChannel und NotificationDeliveryStatus. TelegramLink (Chat-Bindung eines Nutzers; eindeutig je Chat), TelegramLinkToken (Einmal-Token zum Verknüpfen), NotificationDelivery (Zustell-Warteschlange), TelegramPollState (genau eine Zeile singleton, per CHECK id = 'singleton'). Alle Fremdschlüssel auf User laufen mit ON DELETE CASCADE, ebenso Notification.deliveries. Keine dieser Tabellen ist Teil des Backups (Abschnitt 8).

NotificationDelivery. Eindeutig je (notificationId, channel), Index auf notificationId. Der CHECK NotificationDelivery_target_check verlangt entweder Meldung und Nutzer oder Vorlage und Chat. Zustellungen mit Vorlage haben chatId und kein userId; sie entfernt die Nutzer-Kaskade nicht. Ihre Aufräumregeln nach Alter stehen im Job cleanup (Abschnitt 10).

Dritte Outbox. NotificationDelivery nutzt src/common/outbox/outbox-claims.ts wie Ereignisse, Webhooks und Kalender: claimBatch, settleSuccess, settleFailure, releaseStaleClaims. Neu sind deferClaim (zurück auf PENDING nach einer Wartezeit, Versuche unverändert, Wartezeit höchstens 24 h) und settleSkipped (SKIPPED mit Grund, höchstens 200 Zeichen). Beide sind Compare-and-Set auf id + claimedAt und nutzen die DB-Uhr. Weckkanal notification_delivery (worker-wakeup.ts); NotificationDelivery steht in der Sperr-Karte ganz zuletzt.

TelegramClient (src/telegram/; Konfiguration per Token TELEGRAM_CONFIG, bereitgestellt von TelegramModule). Aufrufe getUpdates (allowed_updates ["message"]; Updates ohne message.chat werden übersprungen) und sendMessage. Sicherheit (Annahme 46, Abschnitt 9):

  • Der Token steht nie in Logs, Fehlern oder lastError. TelegramApiError trägt Methode, Status, bereinigte Beschreibung und retry_after; describeTelegramFailure folgt cause höchstens 5 Ebenen tief und gibt nie die Meldung zurück.
  • Nur HTTPS zu api.telegram.org; redirect: 'error', eine Weiterleitung ergibt „network error“ ohne URL und Token; Antwortkörper werden gestreamt und bei 256 kB abgebrochen.
  • retry_after wird auf 1–3600 s begrenzt (Nicht-Ganzzahlen und Texte werden null).
  • Der Text wird bereinigt (sanitizeTelegramText): Steuerzeichen und die Richtungs- und Nullbreitenzeichen U+200B–200F, U+202A–202E, U+2066–2069 entfernt, höchstens 600 Zeichen nach Codepunkten (kein halbes Surrogatpaar) mit Auslassungspunkten; Klartext, kein parse_mode.
  • Ein fehlerhaft geformter Token macht den Client unbrauchbar („bot token not configured“), ohne dass er ruft.
  • assertTelegramToken(role, env, logger?) (src/runtime/startup-checks.ts) prüft nur die Rollen worker und all. Ein fehlender Token ist in Ordnung. Ein fehlerhafter (in Produktion auch ein Platzhalter wie change-me…) wirft nie: Fehler-Log ohne Wert, Telegram bleibt aus. Ein Benutzername ohne Token oder umgekehrt warnt nur im Log.
  • TELEGRAM_BOT_USERNAME verliert ein führendes @ und muss ^[A-Za-z][A-Za-z0-9_]{4,31}$ erfüllen, sonst gilt er als nicht gesetzt (telegramBotAvailable() falsch) mit Warnung.
  • Der Wächter no-network-outside-loops.spec.ts hält fest, dass das Netzwerk nur in den erlaubten Dateien und nur aus Schleifen genutzt wird (Abschnitt 9).

Planung. NotificationDeliveryPlanner.planIn(db, notifications) (Abschnitt 10, „Zustellplanung“) liest in einer Abfrage die Einstellungen (telegramEnabled, Verbindung, telegramCategories) und die DB-Uhr, legt per createMany({ skipDuplicates }) (eindeutig je Meldung und Kanal) die Zustellungen an und weckt den Worker nur, wenn Zeilen entstanden. Standardkategorien TASK_DUE, DIGEST und seit T6 COMPLIANCE (Migration 20261016100400_t6_notification_compliance_default, Abschnitt 12.1); SUGGESTION, WITHDRAWAL, SEASON und SYSTEM schaltet der Nutzer zu. COMPLIANCE hat wie SYSTEM keine Tageszeit (nur die Ruhezeit) und ist 24 Stunden frisch. Der Text wird nicht gespeichert, sondern beim Senden aus der Meldung gerendert. Die Zustellzeit (availableAt) folgt Annahme 49 (Morgenzeit, Ruhezeit, Zeitumstellung).

Texte (src/telegram/telegram-texts.ts). TELEGRAM_TEMPLATES hält die festen Texte, renderTelegramText baut daraus den Klartext: ohne Details nur „Neue Meldung in ApiCore.“ und Link, Standardlink /dashboard, ein Schrägstrich am Ende von FRONTEND_ORIGIN wird toleriert. Höchstens 600 Zeichen nach Codepunkten; die Meldung wird mit Auslassungspunkten gekürzt, der Link bleibt ganz, Steuer- und unsichtbare Zeichen entfallen. Ohne FRONTEND_ORIGIN entfällt der Link (Annahme 46).

Senden (TelegramSender, Schleife telegram-send, Abschnitt 10). Ein Durchlauf übernimmt höchstens 25 Zustellungen (claimBatch, älteste zuerst). Je Batch geht höchstens eine Zustellung je Nutzer bzw. Chat hinaus, die übrigen kommen 1,1 s später (deferClaim); ein Speicher je Chat (10 s) hält auch aufeinanderfolgende Durchläufe und Bot-Antworten auseinander, und zwischen zwei Sendungen liegen mindestens 40 ms (25 pro Sekunde). drain wartet nach einer Runde mit zurückgehaltenen Nachrichten 1,1 s, damit ein Schwall für eine Person nicht auf den nächsten 5-s-Takt wartet; ein Batch ist auf 120 s begrenzt (TELEGRAM_BATCH_BUDGET_MS), danach gehen die übrigen ohne Fehlversuch zurück, ebenso beim Herunterfahren. Vor jeder Sendung prüft eine SQL-Abfrage mit frisch gelesenen Rechten (PermissionResolverService.getFreshPermissions) die Gründe aus Annahme 49 in dieser Reihenfolge: user inactive, telegram disabled, not linked, category off, permission revoked, stale, task already done, dazu notification gone und unknown template; ein Treffer ergibt SKIPPED mit Grund. Antworten des Bots (templateCode) überspringen alle Prüfungen. Die Frische rechnet in SQL (Berliner Tag bzw. 24 Stunden gegen now()).

Antworten von Telegram.

  • 200: DELIVERED.
  • 429: deferClaim um retry_after (ohne Angabe 5 s), kein Fehlversuch; der Sender pausiert für den ganzen Pod bis dahin (pausedUntil, der Rest des Batches geht zurück).
  • 403 und 400 „chat not found“ (Bot blockiert, Chat gelöscht): Bei einer Nutzer-Zustellung sperrt eine Transaktion NotificationPreference (lockRowForUpdate), entfernt über den gemeinsamen Helfer removeLinkIn (siehe „Befehle“) die TelegramLink mit userId und chatId, schaltet nur dann telegramEnabled aus und schreibt die Meldung „Telegram-Zustellung beendet. Der Bot wurde blockiert oder der Chat gelöscht.“ (SYSTEM, HINWEIS), wenn die Verbindung noch auf diesen Chat zeigt; die Zustellung wird SKIPPED („chat unavailable“). Antworten des Bots werden nur SKIPPED.
  • 401 (Token ungültig): deferClaim um 15 min, der Sender pausiert 15 min (monotone Uhr, kein Claim), und system:manage bekommt einmal je Berliner Tag „Telegram: Bot-Token ungültig.“ (Schlüsselpräfix telegram-token-invalid:<Tag>). Scheitert die Meldung, wird das nur geloggt.
  • Alles andere (5xx, Zeitüberschreitung, Netzfehler, sonstige 4xx, unerwartete Ausnahmen): settleFailure mit loggableError(...).message, Wiederholung nach der Outbox-Regel (30 s bis 6 h, nach 8 Versuchen FAILED).

Verbinden. POST /profile/telegram/link-token (jeder angemeldete Nutzer; Step-up mit aktuellem Passwort und bei MFA TOTP; 10 pro Minute; 201; nicht @Idempotent, weil die Antwort ein Geheimnis enthält; Cache-Control: no-store) antwortet { deepLink, expiresAt } mit https://t.me/<Bot>?start=<Token>. Ohne TELEGRAM_BOT_USERNAME ergibt es 409 „Telegram ist nicht eingerichtet.“; das Bot-Token kennt die api nie. Das Token besteht aus 32 Zufallsbytes (Base64url, 43 Zeichen), gespeichert wird nur der SHA-256. Es gilt 10 Minuten (Ablauf nach DB-Uhr) und einmal; ein neues entwertet ältere offene Token desselben Nutzers. Der Job cleanup löscht Token, die seit mehr als 24 h abgelaufen oder verbraucht sind. POST /profile/telegram/unlink (204) trennt.

Empfangen (TelegramUpdatesService.pollOnce, Schleife telegram-updates, Abschnitt 10). Long Polling (getUpdates, timeout=25, allowed_updates ['message']), kein öffentlicher Eingang, nur private Chats. Genau ein Empfänger: eine Lease in TelegramPollState (60 s, nur DB-Uhr, jede Runde verlängert); wer sie nicht hat, wartet 30 s, ebenso nach 409 und nach Fehlern. Das setzt ein einzelnes Worker-Replikat voraus (Abschnitt 7). Jedes Update läuft in einer eigenen Transaktion, in der zuerst der Offset (update_id + 1) per Compare-and-Set auf Lease-Inhaber und alten Stand weitergeschoben wird; geht die Lease verloren, bricht die Transaktion samt Wirkung ab, jedes Update wirkt genau einmal. Nicht auswertbare Updates (etwa ohne message.chat) schiebt der Offset ohne Wirkung weiter, auch am Ende eines Batches (getUpdates liefert { updates, highestUpdateId }). Ein Update, dessen Wirkung immer scheitert, wird nach 5 Versuchen oder 10 Minuten übersprungen (Annahme 51). Verbindungsfehler (Datenbank oder Netz nicht erreichbar, auch TelegramApiError ohne Status) werden gespeichert, zählen aber nie; die Zähler werden nach jedem Batch bereinigt (Einträge hinter dem Offset und ältere als das Fenster). Grenzen und Uhr kommen über das optionale Token TELEGRAM_UPDATES_OPTIONS ({ attempts, windowMs, now }) in den Dienst. Eine Antwort mit Einträgen, aber ohne update_id, gilt als Fehler (lastError „getUpdates answer without update ids“, Offset bleibt, 30 s Wartezeit). Auf 429 wartet der Empfang bis retry_after (ohne Angabe 30 s, höchstens 1 Stunde), ohne DB und Telegram anzufassen. Beim Beenden bricht das Stop-Signal den Abruf ab; WorkerRuntime.onModuleDestroy ruft nach dem Stopp der Schleifen releaseLease() auf (ohne gehaltene Lease ohne Wirkung, auch bei einem Stopp während der 30-s-Wartezeit), der nächste Empfänger kommt sofort zum Zug. lastPollAt steht nach dem Abruf; lastError (fester Text plus Klassenname, nie IDs oder Nachrichtentext) wird erst nach einem Batch ohne Fehler und ohne Überspringen gelöscht.

Befehle. /start <Token> (auch /start@Bot <Token>) verbraucht das Token per Compare-and-Set (nur offen und nicht abgelaufen), legt die Präferenz an, sperrt NotificationPreference aller betroffenen Nutzer in einer Anweisung (aufsteigend) und liest Verbindung und Präferenz erst danach (dieselbe Sperre wie der 403-Pfad des Senders). Es ersetzt eine frühere Verbindung desselben Nutzers (linkedAt beginnt neu), löst die Verbindung eines anderen Nutzers mit demselben Chat (Meldung „Deine Telegram-Verbindung wurde getrennt, weil der Chat mit einem anderen Konto verbunden wurde.“), schaltet Telegram ein und schreibt die Meldung „Telegram verbunden mit „{Vorname}“ am TT.MM.JJJJ. Warst du das nicht? In den Einstellungen trennen.“ (deckt einen mitgelesenen Link auf; Vorname bereinigt, höchstens 64 Zeichen). Ist Nutzer oder Rolle inaktiv, oder das Token ungültig, abgelaufen oder benutzt, antwortet der Bot LINK_INVALID ohne Hinweis auf Konten. /stop und „Trennen“ in der App entfernen die Verbindung und senden STOPPED. /hilfe, /help, /start ohne Token und anderer Text ergeben HELP. Antworten des Bots werden als NotificationDelivery mit templateCode und chatId eingereiht (kein Netzaufruf in der Transaktion) und vom Sender verschickt. Höchstens 3 Antworten je Chat in 10 Minuten (Zähler im Speicher, gezählt erst nach dem Commit); LINKED ist ausgenommen (Annahme 54). Jede Beendigung einer Verbindung (App, /stop, Chat zieht zu einem anderen Konto um, Bot blockiert oder Chat gelöscht im Sender) läuft über denselben Helfer removeLinkIn (src/telegram/telegram-link-removal.ts): Verbindung löschen (bei Bedarf nur, wenn sie noch auf diesen Chat zeigt), telegramEnabled = false und die wartenden Telegram-Zustellungen des Nutzers (PENDING, PROCESSING) in derselben Transaktion auf SKIPPED mit claimedAt leer und Grund unlinked, chat unavailable oder relinked. Wird die Verbindung eines Nutzers auf einen anderen Chat umgestellt, verfallen die wartenden Zustellungen ebenfalls (relinked); derselbe Chat noch einmal ändert nichts. Alte Nachrichten erreichen so nie einen später verbundenen Chat. Datenschutz: Vorname, Chat-ID und Token stehen nie in Logs; die Chat-ID verlässt den Server nicht (auch die Einstellungs-Antwort enthält sie nicht). Je Umgebung ein eigener Bot, weil ein Bot nur einen Empfänger haben darf.

Betrieb. Die Karte „Telegram“ unter Verwaltung > System (Abschnitt 10) zeigt Empfänger (Lease, Alter des letzten Abrufs, lastError) und Sender (wartend, überfällig, fehlgeschlagen in 24 h, letzte Fehler) und erlaubt das Wiederholen fehlgeschlagener Zustellungen. Die Einstellungsansicht (GET /profile/notification-settings) nennt das Feld telegram.botNameSet (Bot-Name gesetzt; das Token kennt die api nicht), derselbe Begriff wie am System-Endpunkt. Verdrahtung des Secrets und Einrichtung des Bots: Abschnitt 7.

Oberfläche: Profil > Telegram (TelegramLinkCard). Der Zustand steht in einer Statuszeile (role="status"): „Telegram ist nicht eingerichtet.“ (ohne botNameSet, dann ohne Knopf), „Nicht verbunden“ oder „Verbunden mit „{Vorname}“ seit {TT.MM.JJJJ}“. „Telegram verbinden“ öffnet den StepUpDialog; danach erscheint der große Knopf „In Telegram öffnen“ (target="_blank", rel="noopener noreferrer") mit „Gültig bis {HH:MM}“ (Berliner Zeit), und der Fokus springt auf diesen Knopf; nach „Trennen“ (ConfirmDialog) springt er auf „Telegram verbinden“. Der Deep Link ist ein Geheimnis: Er wird nur gezeigt, wenn er mit https://t.me/ beginnt (sonst „Der Telegram-Link ist ungültig. …“), liegt nur im Zustand der Komponente (nie im React-Query-Cache, in localStorage/IndexedDB oder im Log) und verschwindet bei Ablauf oder sobald der Status „verbunden“ meldet. Solange ein Link gültig und noch nicht verbunden ist, lädt die Karte den Status alle 5 Sekunden neu. Verbundene Nutzer wählen die sechs Kategorien (Stand T4c; COMPLIANCE liefert das Backend schon, die Karte zeigt sie mit T6 Task 18; ToggleChip, während des Speicherns und offline aus) und den Schalter „Mit Details“ mit dem Hinweis „Nachrichten laufen über Server von Telegram und sind dort nicht Ende-zu-Ende verschlüsselt.“; jede Änderung geht einzeln per PATCH. Verbinden, Trennen und Speichern sind offline gesperrt („Nur mit Verbindung“).

Step-up (src/components/auth/step-up-dialog.tsx, geteilt mit dem Backup-Import; pendingLabel, beim Import „Wird eingespielt…“). Der Dialog fragt das aktuelle Passwort und bei MFA den 6-stelligen Code; onConfirm(proof) darf werfen, dann bleibt er offen, leert die Felder, fokussiert das Passwortfeld und zeigt den deutschen Grund in einer dauerhaften Live-Region. Während der Ausführung sind die Eingaben readOnly, Esc, Hintergrund und Schließen-Knopf wirkungslos. Die Felder leben nur, solange der Dialog offen ist. Ein fehlgeschlagener Import erscheint im Dialog statt als Toast. Alle Step-up-Aufrufe senden mit refreshOn401: false (Annahme 56): createTelegramLinkToken, importBackup, changePassword, deactivateAccount, confirmMfaEnrollment und disableMfa. stepUpErrorText (src/lib/auth/step-up-error.ts, auch im Passwortformular des Profils) liefert: 429 „Zu viele Versuche. Bitte in einer Minute noch einmal.“, ein 401 ohne eigene Meldung „Sitzung abgelaufen. Bitte die Seite neu laden und erneut anmelden.“, deutsche Server-Meldungen unverändert. Der Client kennt den Ablauf des Tokens nicht und erneuert vor Step-up-Aufrufen nicht vorab.

Logs nennen nur Zustellungs-IDs, nie Token, Chat-ID oder Text.

12. Compliance (T6)

Prüfbereitschaft für Tierarzneimittel, Etiketten und Behörden. Rechtswerte (Fristen, Paragraphen, Ziffernhöhen, x-Höhe, Landesrecht Bayern) stehen nie im Code, sondern sind Einstellungen mit dem Vermerk „Annahme – vom Inhaber zu prüfen“; sie sind vor dem Rollout gegen gesetze-im-internet.de und EUR-Lex abzugleichen. Belege, Rechnungsnummern, Anschriften, Koordinaten und Aktenzeichen sehen nur Inhaber von compliance:read bzw. veterinary-medicines:read; sie erscheinen nie in öffentlichen Endpunkten, im Shop-Feed, in Webhooks oder in Ereignis-Payloads. Entscheidungen des Inhabers vom 2026-10-15: eigene Meldungs-Kategorie COMPLIANCE, Stand vor Volk beim Sperren, kein Step-up für die Ausnahme vom Sperrbezirk.

Stand: Umgesetzt sind Task 1 (Schema, Rechte, Sperren, Backup), 2 (Tierarzneimittel), 3 (Behandlung aus Packung), 4 (Dokumente), 5 (Etikett-Pflichtangaben), 6 (Druck-Sperre und Probedruck), 7 (Ereignisse), 8 (Sperrbezirk und Sperr-Umbau der Völker) und 9 (Behördenpaket I), 10 (Anzeigen-PDFs), 11 (Hinweise und Tagesauswertung) und 12 (Prüfbereitschaft I), 13 (Prüfbereitschaft II), 14 (Oberfläche Prüfbereitschaft), 15 (Oberfläche Tierarzneimittel) 17 (Oberfläche Etikett), 16 (Behandlungsmaske), 18 (Anzeigen und Einstellungen) und 19 (Oberfläche Sperrbezirk). Task 1 bis 19 sind erledigt (T6a Backend: Task 1 bis 13; T6b Frontend: Task 14 bis 19). Offen sind die Browserprüfung aller T6-Seiten, die Rechtswerte-Prüfung durch den Inhaber und das Ausrollen (TASKS.md).

12.1 Schema und Rechte

Migrationen (alle additiv, nach 20261015110000_t4_telegram): 20261016100000_t6_veterinary_medicines (Enums MedicineUnit, VeterinaryMedicineMovementType; drei Tabellen; Treatment.medicineId, medicineLotId, doseQuantity mit Fremdschlüsseln ON DELETE RESTRICT, Indizes Treatment_medicineId_idx und medicineLotId; DocumentEntityType + MEDICINE_LOT; Rechte), …100100_t6_label_particulars (SiteSettings.foodBusinessName/foodBusinessAddress, Product.legalName, LabelTemplate.minXHeightMm Vorgabe 1,2, AuditAction + PRINT), …100200_t6_authority_compliance (ComplianceSettings, AuthorityNotice, ApiaryRestriction, ComplianceAcknowledgement; DocumentEntityType + AUTHORITY_NOTICE, APIARY_RESTRICTION; Rechte), …100300_t6_notification_category_compliance (nur ALTER TYPE … ADD VALUE 'COMPLIANCE'; der Wert wird in derselben Migration nicht benutzt) und …100400_t6_notification_compliance_default und …100500_t6_compliance_settings_row (Datenmigration: legt die Zeile ComplianceSettings mit der Id singleton an). Prüfungen und Annahmen zum Schema: Annahme 59. netQuantityDigitRules ist ein Prisma-@default, damit die SQL-Vorgabe keinen Drift gegen das Schema erzeugt; die Singleton-Zeile ComplianceSettings legt die Migration …100500 an; ComplianceSettingsService und herdStatusIn behalten einen idempotenten upsert nur als Rückfall, der Etikettendruck dagegen schließt ohne Zeile (12.6). Es gibt keine Löschroute für Behandlungen; die RESTRICT-Schlüssel schützen eine Behandlung mit Buchung weiter. Ein künftiger Löschpfad müsste P2003 auf 409 „Diese Behandlung hat eine Buchung aus einer Packung und kann nicht gelöscht werden.“ abbilden oder gegenbuchen.

Rechte (Katalog permission-catalog.ts; die Migrationen vergeben, der Katalog-Sync berechtigt nur System-Rollen):

Recht Gruppe (Sortierung) ADMIN BEEKEEPER STAFF READONLY API_CLIENT
veterinary-medicines:read Tierarzneimittel (3700) ✓ ✓ ✓ ✓ –
veterinary-medicines:create, :update Tierarzneimittel ✓ ✓ ✓ – –
compliance:read Prüfbereitschaft & Behörden (3800) ✓ ✓ ✓ ✓ –
compliance:manage Prüfbereitschaft & Behörden (3801) ✓ ✓ – – –

Eine Behandlung aus einer Packung bucht mit treatments:create ab, es gibt kein eigenes Recht. Sperrbezirke lesen mit apiaries:read (Einzelheiten nur mit compliance:read, 12.7) und schreiben mit compliance:manage. AuditAction kennt PRINT (Etikettendruck, 12.6).

Sperr-Reihenfolge. Apiary steht an erster Stelle von LOCKABLE_TABLES, danach Colony und die übrigen Tabellen wie bisher; die acht neuen Tabellen stehen hinter NotificationDelivery (Annahme 50). Die Völker-Pfade, die Stand und Volk zusammen sperren, nehmen zuerst die Stände mit lockRowsForShare(tx, 'Apiary', ids) (aufsteigend), dann das Volk; ApiaryRestrictionsService sperrt die betroffenen Stände mit lockRowsForUpdate, damit sich eine Sperre und ein laufendes Umsetzen hintereinander einreihen. Wechselt der Stand eines Volkes zwischen Lesen und Sperren, endet die Anfrage mit 409 „Der Stand wurde gerade geändert. Bitte neu laden.“, ohne automatischen Neuversuch. Die Umbauten der Aufrufer stehen in 12.7. Services, die T6 anfasst und before bisher außerhalb lasen (SiteSettingsService.update, ProductsService.update, LabelTemplatesService.update), lesen es seit Task 5 nach der Zeilensperre.

Backup. Die sieben neuen Tabellen gehören zum versiegelten Bundle; Reihenfolge, Verlust des Packungsbezugs in unversiegelten Dateien und die Regel für Behördenanzeigen stehen in Abschnitt 8 (Annahmen 63 und 64).

Meldungs-Kategorie COMPLIANCE („Behörden & Prüfung“). Sie trägt die Hinweise „Bestand geändert“ und „TSK-Stichtag“ (Task 11) und ist anders als SYSTEM für Telegram vorgewählt: Die Vorgabe telegramCategories ist [TASK_DUE, DIGEST, COMPLIANCE], und die Daten-Migration hängt COMPLIANCE an jede bestehende, nichtleere Liste an. Eine leere Liste (der Nutzer hat bewusst alles abgewählt, Opt-out) bleibt leer; Kosten bei falscher Annahme: ein Nutzer mit Opt-out bekäme sonst Behördenhinweise. IsEnum(NotificationCategory) und die Längengrenze übernehmen den Wert aus dem Enum, es gibt keine zweite Kategorienliste im Code. Das Frontend (Task 18) ergänzt Label und Auswahl; bis dahin zeigt es den Wert als unbekannte Kategorie.

Null-Verträge. describeNullContracts (src/common/validation/null-contract.spec-helper.ts, per tsconfig.build.json aus dem Build ausgeschlossen) registriert je Update-DTO die drei Prüfungen der Null-Regel (Annahme 20); jede T6-Task legt ihre Verträge in eine eigene Spec-Datei.

12.2 Tierarzneimittel: Katalog, Packungen, Bewegungsbuch

VeterinaryMedicinesModule (src/veterinary-medicines/, in AppModule, nicht im WorkerModule: es hat keine Handler) trennt drei Ebenen: das Mittel (VeterinaryMedicine, Handelsname, Wirkstoff, Einheit, Wartezeit, Lagerhinweise), die Packung (VeterinaryMedicineLot, Beleg, Verfall) und die Bewegung (VeterinaryMedicineMovement: TREATMENT nur aus einer Behandlung, CORRECTION für die Inventur, DISPOSAL mit Pflichtgrund). Grundsätze: Annahmen 60 bis 62. Der Rest ist roundToThousandths(lot.quantity + Σ movement.quantity) (src/common/quantity.ts, die einzige Rundung auf Tausendstel; roundKg in honey-plausibility.ts ist ein Alias, bottling.service.ts ruft sie direkt auf, -0 wird zu 0). Die reinen Helfer stehen in medicine-ledger.ts (summarizeLot, treatmentBookingDelta für die Gegenbuchung bei geänderter Packung oder Dosis, Task 3), die Sichten in medicine-views.ts (eine groupBy-Abfrage je Liste, kein N+1).

API (Rechte veterinary-medicines:read|create|update, Datumsfelder der Antworten als Tagestexte JJJJ-MM-TT):

  • GET /veterinary-medicines?active=, GET /veterinary-medicines/suggestions (eigene, noch nicht katalogisierte Handelsnamen aus Treatment.product ohne Beachtung der Groß-/Kleinschreibung, höchstens 50, mit den Werten der jüngsten Behandlung; legt nichts an), GET/PATCH /veterinary-medicines/:id, POST /veterinary-medicines, POST …/:id/deactivate|reactivate (ohne Änderung keine Schreibzeile und kein Audit).
  • GET /veterinary-medicine-lots?medicineId=&selectable=true&includeLotId= (wählbar: Rest > 0 und Mittel aktiv, älteste Haltbarkeit zuerst; includeLotId hält die Packung einer bearbeiteten Behandlung zusätzlich wählbar), GET/PATCH /veterinary-medicine-lots/:id (mit Bewegungsbuch), POST /veterinary-medicine-lots, POST …/:id/movements mit { type: 'CORRECTION', actualRemaining } oder { type: 'DISPOSAL', quantity, reason }.
  • Die Anlege-Endpunkte und :id/movements tragen @Idempotent(). Die Listen sind noch nicht paginiert; mit wachsendem Buch ist ein Limit zu ergänzen.

Schreiben. Mittel und Packungen lesen before nach der Zeilensperre und schreiben das Audit in derselben Transaktion; die Sperrfolge ist Mittel, dann Packung. Eine Inventur bucht die Differenz zwischen Buchrest und gezähltem Rest unter der Packungssperre (Delta 0 bucht nichts und schreibt kein Audit), zwei gleichzeitige Inventuren laufen nacheinander. Eine Entsorgung, die auf 0 rundet (unter 0,0005), ist 400. name und reason dürfen nicht nur aus Leerzeichen bestehen. minTemperatureC <= maxTemperatureC prüft der Dienst gegen den zusammengeführten Stand (400). Der Seed legt keine Mittel an; seed-no-medicines.spec.ts sichert das (nur die fünf bekannten Seed-Module, keine weitere .ts unter prisma/).

Behandlung aus Packung (Task 3). POST /treatments, POST /treatments/bulk und PATCH /treatments/:id nehmen zusätzlich medicineId, medicineLotId und doseQuantity (null leert); product entfällt, wenn Mittel oder Packung gewählt sind, PATCH kennt außerdem apiaryId (Stand am Behandlungstag, 404 bei unbekanntem Stand). Die Logik steckt in treatment-medicine.ts (freie Funktionen mit tx); TreatmentsModule importiert VeterinaryMedicinesModule nicht, die Buchung braucht nur treatments:create.

  • Prüfung (resolveMedicineIn, am zusammengeführten Zustand): Eine Packung schließt supplier, purchaseProof, batchNumber und prescribingVet auf der Behandlung aus (400 „Diese Angabe kommt aus der Packung“); medicineId: null bei gesetzter Packung, eine Packung eines anderen Mittels, ein abweichendes product (ohne Groß-/Kleinschreibung, getrimmt) und ein inaktives Mittel beim Anlegen sind 400, ein unbekanntes Mittel oder eine unbekannte Packung 404. Die Dosis wird auf Tausendstel gerundet, unter 0,001 ist 400. Das Mittel wird FOR SHARE gesperrt. activeIngredient, withdrawalPeriodDays und method füllt der Server nie aus dem Katalog nach; product wird zum Handelsnamen und folgt dem Katalog nur, wenn der Patch product, medicineId oder medicineLotId anfasst. update prüft und bucht nur, wenn der Patch Mittel, Packung, Dosis, product oder eines der vier Packungs-Textfelder berührt; eine reine Notizänderung gelingt auch, wenn das Mittel der Packung inzwischen gewechselt hat.
  • Delta-Buchung (bookTreatmentIn): Eine Behandlung mit Packung und Dosis bucht TREATMENT mit −Dosis in derselben Transaktion wie Behandlung, Audit und TreatmentRecorded (Sammelerfassung: je Volk eine Bewegung, alles oder nichts). Beim Bearbeiten bucht der Server nur das Delta je Packung aus treatmentBookingDelta über die Summe der bisherigen Bewegungen dieser Behandlung (Gegenbuchung auf der alten, neue Buchung auf der neuen Packung; Bewegungen werden nie geändert). Ein Umhängen auf ein anderes Volk lässt den Bestand unberührt. Eine Dosis ohne Packung wird gespeichert und bucht nichts; eine später gesetzte Packung bucht die volle Dosis nach. Leere oder abgelaufene Packungen blockieren nicht (der Rest wird negativ, die Prüfbereitschaft meldet es).
  • Sperr-Reihenfolge: Volk (wenn colonyId im Patch steht) → Behandlung → Mittel (FOR SHARE) → Packungen aufsteigend in einer Anweisung (lockRowsForUpdate), also Colony vor Treatment wie in der Karte LOCKABLE_TABLES. Nach der Packungssperre prüft die Buchung, dass die Packung noch zum Mittel der Behandlung gehört (sonst 409 „Die Packung wurde gerade geändert. Bitte neu laden.“). Eine Inventur auf derselben Packung wartet auf dieselbe Sperre.
  • Bestandsbuch (export/treatment-log.ts, GET /export/treatment-log.pdf): resolveSourceValues liest die Spalten aus genau einer Quelle (Packung, wenn gesetzt, sonst die Behandlung). Die Menge ist der Text der Behandlung, sonst die Dosis mit Einheit des Mittels („30 ml“, auch ohne Packung, sobald ein Katalogmittel da ist). treatmentLogGaps (SUPPLIER, PROOF, AMOUNT, WITHDRAWAL mit 0 Tagen als erfasst, VET nur bei verschreibungspflichtigem Mittel) ist die einzige Quelle: treatmentLogCells druckt „FEHLT“ genau an diesen Stellen (die Tierarzt-Lücke in der Spalte „a) Lieferant …“ als „; Tierarzt: FEHLT“), und die Prüfbereitschaft nutzt dieselbe Funktion; FEHLT und Lücken sind also gleich. Zeilen mit gleichen aufgelösten Werten gruppiert das Buch nur bei gleicher Packung und gleichem prescriptionOnly. Fußnote: „Lieferant, Beleg und Ch.-B. stammen aus der erfassten Packung.“ Der Export braucht export:read und zusätzlich veterinary-medicines:read (Annahme 73); ohne dieses Recht 403, die Stammkarte bleibt mit export:read erreichbar.

12.3 Dokumente mit Rechten je Art

Für MEDICINE_LOT, AUTHORITY_NOTICE und APIARY_RESTRICTION gilt zusätzlich zum Dokumentenrecht das Fachrecht des Datensatzes. Die Zuordnung steht in DOCUMENT_ENTITY_PERMISSIONS (documents.service.ts): Packung lesen veterinary-medicines:read, schreiben veterinary-medicines:update; Behördenanzeige und Sperrbezirk lesen compliance:read, schreiben compliance:manage. „Schreiben“ umfasst Hochladen, Umhängen, Umbenennen und Löschen; beim Umhängen zählen die alte und die neue Art. DocumentsService.assertEntityAccess(userId, entityType, mode) wirft 403 ohne das Recht; Arten ohne Eintrag (Stand, Volk, Königin, Durchsicht, Honigcharge) prüfen nichts zusätzlich. Die Prüfung kommt vor jeder Datenbankabfrage (findAll, create) bzw. nach dem Laden des Dokuments und vor dem Lesen der Datei (readFile, update, delete), daher 403 vor 404 und kein Existenz-Orakel für Unberechtigte. Wer nur documents:read hat, sieht Belege, Anzeigen und Sperrbescheide weder in der Liste noch per Download-ID. compliance:manage schließt compliance:read nicht ein (Annahme 69). Speicherordner sind medicine_lot, authority_notice und apiary_restriction; MIME-Allowlist und Signaturprüfung gelten unverändert. Ein Fehler beim Löschen der Datei wird mit Dokument-Id und loggableError() protokolliert, Zeile, Audit und Antwort bleiben. Dokumente gelöschter Datensätze bleiben bestehen (Annahme 70). Das Recht wirkt über den 30-s-Cache des Resolvers verzögert, wie überall.

Zwischenspeicher. Alle API-Antworten tragen Cache-Control: no-store (Abschnitt 9, noStoreMiddleware); die e2e-Tests halten das für Liste und Download fest. Der Service Worker beachtet no-store bewusst nicht, weil es jede Antwort trägt (Abschnitt 2, Annahme 87). Stattdessen schließen Pfadregeln die Listen der drei Arten (/documents?entityType=…) und documents/:id aus; das Präfix /api/v1/documents wird nicht ganz gesperrt, sonst fehlten die Durchsichtsfotos offline. Nie gecacht werden auch die Compliance-, Arzneimittel- und Sperrbezirk-Pfade (NEVER_CACHED_API_PATHS, seit Task 14).

12.4 Etikett-Pflichtangaben

checkLabel (src/label-templates/label-particulars.ts) ist rein und ohne Datenbank; Regeln und Grenzen: Annahmen 65 bis 68. Pflichtangaben (sperren den Druck: 409 LABEL_PARTICULARS_MISSING mit { gaps }, Abschnitt 12.6): LEGAL_NAME (ein Wort mit „honig“), NET_QUANTITY (Füllmenge der Abfüllung, „250,5 g“ wie „250.5 g“, „1 kg“ nur bei vollen Kilogramm; als ganze Wörter), BEST_BEFORE („Mindestens haltbar bis“ mit dem Datum), LOT, ORIGIN und FOOD_BUSINESS (Name und Anschrift). Warnungen (sperren nie): LOT_PREFIX, X_HEIGHT (unter LabelTemplate.minXHeightMm), NET_QUANTITY_HEIGHT (Ziffernhöhe unter der Regel aus ComplianceSettings.netQuantityDigitRules), TEXT_CLIPPED. Das Ergebnis trägt requirements (minXHeightMm, netQuantityMinCapHeightMm) und je Befund elementIds (bei Lücken die abgeschnittenen Elemente, die die Angabe getragen hätten). Verfeinerungen (Runden 2 bis 4 der Task 5).

  • Ziffern-Wächter je Element. „Zahl direkt vor der Menge“ („1,250 g“ ist kein Treffer für 250 g) gilt nur im Text eines Elements, nie im verbundenen Text: Eine Losnummer oder ein Datum in einem Nachbarelement macht „500 g“ nicht ungültig. „250g“ gilt als „250 g“, „L2026“ bleibt.
  • Lesereihenfolge kommt allein aus den Positionen (nach y, Zeilen mit 1 mm Toleranz gegen die erste Oberkante, je Zeile von links nach rechts), nie aus der Reihenfolge der Elemente in der Vorlage.
  • Verteilte Angaben (Name und Anschrift): Zeilen, Blöcke in Lesereihenfolge oder Ketten wortgleicher Elemente, jeweils mit symmetrischem senkrechtem Abstand von höchstens 10 mm (max(0, y − Unterkante, Oberkante − (y + h)), gemessen an der laufenden Ober- und Unterkante der ganzen Gruppe, nicht am Vorgänger, groupByDistance). Elemente mit leerem normalisiertem Text werden ignoriert und überbrücken nichts; eine hohe Textbox überbrückt Zeilen um ihre Boxhöhe. Nur sichtbare (nicht abgeschnittene) Teile zählen (Annahme 75).
  • Zahl | Einheit („500“ | „g“): zwei Elemente gelten als Paar, wenn sich die Boxen senkrecht überlappen, die Einheit rechts liegt und der waagerechte Abstand höchstens 10 mm beträgt (Annahme 74). Beide Teile zählen für X_HEIGHT und NET_QUANTITY_HEIGHT.
  • Träger einer verteilten Angabe sind nur die Elemente, die sich mit dem Treffer im verbundenen Text überlappen (matchParts), nicht jedes Element mit einem gemeinsamen Wort; abgeschnittene Träger erscheinen über dieselbe Überlappung in elementIds der Lücke und von TEXT_CLIPPED (clippedHolders).
  • Zeitzonen-Unabhängigkeit. Der Datumstag kommt aus labelDayOf (Berliner Kalendertag). Ein Quelltext-Wächter liest label-templates.service.ts, label-particulars.ts, label-text.ts, font-metrics.ts, src/common/dates.ts und format-day.ts und scheitert bei zeitzonenabhängigen Aufrufen (getDate, getHours, toLocaleDateString ohne timeZone, Intl.DateTimeFormat( mit oder ohne new ohne timeZone, mehrargumentiges new Date(y, m, …) u. a.); ein Selbsttest prüft die Muster an schlechten und guten Beispielen. Die Spec setzt keine Prozess-Zeitzone, sondern prüft wörtliche Berliner Tage.
  • Dokumentierte Grenzen: Ein fremdes Element mit einem gemeinsamen Wort kann in eine Kette geraten; der waagerechte Abstand zählt bei Anschriften nicht; „honig“ in einem Wort genügt (Annahme 67); NET_QUANTITY_HEIGHT zählt die größte Nennung (Annahme 66). Eine Füllmenge, die auf drei oder mehr Elemente verteilt ist oder deren Einheit links steht, ist eine Lücke.

Höhen. mm = Schriftgröße in pt × AFM-Metrik / 1000 × 25,4 / 72 (Helvetica: x-Höhe 523, Versalhöhe 718). Der Zugriff auf das private pdfkit-Feld steckt allein in font-metrics.ts (fontMetricsOf, je Schnitt gecacht, Fehler bei fehlenden Werten, durch einen Spec-Test gesichert); pdfFontName und MM_TO_PT wohnen dort. Beispiel: 6 pt ergibt 1,11 mm (Warnung), 7 pt 1,29 mm (in Ordnung). Die Rechtswerte sind Eingaben, die Beispielregeln stehen nur in den Tests.

Stammdaten und Platzhalter. SiteSettings.foodBusinessName (200) und foodBusinessAddress (300), Product.legalName (200) und LabelTemplate.minXHeightMm (0,5 bis 5) bleiben leer bzw. bei der Vorgabe, bis der Inhaber sie pflegt; leer ist eine Lücke, kein Standardwert. Neue Platzhalter: legalName, originCountry, foodBusinessName, foodBusinessAddress, bestBeforeLine („Mindestens haltbar bis TT.MM.JJJJ“), lotLine („L “); die Liste liegt als test/fixtures/placeholder-keys.json vor (Wächter placeholder-keys.spec.ts, das Frontend vergleicht seine Liste damit). buildPlaceholderValues(bottlingBatch, settings) setzt unbesetzte Stammdaten auf ''. Die öffentliche Herkunftsseite und der Shop-Feed zeigen weder Anschrift noch Verkehrsbezeichnung (e2e belegt, auch mit gesetztem legalName). holderName/holderAddress laufen über denselben trimmedOrNull-Helfer (null löscht). Der Druck (12.6) ruft labelDayOf(bottling.bestBeforeDate) für expected.bestBefore und nutzt einen gemeinsamen Messer je Druck (pdfTextMeasurer()); labelDayOf wohnt in label-text.ts (vermeidet einen Importzyklus) und wird vom Service weiter exportiert.

12.5 Ereignisse für Völker und Stände

Vier neue Typen im Katalog (domain-event-types.ts, 38 Typen), Aggregattyp Compliance neu ('Apiary' gab es schon). Alle tragen nur Ids, Codes, Jahr und Tag, nie Adresse, Koordinaten oder Namen, und sind nicht in DOMAIN_EVENT_WEBHOOKS; der Testhelfer herd-payload.spec-helper.ts prüft die wirklich geschriebenen Payloads gegen eine Allow-Liste.

  • ColonyCreated { apiaryId } (Aggregat Colony): ColoniesService.create und für den Ableger in split (zusätzlich zu ColonySplit), jeweils in der Transaktion des Schreibens. Auch der CSV-Import schreibt es je angelegtem Volk in derselben Transaktion mit Datensatz und Audit (Colony CREATE), damit die Bestandsänderung die importierten Völker sieht (gemeinsamer Erzeuger colonyCreatedEvent, src/colonies/colony-created-event.ts). Der Import sendet weiterhin keinen Webhook colony.created; der Webhook bleibt im Altpfad (Annahme 11). Fehlerzeilen, Überspringen und Zähler des Imports sind unverändert. Eine Wiederherstellung aus dem Backup schreibt bewusst kein ColonyCreated.
  • ColonyMoved { fromApiaryId, toApiaryId } (Aggregat Colony): move und update mit geändertem Stand. Ein Umsetzen in denselben Stand schreibt Historie und Audit, aber kein Ereignis.
  • ApiaryChanged { change } (Aggregat Apiary), change = CREATED | DEACTIVATED | REACTIVATED | LOCATION_CHANGED. LOCATION_CHANGED nur, wenn Adresse, Ort, Breite oder Länge sich gegenüber dem gesperrten before ändern. Je Schreibvorgang ein Ereignis, ohne dedupeKey.
  • TskReferenceDayReached { year, referenceDay } (Aggregat Compliance, referenceDay als JJJJ-MM-TT, tagesbezogen über dailyDedupeKey): nur im Katalog; der Erzeuger ist die Tagesauswertung (Task 11).

ApiariesService schreibt Datensatz, Audit und Ereignis seit Task 7 in einer Transaktion; update, deactivate und reactivate sperren zuerst die Standzeile (lockRowForUpdate) und lesen before danach, deactivate zählt lebende Völker erst danach über tx; reactivate behält die Audit-Aktion UPDATE. Die Handler (compliance.herd-change, compliance.tsk-reference-day) liegen im künftigen ComplianceModule (von AppModule und WorkerModule importiert, ohne PermissionsModule, Task 11); die Erzeuger laufen nur in der API. Frontend: DOMAIN_EVENT_LABELS kennt die vier Typen („Volk angelegt“, „Volk umgesetzt“, „Stand geändert“, „TSK-Stichtag erreicht“); Task 14 trägt sie nicht noch einmal ein.

12.6 Etikettendruck: Sperre, Probedruck, Audit

LabelTemplatesService.renderLabelsPdf(templateId, bottlingBatchId, options, actingUserId) mit options = { startPosition?, proof? } ersetzt die alte Signatur mit startPosition. Der Druck ruft vor dem Rendern dieselbe Prüfung checkLabel wie die Prüf-Endpunkte (eine private Methode check, dieselbe Aufbereitung der Platzhalter, dieselbe Füllmenge, derselbe MHD-Tag aus labelDayOf, dieselben Ziffernregeln).

  • Sperre. Fehlt eine Pflichtangabe und ist proof nicht gesetzt, antwortet der Druck mit 409 { code: 'LABEL_PARTICULARS_MISSING', message, gaps } und erzeugt kein PDF. Warnungen sperren nie, sie landen im Audit. ?proof=false mit Lücke ist 409, ?proof=maybe und ?proof=0 sind 400 (ParseBoolPipe).
  • Probedruck (?proof=true): immer möglich; jedes Etikett trägt den Stempel „MUSTER – nicht verkaufsfähig“ (label-proof-watermark.ts: diagonal, 50 % Grau, halbtransparent, 80 % der Diagonale, höchstens 50 % der Etikettenhöhe; im gedrehten Bogen mit den Etikettenmaßen), damit ein Probeabzug nie als verkaufsfähig umläuft.
  • Audit vor dem PDF. Jeder Druck, auch der Probedruck, schreibt vor der Auslieferung PRINT am BottlingBatch mit { templateId, copies, proof, gapCodes, warningCodes }; copies ist immer jarCount der Abfüllung, unabhängig von startPosition. Schlägt das Audit fehl, schlägt der Druck fehl (fail closed; das Audit steht außerhalb einer Transaktion, es gibt keinen zu schützenden Datensatz).
  • Prüf-Endpunkte. GET /label-templates/:id/check/:bottlingBatchId prüft eine gespeicherte Vorlage gegen eine Abfüllung, POST /label-templates/check (200, vor :id registriert) einen ungespeicherten Entwurf: nur Text-Elemente (@Equals('text'), andere Arten 400), höchstens 100, Element-Ids eindeutig, Breite und Höhe 0,1 bis 1000 mm, x/y −1000 bis 1000, fontSize höchstens 500, minXHeightMm 0,5 bis 5; ohne bottlingBatchId mit Beispielwerten (sample: true). widthMm/heightMm des Entwurfs werden nur auf Grenzen geprüft (10 bis 1000), maßgeblich sind die Feldgrößen der Elemente. startPosition liegt zwischen 1 und 1000, sonst 400.
  • Rechte. Prüfen: label-templates:read, Drucken: label-templates:print; zusätzlich bottling:read bei GET check, GET print und bei POST check mit bottlingBatchId (sonst 403; der Controller prüft das über PermissionResolverService.hasAny, weil @RequirePermission nur „oder“ kennt). Der Entwurfs-Check mit Beispielwerten braucht nur label-templates:read. Zur Vergabe der Etikett-Rechte: Annahme 77.
  • Ziffernregeln nur aus ComplianceSettings. Der Code kennt keine Rechtswerte und keine Rückfall-Vorgabe. loadDigitRules liest ComplianceSettings.netQuantityDigitRules der Zeile singleton (Migration 20261016100500); fehlt die Zeile oder sind die Regeln unbrauchbar (parseDigitRules: leer, Höhe nicht größer als 0, nicht endlich, nicht genau eine Regel ohne Obergrenze), antworten Druck und Prüfung mit 409 { code: 'COMPLIANCE_SETTINGS_INVALID', message: 'Compliance-Einstellungen fehlen oder sind ungültig.' } (fail closed; geloggt über loggableError, ohne Werte). Auch dann entsteht kein Audit.
  • Alle Antworten (Prüfung wie PDF) sind no-store (die globale Middleware, Abschnitt 9); die Prüfantworten enthalten die Anschrift des Lebensmittelunternehmers. Das Frontend (Task 17) liest GET/POST check und die Lückenliste gaps des 409.

12.7 Sperrbezirk

ApiaryRestriction (Seuche, Behörde, Aktenzeichen, Beginn, Ende, Notiz) sperrt Bewegungen von Völkern in und aus einem Stand. Aktiv ist eine Sperre vom Beginn bis einschließlich Endtag, ohne Endtag bis auf Weiteres. Maßgeblich ist der Berliner Kalendertag (berlinToday() der Anwendung, nicht der UTC-Tag und kein Datenbank-now(); Entscheidung des Inhabers 2026-10-15). Eine Sperre wird nie gelöscht, sie endet über das Enddatum. Paragraphen und Fristen (BienSeuchV) sind Hinweise und vor dem Rollout gegen die Quelle zu prüfen.

  • Geschützte Pfade: ColoniesService.create (Volk im Stand anlegen), update mit Standwechsel (alter oder neuer Stand; eine reine Namensänderung prüft nichts und sperrt keinen Stand), move (Quell- oder Zielstand), merge über verschiedene Stände und der CSV-Import (ImportService.importColonyRow sperrt den Stand FOR SHARE, prüft ACTIVE über tx und die Sperre ohne Ausnahme; Treffer sind Fehlerzeilen, Annahme 71).
  • Freie Pfade: Ableger (split, im selben Stand), Auflösen (dissolve), Ernte (warnt nur, Task 13) und die Wiederherstellung aus dem Backup (prüft bewusst nichts); ebenso ein Umsetzen in den eigenen Stand und ein Vereinigen innerhalb eines Standes.
  • Antwort: 409 { code: 'RESTRICTED_ZONE', message, restrictionIds, restrictions: [{ id, apiaryId, apiaryName, disease, startsOn }] }; die Meldung nennt Stand, Seuche und Beginn TT.MM.JJJJ.
  • Ausnahme. restrictionOverrideReason (getrimmt 10 bis 500 Zeichen) bei Anlegen, Bearbeiten, Umsetzen und Vereinigen. Ohne compliance:manage ist jede Begründung 403, vorab (vor Lesen und Sperren, auch ohne aktive Sperre; Annahme 72). Kein Step-up (Entscheidung des Inhabers). Mit dem Recht und aktiver Sperre landet die Begründung im Audit (afterData.restrictionOverride = { reason, restrictionIds }) und in ColonyApiaryHistory.notes („Ausnahme Sperrbezirk: …“, vor einer vorhandenen Notiz; bei Anlegen und Vereinigen gibt es keine Historienzeile, dort nur das Audit). Die Begründung steht nie am Volk: sie wird vor jedem Prisma-Aufruf abgetrennt. Ohne aktive Sperre wird sie (mit dem Recht) ignoriert, null zählt wie weggelassen.
  • Sperrmuster und 409 „neu laden“. Die Pfade lesen die Stand-Ids ungesperrt, sperren die Stände FOR SHARE aufsteigend, dann die Völker (beim Vereinigen Überlebendes und Aufgelöstes in einer Anweisung), vergleichen unter der Sperre den Stand des Volkes mit dem gelesenen (sonst 409 „Der Stand wurde gerade geändert. Bitte neu laden.“, kein automatischer Neuversuch; ein verschwundenes Volk ist 404), lesen assertApiaryActive über tx und prüfen erst dann die Sperre. ApiaryRestrictionsService (POST, PATCH) sperrt die Stände FOR UPDATE: Ein laufendes Umsetzen und eine neu beginnende Sperre reihen sich hintereinander ein, eine Sperrenänderung wartet auf laufende Umsetzungen. updateIn nimmt selbst keinen Standlock; ein Patch mit Standwechsel ohne den Wächter aus update() wirft (Programmierfehler). Die Durchsicht-Übernahme (ohne apiaryId) sperrt nur das Volk.
  • Endpunkte. POST /apiary-restrictions (compliance:manage, @Idempotent, 1 bis 50 verschiedene Stände, je Stand eine Zeile und ein Audit, eine Transaktion, unbekannter Stand 404 mit Rollback, endsOn >= startsOn sonst 400), PATCH /apiary-restrictions/:id (Stand FOR UPDATE, dann Sperre, before danach, endsOn: null öffnet wieder), GET /apiary-restrictions?active=true|false, GET /apiaries/:id/restrictions (apiaries:read).
  • Einzelheiten nur mit compliance:read: Behörde, Aktenzeichen, Notiz und Ersteller liefern beide GET-Pfade nur dann; mit nur apiaries:read besteht eine Zeile aus id, apiaryId, disease, startsOn, endsOn. active=true liefert für alle die schlanke ActiveRestrictionInfo (ohne diese Felder). Öffentliche Seiten und der Shop-Feed nennen weder Seuche noch Sperre (e2e mit echter öffentlicher Charge).
  • Das Frontend (Task 19, Abschnitt 12.11) bietet den Ausnahmedialog nur mit compliance:manage an und sendet restrictionOverrideReason.

12.8 Behördenpaket I: Einstellungen, Bestandsbild, Anzeigen

ComplianceModule (src/compliance/, in AppModule und seit Task 11 auch in WorkerModule, Abschnitt 12.9) enthält ComplianceSettingsService, AuthorityNoticesService und die reinen Helfer tsk-day.ts, herd-snapshot.ts, herd-status.ts. Kein Provider hängt am PermissionResolverService; das Modul exportiert beide Services.

  • Einstellungen. GET/PATCH /compliance/settings (compliance:read / compliance:manage). update upsertet, sperrt ComplianceSettings, liest before danach und schreibt das Audit in derselben Transaktion. Prüfungen (400): tskReferenceDay und tskReportDueDay als MM-TT (02-29 und „1-1“ abgelehnt), tskExcludedColonyTypes ohne Doppelte, netQuantityDigitRules mit 1 bis 10 Regeln, Obergrenzen streng aufsteigend, nur die letzte ohne Grenze, minMm 0,5 bis 20; ein fehlender maxGrams wird vor der Prüfung zu null normalisiert und so gespeichert. Alle Rechts- und Fristwerte sind Einstellungen mit dem Vermerk „Annahme – vom Inhaber zu prüfen“.
  • Bestandsbild (herdStatusIn(db, now)): lebende Völker (LIVING_COLONY_WHERE) an aktiven Ständen, bei der TSK-Zählung ohne tskExcludedColonyTypes; zwei ungesperrte Abfragen genügen für eine Anzeige. herdDeviation vergleicht baseline.totalColonies mit der aktuellen Zahl und erkennt neue, aufgegebene und verlegte Stände (Adresse, Ort, Koordinaten); ein Umsetzen zwischen Ständen löst nichts aus. noticeOnColonyCountChange und noticeOnApiaryChange schalten nur triggered, die Tatsachen stehen immer da.
  • Bezugsstand-Regeln. Bezugsstand ist die jüngste SENT-Anzeige der Art REGISTRATION oder HERD_CHANGE, sortiert nach sentOn, dann createdAt, id; TSK-Zählnachweise zählen nicht. Ohne Bezugsstand ist deviation = null (Punkt „Erstanzeige fehlt“). REGISTRATION entsteht nur ohne Bezugsstand, HERD_CHANGE nur mit („Erst die Erstanzeige erfassen.“, 409); TSK_REPORT braucht year, andere Arten dürfen keines haben (400). Eine wiedergeöffnete Erstanzeige lässt sich erneut senden.
  • Lebenszyklus. DRAFT → SENT (mark-sent) → DRAFT (reopen); DRAFT → CANCELLED (cancel). recount nur im Entwurf. createDraftIn(tx, { kind, year?, referenceDate }, userId | null) ist der gemeinsame Kern von create und dem des Tagesjobs (Task 11); referenceDate und sentOn beim Senden sind standardmäßig berlinToday(). Jeder Wechsel sperrt die Anzeige, liest before danach, prüft den Übergang (409, deutsche Meldung) und schreibt Audit UPDATE mit beforeData/afterData in derselben Transaktion. Die POST-Wechsel antworten wie die übrigen POST-Aktionen mit 201.
  • openKey und Mutex. Anlegen, mark-sent und reopen sperren zuerst ComplianceSettings (singleton), dann die Anzeige (Reihenfolge der Karte). openKey (REGISTRATION | HERD_CHANGE | TSK_REPORT:<Jahr>, nur bei DRAFT, nullbar eindeutig) lässt höchstens einen offenen Entwurf je Art und Jahr zu; ein zweiter, auch im Rennen, antwortet 409 { code: 'DRAFT_EXISTS', existingId }. Ein P2002 ohne erklärenden Entwurf wird unverändert weitergeworfen. Die Prüfung auf einen offenen Entwurf entfällt, wenn die Anzeige mit sentOn angelegt wird.
  • sentOn-Regeln. Mit sentOn beim Anlegen entsteht die Anzeige gleich als SENT ohne openKey (Weg „bereits angezeigt am …“ für Altbestand). reopen setzt sentOn = null und openKey, scheitert aber bei vorhandenem Entwurf (409 DRAFT_EXISTS). PATCH (totalColonies, referenceDate, sentOn, sentVia, reference, notes): sentOn auf einem Entwurf ist 400 „Ein Entwurf hat kein Sendedatum.“, das Sendedatum einer SENT-Anzeige ist nicht löschbar (400), eine CANCELLED-Anzeige nicht änderbar (409); fasst ein Patch sentOn an, nimmt update zuerst den Settings-Mutex.
  • Verwerfen statt Löschen. cancel verwirft nur einen Entwurf (CANCELLED, openKey frei); es gibt keine Löschroute (Spec 7), angehängte Dokumente bleiben (Annahme 76). GET /authority-notices?kind=&year= liefert höchstens die 200 neuesten Einträge (take: 200), eine Seitenaufteilung fehlt noch.
  • Endpunkte. GET /authority-notices, GET …/:id, POST /authority-notices (@Idempotent), PATCH …/:id, POST …/:id/recount|mark-sent|reopen|cancel; Lesen mit compliance:read, Schreiben mit compliance:manage (Nutzer ohne die Rechte: 403 auf Liste, Einzelanzeige und Einstellungen). Der PDF-Endpunkt …/:id/pdf steht im Absatz „PDFs (Task 10)“ am Ende dieses Abschnitts.

PDFs (Task 10). Aufbau: reines Modell pdf/authority-notice-model.ts (buildNoticePdfModel) getrennt vom Renderer pdf/authority-notice-pdf.ts (pdfkit, PDF_MARGIN, drawTable); noticeHeaderLines ist rein und einzeln testbar.

  • Endpunkt. GET /authority-notices/:id/pdf (compliance:read) liefert application/pdf, Cache-Control: no-store und Content-Disposition: attachment; filename="anzeige-<id>.pdf" (Dateiname nur mit der Id, auch für TSK-Nachweise). AuthorityNoticesService.pdf(id) gibt { buffer, fileName }; eine unbekannte Id ist 404.
  • Inhalt. Erstanzeige „Anzeige der Bienenhaltung (§ 1a BienSeuchV)“ ohne Spalte „vorher“; Änderungsanzeige „Änderungsanzeige Bienenhaltung (§ 1a BienSeuchV)“ mit „vorher“ je Stand, Summenzeile und der Liste „Änderungen seit der Anzeige vom {Datum}“ (Stand neu, aufgegeben, Lage geändert, Zahl der Völker). Lage = Adresse, sonst Koordinaten mit fünf Nachkommastellen, sonst Ort, sonst FEHLT.
  • TSK-Zählnachweis in A4 quer. Titel „Zählnachweis Bienenvölker zum Stichtag {Datum}“, eine Spalte je gezählter Völkerart (feste Reihenfolge) plus „Summe“, Zahlenspalten rechtsbündig, Spaltenbreite aus dem breitesten Wortteil (Mindestbreite 40 pt, Umbruch am Bindestrich), „Lage“ bekommt den Rest. Hinweis „Gezählt am {Tag}“, bei Zählung nach dem Stichtag „…, nicht am Stichtag. Bitte prüfen.“; Fußsatz „Ersetzt nicht die Meldung an die Tierseuchenkasse.“; kein Empfängerblock. Erst- und Änderungsanzeige bleiben Hochformat (Annahme 82). Rutscht die Unterschrift bei vielen Ständen auf Seite 2, ist das gewollt (kein Abschneiden).
  • FEHLT statt Weglassen. Halter, Veterinäramt, TSK-Nummer und die Lage eines Standes druckt der Renderer als FEHLT; die Registriernummer als „–“ (Spec 8.1). Fehlt beim Halter nur die Anschrift (oder nur der Name), steht FEHLT an dieser Stelle („Imkerei Muster, FEHLT“); nur wenn beides leer ist, ist holder = null und der Renderer druckt FEHLT.
  • Summen aus dem gespeicherten totalColonies. totals.current und totals.previous sind beide das gespeicherte totalColonies (der Anzeige bzw. der Bezugsanzeige); die Zeilen kommen weiter aus dem Bestandsbild. Weicht die Zahl von der Summe der Stände ab, steht vor den Änderungen „Gesamtzahl berichtigt (Summe der Stände: n)“; die Zeile „Zahl der Völker: a auf b“ nutzt dasselbe totalColonies. Das PDF druckt, was in der Anzeige gespeichert ist, keinen Live-Stand (Annahme 84).
  • Richtiger Bezugsstand. Entwurf: jüngste SENT-Anzeige (findBaselineIn). Gesendete Anzeige: die davor gesendete (sentOn kleiner, bei Gleichstand createdAt kleiner, andere Id), damit ein altes PDF nicht gegen einen später gesendeten Stand verglichen wird. Halter aus SiteSettings, Empfänger und Nummern aus ComplianceSettings. Fußzeile „Stand der Daten {updatedAt} · erstellt mit ApiCore“ (Berliner Tag). Das Briefdatum ist der Berliner Tag von now; der Ort daneben kommt aus der Postleitzahl-Zeile der Halteranschrift und bleibt leer, wenn keine gefunden wird (Annahme 83).
  • Geteilter Tabellenhelfer. src/export/pdf-table.ts hat zwei additive Optionen: PdfColumn.align (rechtsbündige Zahlenspalten) und drawTable(…, { boldLastRow }) (fette Summenzeile). Standardverhalten und Kopfwiederholung beim Seitenumbruch bleiben unverändert. Nach drawTable setzt der Renderer doc.x = PDF_MARGIN, sonst liefen Notizen und Fußzeile im rechten Randstreifen.
  • Layout-Tests: 15 Stände mit langen Adressen ergeben eine Hochformat-Seite, Notizen und Fußzeile beginnen bei PDF_MARGIN, der TSK-Nachweis hat eine Querformat-MediaBox. Beispiel-PDFs wurden gerendert und angesehen (lesbar, Umlaute korrekt).

12.9 Hinweise und Tagesauswertung

Task 11 verbindet die Anzeigen mit dem Ereignis-Rückgrat (Abschnitt 10). ComplianceModule samt ComplianceEventHandlers ist in AppModule und WorkerModule; worker-runtime.e2e-spec.ts und authority-notices-herd.e2e-spec.ts prüfen, dass compliance.herd-change und compliance.tsk-reference-day in beiden Registern stehen.

  • recordForPermissionOnce. NotificationsService.recordForPermissionOnce(permission, input, tx) schreibt je Thema (relatedType + relatedId) und Nutzer höchstens eine ungelesene Meldung. Ein Advisory-Lock je Thema (pg_advisory_xact_lock(hashtextextended('notify-once:<Typ>:<Id>', 0))) ordnet Prüfung und Schreiben gleichzeitiger Transaktionen hintereinander (e2e: zwei parallele Transaktionen schreiben genau eine Zeile je Nutzer). Bewusst kein dedupeKey: Das Thema kommt wieder, sobald die Meldung gelesen ist. Empfänger sind aktive Nutzer mit aktiver Rolle und allen Rechten; planIn plant die Telegram-Zustellung in derselben Transaktion (Kategorie COMPLIANCE, Vorgabe-Kategorie). Texte unter 600 Zeichen, ohne Anschrift; der Link steht in link. Themen: ComplianceItem / herd-change und ComplianceItem / tsk-report:<Jahr>.
  • compliance.herd-change. Ereignisse: ColonyCreated, ColonyMoved, ColonyEnded, ColonyStatusChanged (nur Wechsel zwischen lebend und beendet) und ApiaryChanged. Der Handler berechnet über herdStatusIn die Abweichung vom Bezugsstand (außerhalb der Sperre, READ COMMITTED: schlimmstenfalls zu früh oder zu spät gemeldet, nie doppelt). Löst sie aus (noticeOnColonyCountChange, noticeOnApiaryChange), erhält jeder aktive Nutzer mit compliance:manage höchstens eine ungelesene Meldung „Bestand geändert: …“ (Priorität HINWEIS, Kategorie COMPLIANCE, Link /compliance). Er sendet nur eine Meldung und legt nie eine Anzeige an, auch keinen Entwurf (Spec 5.5, Annahme 78). Ein Stand-Umzug (ApiaryChanged mit Ortswechsel) zählt, ein Umbenennen nicht, ein Umsetzen zwischen Ständen löst nichts aus. Der CSV-Import emittiert ColonyCreated (Task 7), damit auch importierte Völker die Meldung auslösen.
  • compliance.tsk-reference-day. Ereignis TskReferenceDayReached. Unter der Sperre der Zeile ComplianceSettings legt der Handler einen TSK-Entwurf für das Jahr an (createDraftIn, 12.8), außer es gibt für das Jahr schon eine gesendete Anzeige oder einen offenen Entwurf. Dazu die Meldung „TSK-Stichtag TT.MM.JJJJ: n Völker gezählt. Bitte prüfen und melden.“ (WARNUNG, Kategorie COMPLIANCE, Link /compliance/notices/<id>). Die Erinnerung geht auch, wenn nur ein Entwurf existiert (kein neuer Entwurf, Link auf den vorhandenen, höchstens eine ungelesene je Nutzer); bei einer gesendeten Anzeige bleibt der Handler still (Annahme 79).
  • Tagesregel TskReferenceDayReached. Aggregat Compliance, aggregateId tsk:<Jahr>, Payload { year, referenceDay }, Schlüssel TskReferenceDayReached:tsk:<Jahr>:<Tag>. Die Regel prüft den Stichtag des Jahres des Berliner Laufs und des Vorjahres mit einem Nachholfenster von 7 Tagen: Ein verpasster Lauf wird nachgeholt (Lauf am 05.03. nach verpasstem 03.03. ergibt ein Ereignis, einen Entwurf und eine Meldung je Nutzer). Der Tag ist der Berliner Kalendertag; den Stichtag liest die Regel aus der Zeile ComplianceSettings (tskReferenceDay). Im Code steht kein Vorgabewert: Fehlt die Zeile, legt complianceSettingsIn(db) sie mit dem Schema-Vorgabewert (01-01) an; die Migration 20261016100500 sät sie. Zweimal am Stichtag gelaufen heißt: ein Ereignis, ein Entwurf.
  • Umschalter wirkt erst beim nächsten Ereignis. Schaltet der Inhaber noticeOnColonyCountChange oder noticeOnApiaryChange von aus auf an und besteht bereits eine Abweichung, kommt die Meldung erst beim nächsten Herd-Ereignis; die Einstellung löst selbst kein Ereignis aus und kein Handler hört auf Einstellungsänderungen (Annahme 80).
  • Die e2e-Dateien der Stufe sichern und stellen die geseedete Zeile ComplianceSettings wieder her (nie deleteMany auf der Tabelle) und räumen nur selbst angelegte Anzeigen ab.

12.10 Prüfbereitschaft I

Task 12 liefert den Rahmen der Prüfbereitschaft mit den ersten sieben Regeln, Task 13 ergänzt die übrigen elf (zusammen die 18 der Spec, Ergänzungen am Ende dieses Abschnitts).

  • Modul und Worker-Schutz. ReadinessModule (src/compliance/readiness/) ist nur in AppModule. Der Controller hängt am PermissionResolverService und darf nicht in den Worker-Graphen. readiness-worker-graph.spec.ts läuft über die imports des WorkerModule und prüft, dass weder ReadinessModule noch ReadinessController darin liegen.
  • READINESS_RULES_TOKEN. Die Regelliste (READINESS_RULES in rules/index.ts) wird unter dem Token bereitgestellt; ReadinessService und AcknowledgementsService bekommen sie per Konstruktor. Unit-Specs übergeben Fake-Regeln direkt, die e2e überschreibt den Provider ([...READINESS_RULES, softRule]), ohne die geteilte Liste zu verändern. rules/index.spec.ts prüft eindeutige Schlüssel und eindeutige subjectId je Regel.
  • Sieben reine Regeln. Jede Regel ist eine pure Funktion evaluate(ctx): holder-data, food-business-data (Pflicht), vet-office (Prüfen), registration (keine SENT-Erstanzeige), herd-change (Abweichung zum Bezugsstand laut Einstellung, nie ohne Bezugsstand), tsk-report (Stichtag erreicht ohne SENT-Zählung, „überfällig“ nach tskReportDueDay; das Vorjahr bleibt offen, solange sein Entwurf existiert) und restriction-active (ein Punkt mit allen gesperrten Ständen). Eine Regel ohne Befund ergibt genau einen erledigten Punkt (DONE, subjectId global). Keine dieser sieben Regeln ist quittierbar (Spec 5.7, Annahme 85); die Quittierung ist als Mechanik gebaut (acknowledgeable) und wird erst von den weichen Regeln aus Task 13 genutzt (vier quittierbare Regeln, siehe unten).
  • Live-Auswertung, nur Quittungen gespeichert. GET /compliance/readiness lädt einen Kontext (Stammdaten, Einstellungen, Bestandsstatus, Anzeigen, aktive Sperren, jeweils begrenzt) und führt jede Regel aus; gespeichert wird allein ComplianceAcknowledgement. Tage (today, Stichtag, „überfällig“) sind Berliner Kalendertage; die Sperre zählt über isRestrictionActive, dieselbe Funktion wie der Wächter an den Völker-Pfaden (12.7), damit Anzeige und Sperre nie auseinanderlaufen.
  • Fingerabdruck. sha256 über kanonisches JSON der für den Befund maßgeblichen Werte; Listen (z. B. apiariesAdded/Removed/Relocated bei herd-change) werden vorher sortiert, damit dieselben Daten in anderer Reihenfolge denselben Abdruck ergeben (Test).
  • Quittung, Rücknahme, veraltet. Quittiert wird mit Notiz (10 bis 2000 Zeichen); der Fingerabdruck muss zum jetzt berechneten Punkt passen (sonst 409). Eine Quittung gilt nur, solange sie nicht zurückgenommen ist und ihr Fingerabdruck noch passt: Eine zurückgenommene oder veraltete Quittung öffnet den Punkt wieder. Quittieren und Zurücknehmen schreiben Audit (CREATE/UPDATE an ComplianceAcknowledgement); das Zurücknehmen sperrt die Zeile und setzt revokedAt in SQL.
  • Rot und Schwere. Jeder offene Punkt ist rot; die Schwere steht als Wort („Pflicht“ oder „Prüfen“) daneben. Gesamtstatus READY, wenn kein Punkt offen ist, sonst OPEN.
  • Höchstens 50 Subjekte. Betroffene Objekte stehen in subjects (höchstens 50), der Rest wird in more gezählt.
  • Maskierung je subjectPermission. Titel und Detailtexte enthalten nie Namen, Anschriften, Aktenzeichen oder Notizen. Hat der Aufrufer das Recht der Regel (subjectPermission, hier apiaries:read) nicht, bleiben subjects leer und more zählt sie; summary wertet ohne Subjekte aus. Der Stammdaten-Punkt nennt nur die Lücke („Name“, „Anschrift“), nie den Wert. Die Zählungen bleiben sichtbar („N Stände liegen im Sperrbezirk“), auch maskiert; die Seite verlangt ohnehin compliance:read, nur die Namen hängen am Recht (Annahme 81). Die Arzneimittel-Regeln aus Task 13 halten Mittel, Packung, Lieferant und Tierarzt aus detail und title heraus; welches Recht die Namen freigibt, steht je Regel in der Tabelle unten.
  • Lesepfade schreiben nicht. loadReadinessContext liest SiteSettings per findUnique; fehlt die Zeile, gilt ein leerer In-Memory-Standard (alle Felder null, holder-data meldet die Lücken), kein Upsert. ComplianceSettings kommt aus herdStatusIn (12.8), das nur anlegt, wenn die von der Migration gesäte Zeile fehlt.
  • Registrierungs-Bezug separat. Die jüngste SENT-Erstanzeige (nach sentOn, createdAt, id) und der jüngste DRAFT werden in eigenen Abfragen gelesen, nicht aus einer begrenzten Gesamtliste; nur der TSK-Teil ist auf 100 begrenzt. Der Bezugsstand für herd-change kommt unverändert aus herdStatusIn (findBaselineIn).
  • Endpunkte. GET /compliance/readiness, GET /compliance/readiness/summary (compliance:read); POST /compliance/acknowledgements (@Idempotent) und POST /compliance/acknowledgements/:id/revoke (compliance:manage). BEEKEEPER und ADMIN alles, STAFF und READONLY nur Lesen, API_CLIENT nichts.
  • Oberfläche: Die Zuordnung „Schwere → Wort“ und „OPEN → rot mit Symbol und Wort“ hat seit Task 14 einen eigenen Test (labels.spec.ts, Abschnitt 12.11).

Task 13: die 18 Prüfpunkte. Die Liste READINESS_RULES hat jetzt 18 Regeln in fünf Bereichen; rules/index.spec.ts prüft Bereich, Schwere, acknowledgeable und subjectPermission aller 18. Alle Regeln sind weiter reine Funktionen auf dem Kontext (loadReadinessContext(db, now, { withdrawal }) mit den Feldern treatments, moves, lots, medicines, templates, harvests; ReadinessModule importiert dafür WithdrawalModule).

Bereich Regel (Schlüssel) Schwere quittierbar Namen frei mit
Stammdaten holder-data, food-business-data Pflicht – –
Stammdaten vet-office Prüfen – –
Behörden registration, herd-change, tsk-report, restriction-active Pflicht – apiaries:read bei herd-change, restriction-active
Tierarzneimittel treatment-log-gaps (ein Punkt je Kalenderjahr) Pflicht – treatments:read
Tierarzneimittel lot-gaps, lot-prescription Pflicht – veterinary-medicines:read
Tierarzneimittel treatment-location-uncertain Prüfen – treatments:read
Tierarzneimittel medicine-withdrawal-missing, lot-negative Prüfen – veterinary-medicines:read
Tierarzneimittel lot-expired-stock (je Packung), treatment-expired-lot (je Behandlung) Prüfen ja veterinary-medicines:read
Etiketten label-template-gaps Pflicht – –
Etiketten label-template-form Prüfen ja –
Ernte harvest-withdrawal (je Ernte) Prüfen ja treatments:read
  • Gleiche Funktionen wie das PDF. treatment-log-gaps nutzt resolveSourceValues, treatmentLogGaps und groupTreatmentLog des Bestandsbuchs (12.2): Rückblick sind das laufende und fünf vorige Kalenderjahre (Jahr der UTC-Zeit wie im PDF; ein Eintrag um Mitternacht zum Jahreswechsel kann im Nachbarjahr stehen). Eine lückenhafte Packung steht einmal als Subjekt LOT (Label „Packung der Behandlungen“), Behandlungen ohne Packung als Subjekt TREATMENT mit dem Volk als parentId; Mengen- und Wartezeitlücken bleiben auf der Behandlung, die Tierarzt-Lücke gibt es nur bei verschreibungspflichtigem Mittel. treatment-location-uncertain meldet die *-Zeilen (apiaryOnDate(...).uncertain). medicine-withdrawal-missing zählt 0 Tage als erfasst. lot-expired-stock nimmt den Rest in den Fingerabdruck auf: Ändert sich der Rest, öffnet sich die Quittung wieder.
  • Etiketten. label-template-gaps und label-template-form prüfen jede aktive Vorlage mit demselben checkLabel wie der Druck (12.4, 12.6; buildPlaceholderValues, expectedParticulars, Ziffernregeln nur aus ComplianceSettings), je Vorlage einmal gemessen (label-checks.ts). label-template-form meldet nur Warnungen, sein Fingerabdruck sind die Warn-Codes. Das Ziel der Prüfung ist die jüngste Abfüllung mit Restbestand: Der Lader liest die Abfüllungen seitenweise (200, neueste zuerst) und hört bei der ersten mit Restbestand (summarizeStock) auf; gibt es keine, gilt die jüngste, sonst SAMPLE_BOTTLING (Beispielwerte). Ungültige netQuantityDigitRules ergeben in der Prüfbereitschaft „keine Regel“ (die Höhe der Füllmenge wird nicht beurteilt), der Druck lehnt sie dagegen mit 409 ab.
  • Ernte. harvest-withdrawal rechnet keine Fenster selbst: Der Lader ruft WithdrawalCheckService.conflictsForHarvests für die Ernten der letzten 24 Monate auf, der Text kommt aus withdrawalRangeText (längstes Fenster).
  • Arzneimittel-Namen maskiert. Regeln mit subjectPermission: 'veterinary-medicines:read' zeigen Packungen und Behandlungen ohne dieses Recht nur gezählt (subjects leer, more zählt). Titel, Text und Subjekt-Labels enthalten nie Produktnamen oder Mittelnamen; ein Label heißt allgemein („Packung der Behandlungen“), die Zuordnung zum Mittel steht erst hinter dem Beheben-Link. Die Namen der Lücken (missing) sind deutsche Feldnamen (Lieferant, Beleg, Menge, Wartezeit, Tierarzt) bzw. die Titel der Etikett-Befunde. Neue Subjekt-Typen: TREATMENT, LOT, MEDICINE, LABEL_TEMPLATE, HARVEST.
  • Prüfbericht als PDF. GET /compliance/readiness/report.pdf (compliance:read) liefert pruefbericht.pdf (A4 hoch, no-store, renderReadinessReportPdf in readiness-report.ts). Inhalt: Kopf mit Halter (FEHLT, wenn leer), Datum, Gesamtstatus und dem Satz „Jeder offene Punkt ist zu behandeln.“, je Bereich eine Tabelle mit Status (Offen, Erledigt, Quittiert), Schwere als Wort, Titel, Text, Norm (130 pt breit, „Fertigpackungsverordnung“ in einer Zeile) und bei Quittungen Notiz, Nutzer und Datum. Gedruckt werden nur Zählungen („n Objekte“), nie Namen von Objekten (Annahme 90); Quittungsnotizen werden nach 600 Zeichen mit „…“ gekürzt. Die Auswertung läuft mit den Rechten des Aufrufers. Ein Aufrufer mit compliance:read ohne veterinary-medicines:read bekommt 200, und das PDF enthält weder Mittelnamen noch Behandlungsprodukte (e2e).
  • Kurzfassung gecacht. ReadinessService.summary(now) hält das Ergebnis je Prozess 60 Sekunden (SUMMARY_TTL_MS, Zeitbasis ist das übergebene now). AcknowledgementsService.create (nach Erfolg) und revoke rufen invalidateSummary() auf; ein abgelehntes Quittieren lässt den Cache stehen. Liste (GET /compliance/readiness), PDF-Bericht und Quittierpfad rechnen immer live. Änderungen an den Daten und Quittungen anderer Prozesse zeigen sich in der Kurzfassung bis zu 60 Sekunden später (Annahme 86).
  • 409-Codes beim Quittieren. Die Antwort hat den Körper { code, message } (Meldungen unverändert): READINESS_FINGERPRINT_STALE (Fingerabdruck passt nicht mehr; „Der Punkt hat sich geändert. Bitte neu laden.“), READINESS_ITEM_NOT_OPEN (Punkt weg oder erledigt; gleiche Meldung) bei POST /compliance/acknowledgements, und READINESS_ACK_REVOKED („Die Quittung ist schon zurückgenommen.“) bei …/:id/revoke. Die Konstanten stehen in acknowledgements.service.ts; die Oberfläche wertet die Codes aus (12.11), der Text ist nur Rückfall.
  • Lastgrenze. Eine Auswertung lädt alle Packungen, sechs Jahre Behandlungen und die Ernte-Konflikte (windowsOf liest alle Behandlungen der beteiligten Völker). Das ist für den Betrieb unkritisch und der Grund für den 60-s-Cache der Kurzfassung (Dashboard); bei sehr vielen Daten sind Index oder weitere Zwischenspeicher zu prüfen. Einzelpunkte je Behandlung oder Ernte können lang werden, die Oberfläche klappt sie ein.

12.11 Oberfläche (Frontend)

Die Oberfläche des Compliance-Blocks entstand in Task 14 bis 19: Prüfbereitschaft (14), Tierarzneimittel (15), Behandlungsmaske (16), Etikett (17), Anzeigen und Einstellungen (18) und Sperrbezirk (19). Alle Aktionen sind online-only: Auslöser sind offline deaktiviert und nennen den sichtbaren Grund „Nur mit Verbindung“ (aria-describedby). Der Service-Worker-Ausschluss steht in Abschnitt 2.

Prüfbereitschaft (Task 14). Code in lib/compliance/ (types, api, fix-links, errors, labels, use-readiness; Query-Schlüssel ['compliance', …]) und app/(app)/compliance/.

  • Seite /compliance zeigt GET /compliance/readiness. Oben der Status: „Prüfbereit“ (grün) oder „n offen“ (rot), jeweils mit Symbol und Wort. Jeder offene Punkt ist rot (Token destructive) und trägt Symbol, das Wort „Offen“, die Schwere als Wort „Pflicht“ oder „Prüfen“ und den Titel; dazu ein Satz Detail und klein die Norm. Betroffene Objekte stehen eingeklappt mit einem „Beheben“-Link je Objekt, „und n weitere“ für den Rest; fehlt das Recht auf die Namen, steht nur „n betroffen“. Erledigte Punkte stehen eingeklappt unter „Erledigt (n)“, Quittierte ruhig mit Nutzer, Datum und Notiz. Die Zuordnung Schwere → Wort und offen → rot liegt allein in labels.ts und hat einen eigenen Test. Hauptaktion ist „Nächsten Punkt lösen“ (erster offener Pflicht-Punkt); ist dessen Ziel noch nicht gebaut, heißt sie „Nächsten Punkt ansehen“ und springt auf die Karte (#item-<Schlüssel>-<Subjekt>). Bei grünem Status steht dort „Bestandsbuch {Jahr} drucken“. Hinter „Mehr“ liegen der Bericht (PDF über downloadReadinessReport, als Blob mit Bearer-Header, Dateiname pruefbericht.pdf), das Bestandsbuch, Anzeigen, Sperrbezirke und Einstellungen. Downloads sind offline aus.
  • Beheben-Links aus einer Stelle: fixLinkFor(itemKey, subject) in fix-links.ts bildet Subjekte auf Seiten ab (Typen ohne Beachtung der Groß-/Kleinschreibung). Die Ziele /compliance/notices(/[id]), /compliance/restrictions und /compliance/settings schaltet ROUTE_AVAILABLE seit Task 18 und 19 ein (vorher blendete die Menge solche Links aus, available: false). Eine Behandlung verlinkt auf /colonies/{parentId}, Stammdaten auf /settings/automation?tab=social-media (Reiter „Stammdaten & Web“). herd-change führt zur Anzeige (/compliance/notices), nicht zur Sperrenliste; restriction-active ohne Subjekt zur Sperrenliste.
  • Quittieren und Zurücknehmen (acknowledge-dialog.tsx): nur bei acknowledgeable und offenem Punkt bzw. quittiertem Punkt, nur mit compliance:manage. Notiz 10 bis 2000 Zeichen, vor dem Senden geprüft; Zurücknehmen über ConfirmDialog. Fehler laufen über readinessConflictText, der die drei Codes READINESS_FINGERPRINT_STALE, READINESS_ITEM_NOT_OPEN und READINESS_ACK_REVOKED mit je eigenem deutschem Text auswertet (der Servertext ist nur Rückfall); bei jedem 409 laden alle compliance-Queries neu. Nach Erfolg und Neuladen geht der Fokus auf die Kartenüberschrift (tabIndex=-1), eine role="status"-Region sagt „Punkt quittiert“ bzw. „Quittung zurückgenommen“. lib/compliance/errors.ts liest außerdem DRAFT_EXISTS und RESTRICTED_ZONE, alles andere läuft über saveErrorText; es gibt keine zweite Fehlerlogik.
  • Dashboard-Zeile (components/today/compliance-line.tsx): „n Punkte offen in der Prüfbereitschaft“, nur mit compliance:read und openCount > 0, aus GET /compliance/readiness/summary (60 s gecacht, Annahme 86). Schlägt das Laden fehl (kein Netzfehler; offline bleibt es still), steht eine Fehlerzeile „Prüfbereitschaft nicht geladen.“ mit „Erneut versuchen“.
  • Navigation: nav-config.ts hat „Prüfbereitschaft“ (Symbol ShieldCheck, Gruppe „Betrieb“, Recht compliance:read).

Tierarzneimittel (Task 15). Seiten unter app/(app)/medicines/, Daten aus lib/medicines/api.ts; kein eigener Nav-Eintrag, der Einstieg ist die Karte „Tierarzneimittel“ auf /storage (nur mit veterinary-medicines:read).

  • Seiten. /medicines (Katalog mit Rest und nächstem Verfall, Reiter Aktiv/Deaktiviert; bei leerem Katalog „Aus deinen Behandlungen übernehmen?“ mit GET …/suggestions: „Übernehmen“ öffnet nur das Formular, gespeichert wird erst beim Absenden), /medicines/[id] (Angaben, Packungen, Hauptknopf „Packung erfassen“) und /medicines/lots/[id] (Rest, Angaben, Bewegungsbuch, Beleg, Hauptknopf „Inventur“). Hauptknöpfe: „Mittel anlegen“ (create), „Packung erfassen“ (create), „Inventur“ (update); Entsorgung, Bearbeiten und (De-)Aktivieren sind Nebenaktionen mit update. Ist das Mittel inaktiv, ist „Packung erfassen“ mit sichtbarem Grund „Das Mittel ist deaktiviert.“ gesperrt (Annahme 61). Mit Packungen entfällt das Einheitsfeld (Annahme 62), unit wird nie gesendet; geleerte Felder gehen als null hinaus.
  • Dialoge. Mittel, Packung, Inventur (zeigt die Differenz live in einem aria-live-Bereich, Buchen bei Differenz 0 gesperrt) und Entsorgung (Menge, Pflichtgrund; über den Rest hinaus erlaubt, mit roter Warnung samt Symbol und Wort „Negativ“). Server-Meldungen („Das Mittel ist deaktiviert.“, „Die Einheit des Mittels passt nicht zur Packung.“) erscheinen im Dialog. Ein negativer Rest erscheint überall rot mit Warnsymbol und Wort (RestText).
  • use-attempt-key (lib/medicines/use-attempt-key.ts, Ansatz der Abfüllung): Jeder Anlege- und Buchungsaufruf trägt einen Idempotency-Key. Der Schlüssel bleibt nur, wenn der Body unverändert ist und der letzte Versuch am Netzwerk scheiterte; jede Serverantwort ohne Erfolg und jede Änderung des Bodys erneuert ihn (sonst würde der Server mit dem Schlüssel einen alten Body wiedergeben). Er gilt in allen vier Dialogen.
  • parseDecimal: Tausenderpunkt-Regel. Das Komma ist immer das deutsche Dezimalzeichen („1,000“ ist 1). Ein Punkt gilt nur als Dezimalpunkt, wenn er das einzige Trennzeichen ist und nicht wie ein Tausenderpunkt aussieht (^\d*[1-9]\d*\.\d{3}$). „1,5“, „1.5“ und „0.001“ gehen; „1.000“, „12.345“ und jeder Text mit mehr als einem Trenner (auch „1.234,5“) sind ungültig mit „Bitte ohne Tausenderpunkt eingeben, z. B. 1000 oder 1,5“ (Annahme 88). Gilt in allen Mengenfeldern; sie sind Textfelder mit inputMode="decimal".
  • Bewegungsbuch (lib/medicines/book.ts): Eingang als erste Zeile, laufender Stand auf Tausendstel gerundet, neueste zuerst, Datum ist der Berliner Tag der Buchung. Hinweis bei Abweichung: Weicht der letzte rechnerische Stand von lot.remaining des Servers ab, steht unter der Tabelle „Stand weicht vom Server ab – bitte neu laden“.
  • Seite ohne Leserecht. Die drei Seiten laden ohne veterinary-medicines:read nichts (enabled: false) und zeigen „Dazu fehlt dir das Recht.“ ohne Erneut-Knopf (components/medicines/no-read-access.tsx, Link zurück zum Lager).
  • Belege. DocumentUploadCard (Art MEDICINE_LOT, Titel „Beleg“) hat die Eigenschaft canWrite (Vorgabe true); die Packungsseite übergibt canWrite={canUpdate}. Ohne sie entfallen Hochladen, Umbenennen und Löschen, Herunterladen bleibt. DocumentEntityType kommt aus DOCUMENT_ENTITY_TYPES und kennt MEDICINE_LOT, AUTHORITY_NOTICE, APIARY_RESTRICTION.
  • Hinweis Bestandsbuch (Annahme 73). Auf der Standseite ist der Menüeintrag „Bestandsbuch (PDF)“ ohne veterinary-medicines:read deaktiviert (und nicht fokussierbar, weil die Menükomponente aria-disabled nicht zulässt); dauerhaft steht darunter die Zeile „Das Bestandsbuch braucht das Recht „Tierarzneimittel lesen“.“. Für Task 16: fetchSelectableLots(medicineId, includeLotId?) liefert nur Packungen aktiver Mittel mit Rest > 0.

Etikett (Task 17). Das Frontend entscheidet nichts über Pflichtangaben; es zeigt, was das Backend (12.4, 12.6) prüft.

  • Prüfpanel im Designer (components/labels/label-compliance-panel.tsx): Der Designer sendet 500 ms nach der letzten Änderung nur die Text-Elemente an POST /label-templates/check (Hook useDebouncedLabelCheck in lib/label-templates/check.ts; nur Text-Änderungen lösen aus, Eingaben außerhalb des technischen Bereichs lösen keinen Aufruf aus). Ein neuer Entwurf und das Aushängen brechen den laufenden Aufruf ab (AbortSignal); bei einem ungültigen Entwurf bleibt das letzte Ergebnis stehen, ist aber als „Veraltet: …“ markiert (stale). Das Panel zeigt die sechs Pflichtangaben mit Symbol und „Erfüllt“/„Fehlt“, Hinweise getrennt und mit „Hinweis:“ beginnend; die Live-Region (aria-live="polite") enthält nur die Summenzeile.
  • Keine Rechtszahlen im Frontend. Mindest-x-Höhe und Ziffernhöhen kommen ausschließlich aus requirements der Prüfantwort („Die Prüfung verlangt aktuell mindestens X mm“); der Hinweistext am Feld „Mindest-x-Höhe (mm)“ nennt keine Zahl, DEFAULT_MIN_X_HEIGHT_MM gibt es nicht. Der Pflichtangaben-Block (compliance-block.ts, „Pflichtangaben-Block einfügen“, mit Rückgängig) rechnet die Schriftgrößen aus requirements. Die Schranken 0,5 bis 5 mm des Feldes sind der technische Bereich des DTO, kein Rechtswert. Eine neue Vorlage startet mit leerem Feld und sendet kein minXHeightMm (der Server setzt seinen Standard); bis ein Wert da ist, läuft keine Prüfung (Annahme 89).
  • Druckkarte der Abfüllung (bottling/[id]/print-labels-card.tsx): Vor dem Anzeigen läuft GET /label-templates/:id/check/:bottlingBatchId (gcTime: 0, keine Persistenz, cache: "no-store"). Bei Lücken zeigt sie die rote Lückenliste (Region, Symbol und „Fehlt:“) mit Links zu Stammdaten, Produkt und Designer (der Produktlink entfällt ohne Produkt an der Abfüllung); „Etiketten als PDF“ ist dann aria-disabled (fokussierbar, Grund per aria-describedby). Der Probedruck („Probedruck (MUSTER)“, proof=true) bleibt immer möglich; sein Dateiname enthält „muster“, der des echten Drucks nicht. Antwortet der Druck mit 409 LABEL_PARTICULARS_MISSING, zeigt die Karte die Liste gaps des Servers und setzt den Fokus darauf. 409 COMPLIANCE_SETTINGS_INVALID und 403 erscheinen als deutsche Meldung (labelCheckErrorText). Ein zweiter Klick während der Wartezeit löst keinen zweiten Aufruf aus. Ohne label-templates:read steht nur ein Hinweis, ohne :print gibt es keine Druckknöpfe, ohne Netz „Nur mit Verbindung“. Prüf- und Druckaufrufe laufen mit cache: "no-store" (apiFetchBlob reicht init durch).
  • Stammdaten. Im Reiter „Stammdaten & Web“ (Verwaltung > Automatisierung) stehen foodBusinessName (200) und foodBusinessAddress (300); leere oder nur aus Leerzeichen bestehende Felder werden als null gesendet (value.trim() || null), damit sie serverseitig löschen. „Vom Bienenhalter übernehmen“ füllt nur die Felder, ohne zu speichern. Der Text nennt, dass sie weder im Shop noch auf der Herkunftsseite erscheinen.
  • Produkt legalName. Das Feld „Bezeichnung auf dem Etikett (z. B. Blütenhonig)“ steht im Typ Product, im Neu-Formular, im Bearbeiten-Dialog und auf der Detailseite; Platzhalter-Schlüssel und deutsche Labels liegen in lib/label-templates/placeholder-keys.ts (Wächter-Spec gegen test/fixtures/placeholder-keys.json).

Behandlung mit Mittel, Packung und Dosis (Task 16). Formular „Neue Behandlung“, Dialog „Behandlung bearbeiten“ und Sammelerfassung.

  • Mittel. „Aus dem Katalog wählen“ bietet die aktiven Mittel (nur mit veterinary-medicines:read; ohne das Recht gibt es nur „Anderes Mittel“, ohne Fehler); „Anderes Mittel“ ist die letzte Option mit dem Freitextfeld wie bisher. Ein Katalogmittel füllt Wirkstoff, Wartezeit, Methode und Dosis als sichtbaren, bearbeitbaren Vorschlag mit dem Vermerk „aus Katalog“. Er gewinnt gegen die Vorbelegung aus der letzten Behandlung, überschreibt aber nie, was der Nutzer getippt hat; der Server füllt nichts nach. Lade- und Fehlerzustand des Katalogs zeigt PrefillStatus, Speichern bleibt möglich.
  • Packung. Beim Anlegen wird die Packung mit dem frühesten Verfall vorgewählt (mit Hinweis); „Ohne Packung“ bucht nichts und wird nicht wieder überschrieben. Beim Bearbeiten wählt die Oberfläche nie selbst eine Packung vor (nach einem Mittelwechsel steht „Ohne Packung“, der Nutzer wählt; Annahme 92). Ist eine Packung gesetzt, stammen Chargennummer, Lieferant, Beleg und Tierarzt aus der Packung (Zeile „aus Packung: …“); die Nutzlast lässt diese Felder weg (der Server lehnt Text daneben mit 400 ab), product entfällt mit Katalogmittel. Über „Speichern“ steht die Buchungszeile („Bucht 30 ml je Volk ab (× 6 = 180 ml).“, in der Sammelerfassung mal Anzahl der Völker). Abgelaufene oder zu kleine Packungen warnen, blockieren nie. Der Temperaturhinweis („Heute 9–17 °C. Laut Katalog 12–25 °C.“) kommt aus GET /apiaries/:id/weather (apiaries:read) und entfällt still, wenn das Wetter fehlt.
  • Dosis. Dezimalfeld, kein NumberStepper (der kennt nur ganze Zahlen): Komma als Dezimalzeichen, mindestens 0,001, höchstens 3 Nachkommastellen („Höchstens 3 Nachkommastellen.“, Annahme 94), „1.000“ wird als mehrdeutig abgelehnt (Annahme 88).
  • Mittelwechsel beim Bearbeiten. Der Dialog nutzt dieselbe Katalog-Vorbelegung wie das Anlegen; was die Behandlung schon enthält, gehört dem Nutzer, nur leere Felder füllt der Katalog. Eine vorhandene Wartezeit wird beim Wechsel nie still ersetzt: Weicht sie vom Katalogwert des neuen Mittels ab (oder kennt der Katalog keinen), steht am Feld „Wartezeit stammt vom vorherigen Mittel – bitte prüfen.“, dazu der Knopf „aus Katalog übernehmen“ (nur mit Katalogwert) als ausdrückliche Übernahme. Eine Eingabe im Feld entfernt die Warnung.
  • Patch. medicinePatch sendet die Mittel-Gruppe (product, medicineId, medicineLotId, doseQuantity, vier Packungstexte) nur, wenn sich in ihr etwas geändert hat; eine reine Notiz-Änderung bleibt so immer möglich.
  • Fehler und Deep-Link. Anlegen und Dialog lesen Fehler über einen Leser, saveErrorText (kennt auch Feldfehler-Listen des Servers). ?editTreatment=<id> an der Volksseite öffnet den Dialog dieser Behandlung; findet die geladene Liste die ID nicht, sagt eine Live-Region „Behandlung nicht gefunden.“.
  • Entwurf wiederherstellen (treatments/new): Ist das Mittel des Entwurfs nicht mehr im Katalog, nicht aktiv oder fehlt das Leserecht, fallen medicineId und medicineLotId weg, product bleibt als Freitext, und eine Statuszeile sagt „Das Mittel aus dem Entwurf ist im Katalog nicht mehr aktiv. Bitte wähle es neu.“ Ist die Katalogliste noch nicht geladen, wird nichts verworfen.

Anzeigen und Einstellungen (Task 18). Seiten unter app/(app)/compliance/, Fehlertexte über complianceErrorText (lib/compliance/errors.ts).

  • Liste /compliance/notices: Anzeigen ans Veterinäramt (Art, Jahr, Status mit Symbol und Wort, Völkerzahl, Stichtag), Filter nach Art und Jahresfilter (vier Ziffern, sonst Hinweis am Feld; Server-Query year). Ab 200 Einträgen steht der Hinweis „Es werden die neuesten 200 angezeigt.“ (Obergrenze der Liste).
  • Anlegen: „Anzeige anlegen“ (nur compliance:manage) öffnet einen Dialog (Erstanzeige, Bestandsänderung, TSK-Zählung mit Jahr; „Bereits angezeigt“ mit Datum legt direkt eine gesendete an). Der Aufruf trägt einen Idempotency-Key. Gibt es schon einen Entwurf, antwortet der Server 409 DRAFT_EXISTS mit existingId; der Dialog verlinkt auf den vorhandenen Entwurf.
  • Detail /compliance/notices/[id] mit drei sichtbaren Schritten (1. PDF öffnen, 2. ans Amt schicken, 3. hier bestätigen; je Symbol und Wort Jetzt/Danach/Erledigt). Der PDF-Download (authentifiziert, kein Token im Link) meldet in der aria-live-Region „PDF heruntergeladen. Schick es jetzt ans Amt.“ und setzt den Fokus auf die neue Hauptaktion. „Als gesendet markieren“ ist im Entwurf immer erreichbar: als zweite Aktion (Umrissknopf neben „Neu zählen“), nach dem PDF als Hauptaktion; so geht es auch nach einem Neuladen ohne erneutes PDF. Das Sendedatum und „Gesendet am“ nutzen den Berliner Kalendertag (berlinCalendarDay); ein geleertes Datum zeigt „Das Datum kann nicht entfernt werden.“ „Neu zählen“ und „Anzeige stornieren“ gibt es nur im Entwurf, „Wieder öffnen“ nur bei gesendeter Anzeige; Kopie und Eingangsbestätigung sind Dokumente der Art AUTHORITY_NOTICE. Mit nur compliance:read fehlen die Aktionsknöpfe. complianceErrorText übersetzt 403 („Dazu fehlt dir das Recht.“) und 5xx („Der Server hat einen Fehler gemeldet. Versuch es gleich noch einmal.“) und fügt eine 400-Antwort mit message: string[] zusammen; Nests Englisch erscheint nie.
  • Einstellungen /compliance/settings: Veterinäramt, Erinnerungs-Schalter (Völkerzahl, Stand), TSK-Stichtag und Meldefrist, Mitgliedsnummer. Jeder Rechts- und Fristwert trägt am Feld „Annahme – bitte prüfen“. Stichtag und Meldefrist (leer erlaubt) prüft der Client vor dem Senden auf MM-TT (isValidMonthDay, Tage je Monat im Nichtschaltjahr, „02-29“ ungültig; Hinweis „Bitte einen Tag im Format MM-TT eingeben, zum Beispiel 03-31. Den 29. Februar gibt es hier nicht.“, aria-describedby, kein Request). Unter „Erweitert“ stehen die gezählten Völkerarten und der Editor der Ziffernregeln (1 bis 10 Regeln; „Regel hinzufügen“ fügt vor der letzten ein, „Entfernen, Regel n“; Höhe 0,5 bis 20 mm, Obergrenzen > 0 und aufsteigend, die letzte Regel hat keine Grenze und geht als null hinaus). Die Schranken sind die Grenzen des Backends (assertDigitRules, Konstanten in compliance-settings.service.ts), keine Rechtswerte; Fehler stehen an der Zeile (role="alert") und als Satz „Bitte die Ziffernhöhen unter „Erweitert“ prüfen.“, weil der Abschnitt eingeklappt sein kann. Ohne compliance:manage sind die Felder nur lesbar.
  • Formular-Schutz: Ein Hintergrund-Refetch setzt eine Eingabe nie zurück. Ohne Eingabe werden neue Serverdaten still übernommen; mit ungespeicherter Eingabe bleibt sie stehen, und „Daten wurden geändert – neu laden?“ mit „Neu laden“ erscheint. Nach eigenem Speichern wird der Zustand direkt übernommen.
  • Meldungskategorie COMPLIANCE („Behörden & Prüfung“) steht in lib/notifications/labels.ts und damit in der Kategorienauswahl der Telegram-Karte. Die Glocke zeigt dafür das Kurz-Etikett „Behörden“ (Annahme 93).

Sperrbezirk (Task 19). Aktive Sperren lädt jede Seite einmal (useActiveRestrictions, Schlüssel ['compliance','restrictions','active'], staleTime 30 s, Neuladen beim Fokus; Umsetzen- und Vereinigen-Dialog laden beim Öffnen neu). Der Server entscheidet endgültig, der Browser blendet nur früh aus.

  • Seite /compliance/restrictions (Recht compliance:read, Pflege mit compliance:manage; ohne Leserecht NoComplianceAccess „Dazu fehlt dir das Recht.“ und keine Abfragen) mit drei Abschnitten Aktiv / Geplant / Beendet (Symbol und Wort; restrictionState rechnet nach dem Berliner Kalendertag, eine Sperre mit Beginn in der Zukunft steht unter „Geplant“, nie unter „Beendet“). Die Live-Region sagt nur Laden und Fehler. Die Verfügung hängt als Dokument (APIARY_RESTRICTION, DocumentUploadCard, schreibend nur mit compliance:manage; ohne Leserecht entfällt die Zeile „Verfügung“).
  • Anlegen: „Sperrbezirk anlegen“ legt je gewähltem Stand (1 bis 50) einen Eintrag an (Seuche: Amerikanische Faulbrut, Kleiner Beutenkäfer oder Andere mit Freitext; Beginn, optional Ende, Behörde, Aktenzeichen, Notizen) mit Idempotency-Key.
  • Beenden: Der Dialog setzt nur endsOn (der Eintrag wird nie gelöscht). Vorgabe ist „gestern“ (nicht vor dem Beginn); endsOn gilt einschließlich. Ist der gewählte Endtag heute oder später, zeigen Hinweis und Toast „Die Sperre gilt noch bis einschließlich TT.MM.JJJJ.“. Ein Aufheben am selben Tag ist nicht möglich (Annahme 91): Ist die Sperre heute aufgehoben, trägt man gestern ein, außer sie begann heute, dann gilt sie bis einschließlich heute.
  • Rotes Band am Stand, solange eine Sperre aktiv ist, mit dem Link „Sperrbezirke ansehen“ (Link nur mit compliance:read).
  • Wächter bei Umsetzen (Quell- und Zielstand), Vereinigen über verschiedene Stände, Standkorrektur am Volk und neuem Volk: rote Meldung („Stand {Name} liegt im Sperrbezirk ({Krankheit}, seit {Datum}). … ist gesperrt.“; ohne Servertext der Rückfall „Der Stand liegt im Sperrbezirk.“) mit Symbol und Wort in einer persistenten aria-live-Region. Der Hauptknopf ist aria-disabled mit aria-describedby auf den Grund (bleibt fokussierbar, Klick und Submit sind gesperrt; ConfirmDialog hat dafür die additive Eigenschaft triggerAriaDisabled). Ableger (split) bleiben frei. Der Umsetzen-Dialog trägt den festen Hinweis zu BienSeuchV § 5 (Gesundheitsbescheinigung, im Zweifel beim Veterinäramt nachfragen) und keine Rechtswerte.
  • Ausnahme. Nur mit compliance:manage erscheint der aufklappbare Abschnitt „Ausnahme mit Begründung“ (10 bis 500 Zeichen; Leerzeichen allein genügen nicht); die Begründung gilt erst nach der Rückfrage im ConfirmDialog, ohne Passwort-Abfrage (Inhaber-Entscheidung 2026-10-15). restrictionOverrideReason wird nur gesendet, wenn die Ausnahme genutzt wird; ändern sich die Sperren, verfällt sie. Ohne das Recht wäre die Antwort immer 403, darum wird das Feld nicht angeboten.
  • Ernte: nur die Warnung „Im Sperrbezirk gelten Auflagen auch für Honig und Waben. Verfügung prüfen.“; sie speichert immer.
  • 409. RESTRICTED_ZONE und „Der Stand wurde gerade geändert. Bitte neu laden.“ erscheinen mit dem Servertext über dem Knopf, der Fokus springt auf die Meldung, die aktiven Sperren laden neu, und ein Knopf „Neu laden“ (Umsetzen, Vereinigen, Standkorrektur) lädt Volk und Stände neu und löscht die Meldung. Kein automatischer Neuversuch.
  • Service Worker. Die Sperrbezirk-Pfade sind seit Task 14 nie zwischengespeichert; offline fehlt die frühe Sperre, der Server entscheidet.

13. Beute (3D-Aufbau)

Der Aufbau einer Beute (Boden, Zargen, Rähmchen, Einlagen, Dach) je Volk. Spec: docs/superpowers/specs/2026-10-15-beute-3d-design.md. Lieferung L1 ist das Backend ohne Oberfläche.

Modul. src/hive (HiveModule): hive.service.ts (Aufbau je Volk), hive-catalog.service.ts (Katalog), hive-ops.ts (applyHiveOps), hive-validate.ts (Validator), hive-facts.ts (abgeleitete Fakten), hive-state-schema.ts (Zustandsfelder der Teile). Hive-Fakten schreiben nie in Colony oder Inspection; Colony.occupiedFrames bleibt die Volksstärke.

Speicherform.

  • HiveSystem (Maße, template) und HivePartType (Art, Maße, frameSlots, stateSchema, active) bilden den Katalog. Die Startdaten (Zander nach Liebig, 10 Rähmchen, 14 Teile) kommen per Migration 20261017100100_hive_catalog_zander_liebig mit festen Ids; test/fixtures/hive-ops/catalog.json ist dieselbe Quelle für das Frontend, ein e2e vergleicht beide.
  • HiveSetup hält den aktuellen Aufbau eines Volks als JSON (Stand und version), HiveChange das Protokoll: je Änderung occurredOn (Berliner Tag), source, die Operationen und stateAfter. Der Stand an einem Tag kommt aus dem letzten stateAfter bis zu diesem Tag. Nachträge sind möglich, aber nie vor dem INIT-Tag und nie in der Zukunft.
  • Katalogteile werden nie gelöscht, nur active: false. Bestehende Aufbauten mit einer inaktiven Art bleiben gültig.

Operationen. addPart, addInsert, removePart, movePart, setState, setFrame, setFrames, setLabel; höchstens 100 pro Anfrage, höchstens 30 Teile im Stapel, Beschriftungen und Rähmchen-Namen höchstens 40 Zeichen. Die Regeln und die Gründe (reason-Codes wie RULE_FLOOR_BOTTOM, STATE_OUT_OF_RANGE, UNKNOWN_FIELD, LIMIT_PARTS) stehen im Kopf von hive-validate.ts; die Prüfreihenfolge entscheidet, welcher Grund zuerst gemeldet wird, und das Frontend muss sie übernehmen. Scheitert eine Operation am aktuellen Stand, wird die ganze Änderung abgelehnt (409 HIVE_CONFLICT).

Endpunkte (/colonies/:id/hive): GET (Stand und Fakten), POST (Aufbau anlegen), POST changes (Änderung mit Idempotency-Key: dieselbe Wiederholung liefert dieselbe Antwort ohne zweite Änderung), GET changes (Historie mit Cursor), GET at (Stand an einem Tag). Katalog: GET /hive-systems, GET /hive-systems/:id/template (Vorlage mit frischen Ids je Aufruf), POST und PATCH /hive-systems, POST /hive-systems/:id/part-types, PATCH /hive-part-types/:id.

Sperrreihenfolge. Colony FOR UPDATE, dann HiveSetup FOR UPDATE, dann HivePartType (LOCKABLE_TABLES in src/prisma/row-lock.ts: HiveSetup direkt nach Colony). Die Version steigt je Änderung um 1; zwei gleichzeitige Änderungen laufen nacheinander, die zweite sieht den Stand der ersten.

Rechte. Lesen colonies:read, Aufbau anlegen und ändern colonies:update, Katalog ändern hive-systems:manage (Gruppe „Beutensysteme“). hive-systems:manage kommt nur über die Katalog-Synchronisierung (PermissionCatalogSyncService) zur Rolle ADMIN, ohne Rechte-Migration. STAFF hat colonies:update nicht und kann die Beute nicht ändern (Annahme 99).

Ereignis. HiveChanged (Outbox, in derselben Transaktion wie die Änderung; Nutzlast colonyId, version, source, inspectionId). Ein nicht lebendes Volk ist schreibgeschützt: 409 wie assertActive.

Durchsicht. POST /inspections nimmt optional hiveChange (ops). Durchsicht und Beute-Änderung werden in einer Transaktion gespeichert und gemeinsam abgelehnt. Ohne colonies:update antwortet der Server 403 HIVE_FORBIDDEN und schreibt nichts. Der Tag der Änderung ist der Tag der Durchsicht (nicht in der Zukunft, nicht vor dem INIT-Tag); die Änderung hat source INSPECTION und verweist auf die Durchsicht.

Backup. Export und Import enthalten HiveSystem, HivePartType, HiveSetup und HiveChange. Katalogzeilen werden nach Id eingespielt (auch in eine frische Installation mit Startdaten). Je Volk gewinnt die Sicherung: Aufbau und Protokoll eines wiederhergestellten Volks ersetzen die vorhandenen. Nicht versiegelte Altdateien ohne diese Tabellen werden gemeldet und übersprungen (Abschnitt 8).

Fehlercodes.

Code Status Bedeutung
HIVE_CONFLICT 409 Operation passt nicht zum aktuellen Stand; mit opIndex, reason, current (Version und Stand)
HIVE_EXISTS 409 Das Volk hat schon einen Aufbau
HIVE_NOT_FOUND 404 Das Volk hat noch keinen Aufbau
HIVE_FORBIDDEN 403 Durchsicht mit hiveChange ohne colonies:update
TEMPLATE_NOT_SET 409 Das System hat noch keine Vorlage
SCHEMA_NOT_ADDITIVE 400 Zustandsfelder dürfen nur angehängt werden
TEMPLATE_INVALID 400 Die Vorlage besteht den Validator nicht

Formfehler (Form der Anfrage, zukünftiger Tag, Tag vor INIT, mehr als 100 Operationen) sind 400. Ein neues System beginnt mit leerer Vorlage; sie wird per PATCH gesetzt, sobald die Teile existieren.