Files
ApiCore/docs/superpowers/plans/2026-10-13-plattform-rueckgrat.md
T
Nick SzaunigandClaude Opus 5.5 c01a38835a feat: platform backbone and field capture, first part (checkpoint)
Platform backbone (plan 2026-10-13-plattform-rueckgrat), reviewed tasks:
- run roles api/worker/job/all with shared startup checks (Task 1)
- schema and migration for DomainEvent, deliveries, JobRun, ThrottlerHit (Task 2)
- audit, notifications and domain events written in the same transaction (Task 3)
- dispatcher with SKIP LOCKED claims, retries and stale release (Task 4)
- worker building blocks: tick, LISTEN/NOTIFY wake-up, health (Task 5)
- worker process and entry points (Task 6)
- throttler counters in Postgres (Task 19)
- separate keys per purpose with key ring (Task 20)
- public origin page without exact location (Task 22)
- shop keys split into read and write (Task 23)
- Kustomize base, components, overlay encry, CI validation (Task 26)

Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), reviewed tasks:
- brood frames, read endpoints, apiary overview and offline pack
- network basics, offline queue v3, navigation sheets, form building blocks
- new inspection with last-time hints and weather, varroa per day,
  treatment prefill, apiary page with round
- inspection photo (Task 10) is implemented, review pending

Not rolled out. Remaining tasks follow in later commits.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 17:57:54 +02:00

121 KiB
Raw Blame History

Plattform-Rückgrat Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: ApiCore läuft aus einem Image in drei Rollen (api, worker, job). Fachliche Folgen hängen nicht mehr an direkten Service-Aufrufen nach dem Commit, sondern an einer dauerhaften Outbox (DomainEvent), die der Worker genau einmal je Handler zustellt. Zeitgesteuerte Arbeit läuft als Kubernetes-CronJob. Rate-Limits zählen über alle Pods. Schlüssel sind je Zweck getrennt. Die öffentliche Herkunftsseite verrät keinen Standort. Die Manifeste in deploy/k8s werden wieder zur Quelle der Wahrheit.

Architecture: NestJS-Backend mit drei Einstiegspunkten: dist/main.js (api/all), dist/worker.js (worker) und dist/cli.js <job> (job). Fachservices schreiben Audit-Eintrag und DomainEvent in derselben Prisma-Transaktion und wecken den Worker per pg_notify. Der Worker übernimmt Ereignisse mit FOR UPDATE SKIP LOCKED und ruft per @OnDomainEvent registrierte Handler auf. Jeder Handler läuft in einer eigenen Transaktion, die seine DomainEventDelivery-Zeile sperrt. Handler schreiben nur in die DB; Netzwerkarbeit liegt in eigenen Outboxen (CalendarSyncOutboxEntry, WebhookDelivery), die Worker-Schleifen abarbeiten. Die Jobs schreiben JobRun. Die Seite „Verwaltung > System“ zeigt Läufe, Warteschlange und Fehler. Kustomize mit base, Komponenten je Rollout-Schritt und Overlay encry.

Tech Stack: NestJS 11, Prisma 7.9 (@prisma/adapter-pg, pg 8), PostgreSQL 18, @nestjs/throttler 6.5, cron 4.4, Jest 30 (Unit + e2e gegen Test-DB), Next.js App Router + TanStack Query + Vitest/RTL, Kustomize, kubeconform, Gitea Actions.

Spec: docs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md, Abschnitte 2–9. Abschnitt 10 ist nicht Teil dieses Plans.


Global Constraints

Rollen und Prozesse

  • APICORE_ROLE ∈ api | worker | job | all. Ohne Wert gilt außerhalb von Produktion je Einstieg: main → all, worker → worker, cli → job.
  • Bei NODE_ENV=production: APICORE_ROLE ist Pflicht, all wird abgelehnt, eine Rolle, die nicht zum Einstiegspunkt passt, wird abgelehnt.
  • Einstiege: node dist/main.js (api/all), node dist/worker.js (worker), node dist/cli.js <job> [args] (job). Exit-Codes von cli: 0 erfolgreich, 1 Job gescheitert, 2 falscher Aufruf.
  • Jobs: daily-evaluation, task-templates, cleanup, app-backup, rekey (dazu rekey --dry-run).

Worker

  • Health: GET /health auf WORKER_HEALTH_PORT (Standard 3002). 200 {"status":"ok"}, wenn SELECT 1 in ≤ 5 s antwortet und der letzte Dispatcher-Durchlauf ≤ 120 000 ms her ist. Sonst 503 {"status":"unhealthy","reason":"database"|"dispatcher-stale"}.
  • Schleifen:
    • Ereignisse: alle 5 000 ms, Weckkanal domain_event.
    • Webhook-Zustellung: alle 5 000 ms, Weckkanal webhook_delivery.
    • Kalender-Outbox: alle 60 000 ms, Weckkanal calendar_sync.
  • pg_notify-Payload ist immer '', also keine Daten im Kanal.
  • Wiederverbinden des LISTEN-Clients: 1 s, verdoppelt, höchstens 30 s.

Dispatcher und Outbox

  • Batch 50, Claim per UPDATE … WHERE id IN (SELECT … FOR UPDATE SKIP LOCKED LIMIT 50) RETURNING *.
  • Wiederholabstand nach Fehlversuch n: 30 s, 2 min, 10 min, 1 h, ab n=5 jeweils 6 h (RETRY_DELAYS_MS = [30_000, 120_000, 600_000, 3_600_000, 21_600_000]).
  • Nach 8 Versuchen (MAX_ATTEMPTS = 8) → FAILED.
  • Liegengebliebener Claim: claimedAt älter als 5 min (STALE_CLAIM_MS = 300_000). Er wird mit attempts + 1 sofort wieder PENDING, ab 8 Versuchen FAILED.
  • Handler-Transaktion: Timeout 10 000 ms. drain() macht höchstens 20 Runden je Takt.
  • Alle Zeitvergleiche und Zeitrechnungen für Claims, Backoff und Throttler laufen in SQL mit now() (DB-Uhr), nie mit einem JS-Date gegen DB-Zeitstempel.
  • Handler sind reine DB-Schreiber: nur über den übergebenen tx schreiben, keine HTTP- oder Graph-Aufrufe. Lesen über tx oder PrismaService ist erlaubt.
  • Zeitbasierte Ereignisse: dedupeKey = "<type>:<aggregateId>:<YYYY-MM-DD>" (UTC-Tag des fachlichen Ereignisses), eindeutig. Nachholfenster 7 Tage.

Webhooks

  • Gleiche Wiederholregel wie der Dispatcher, Batch 50, höchstens 5 gleichzeitig, 5 s Timeout (wie bisher).
  • Nur 2xx gilt als zugestellt.
  • X-ApiCore-Delivery ist die WebhookDelivery.id und bleibt über alle Wiederholungen gleich.
  • Signatur wie bisher über "<timestamp>.<deliveryId>.<body>".

Kalender-Outbox: unverändert MAX_ATTEMPTS = 5, BATCH_SIZE = 20. Sie läuft im Worker.

Throttler: UNLOGGED-Tabelle ThrottlerHit, PK (key, throttlerName), eine einzige Upsert-Anweisung mit now(). Grenzen unverändert: Standard 100/min, sensible Aktionen 10/min, öffentliche Chargen 30/min.

Schlüssel

  • MFA_ENCRYPTION_KEY, WEBHOOK_ENCRYPTION_KEY, BACKUP_ENCRYPTION_KEY sind in Produktion Pflicht: je ≥ 32 Zeichen, paarweise verschieden und verschieden von JWT_ACCESS_SECRET/JWT_REFRESH_SECRET.
  • Optional der vorige Schlüssel als *_PREVIOUS.
  • Chiffrat-Format k<8 hex>:<iv>.<tag>.<data> (Base64-Teile), AES-256-GCM, Schlüssel per HKDF-SHA256 je Zweck.

Öffentliche Herkunftsseite: Cache-Control: public, max-age=300, s-maxage=3600 nur auf 200 von GET /api/v1/public/batches/:lotNumber (Route public/batches/:lotNumber, siehe public.controller.ts). Alle anderen Antworten no-store.

Ressourcen (Spec §7)

Pod CPU request CPU limit RAM request RAM limit
api 50m 500m 192Mi 512Mi
worker 20m 200m 128Mi 256Mi
frontend 30m 300m 128Mi 256Mi
CronJob-Pods und initContainer 50m 500m 192Mi 512Mi
  • HPA api: 1–2 Pods bei 70 % CPU.

CronJobs

  • Alle mit timeZone: Europe/Berlin, concurrencyPolicy: Forbid, startingDeadlineSeconds: 600, successfulJobsHistoryLimit: 3, failedJobsHistoryLimit: 3, ttlSecondsAfterFinished: 86400.
  • Zeitpläne:
    • cleanup: 0 4 * * *
    • daily-evaluation: 30 4 * * *
    • task-templates: 0 5 * * *
    • app-backup: 0 * * * * (stündliche Prüfung, ob fällig)
    • rekey und rekey-dry-run: suspend: true, 0 0 31 2 *
  • backoffLimit: 1 für cleanup, daily-evaluation, task-templates; 0 für app-backup und rekey. activeDeadlineSeconds 1800, für app-backup 3600.

Aufbewahrung (cleanup): DONE-Ereignisse und DELIVERED-Zustellungen 30 Tage, FAILED 90 Tage, JobRun 90 Tage. RUNNING-Läufe älter als 6 h → FAILED („abgebrochen“).

Cluster

  • Namespace apicore, ingressClassName: traefik, Annotation traefik.ingress.kubernetes.io/router.middlewares: apicore-cloudflare-only@kubernetescrd.
  • Labels und Selektoren entsprechen dem Live-Stand (read-only geprüft; z. B. Postgres app.kubernetes.io/name: apicore-postgres, Service-Port postgres) – Ruling 2026-10-13 nach Review Task 26, StatefulSet-Selektoren sind unveränderlich. Neu: app: apicore-worker, app: apicore-job + apicore.io/job: <name>.
  • DB-Zugriff nur für Pods mit db-client: "true".

Projektregeln

  • Sicherheit hat Priorität 1.
  • Konsistenter Code ist oberstes Gesetz: ein Muster, gemeinsame Helfer:
    • retry-policy.ts, outbox-claims.ts, notifyWorker, withTimeout, loggableError, DbClient.
    • Pflicht-tx steht als erster Parameter, optionaler tx als letzter.
  • Jedes Feld ist bearbeitbar (Null-Regel, ARCHITECTURE Annahme 20, update-dto-null-handling.spec.ts).
  • TDD: zuerst der fehlschlagende Test.
  • UI-Texte auf Deutsch; Code-Bezeichner, Log- und Fehlermeldungen auf Englisch wie im Bestand.
  • lastError und Logs nur über loggableError(), nie Payload, Secret oder Antwortkörper.
  • Kein prisma db push. Migrationen als SQL in prisma/migrations/<YYYYMMDDHHMMSS>_<name>/migration.sql. Erzeugt werden sie mit prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script gegen die Test-DB und danach von Hand nachgearbeitet. migrate dev nicht gegen geteilte DBs.
  • Agents committen nicht. Der Orchestrator macht am Ende einen zentralen Commit. Darum gibt es in den Tasks keinen Commit-Schritt, sondern am Ende jedes Tasks einen Schritt „Verifikation“.
  • e2e-Tests laufen gegen die lokale Test-DB aus .env.test (localhost:5433).
    • Test-DB migrieren (Git Bash, in ApiCore-Backend/): export DATABASE_URL="$(grep '^DATABASE_URL=' .env.test | cut -d= -f2- | tr -d '"')" && npx prisma migrate deploy && npx prisma generate
    • Einzelne e2e-Datei: npx jest --config ./test/jest-e2e.json test/<datei>.e2e-spec.ts --runInBand
    • Unit: npx jest <pfad>. Lint wie im Image-Gate: npx eslint "{src,test}/**/*.ts". Typecheck: npm run typecheck.
  • Frontend: vor Next.js-Code node_modules/next/dist/docs/ lesen (siehe ApiCore-Frontend/AGENTS.md). Tests mit npx vitest run "<pfad>", dazu npm run lint und npm run typecheck.
  • Neue Abhängigkeiten nur: pg + @types/pg (Task 5) und cron direkt statt @nestjs/schedule (Task 17).
  • ARCHITECTURE.md ist eine geteilte Datei. Laufen mehrere Tasks einer Welle parallel, führt der Orchestrator deren Doku-Schritte nacheinander aus.

Review Focus

  1. Worker fällt stundenlang aus → Ereignisse stauen sich. Die API arbeitet weiter, der Rückstau muss in Reihenfolge und ohne Verhungern abfließen und in „Verwaltung > System“ sichtbar sein.
    • Tests:
      • Task 4 e2e drains a backlog of 120 events in batches of at most 50
      • Task 24 unit reports the oldest pending age from the database clock
      • Task 18 unit catches up occurrences of the last 7 days exactly once
      • Task 25 vitest warns when the oldest open event is older than 10 minutes
  2. Handler nicht idempotent bei erneuter Zustellung (Stale-Release, zweiter Worker, Retry nach Teilfehler).
    • Gegenmittel: Delivery-Zeile FOR UPDATE plus Handler in derselben Transaktion, und Handler schreiben nur in die DB.
    • Tests:
      • Task 4 e2e two dispatchers in parallel produce exactly one effect per event and handler
      • Task 4 e2e a stale-released event does not repeat a handler that already committed
      • Task 4 Wächter handler files never import network clients
      • Task 11 e2e creates exactly one automation task when the handler fails once and is retried
  3. rekey bricht mittendrin ab. Der Job muss wiederaufnehmbar sein, darf parallele Änderungen (neue MFA-Einrichtung) nicht überschreiben, und Altchiffrate bleiben lesbar, bis die JWT-Secrets rotiert sind.
    • Tests:
      • Task 21 unit resumes after an interruption: current rows are skipped, the rest are rekeyed
      • Task 21 unit skips a row changed since it was read and reports it
      • Task 21 e2e a second run rekeys nothing; dry-run writes nothing
  4. NetworkPolicy sperrt DNS, Probes oder Postgres-Zugriffe.
    • Tests: Task 26 manifest-policy.test.mjs:
      • jeder Pod hat DNS-Egress zu k8s-app: kube-dns UDP/TCP 53
      • Postgres-Ingress nur von db-client: "true"
      • Frontend ohne Weg zu 5432
      • 443-Egress schließt private Bereiche aus
      • Default-Deny vorhanden
    • Dazu Prüfbefehle im Runbook, Schritt 2: Probes, getent, Frontend→Postgres blockiert.
  5. Zwei API-Pods plus Throttler bei Uhrenabweichung. Die Zähler müssen über beide Instanzen gelten, nur mit der DB-Uhr rechnen, und parallele Treffer dürfen nicht verloren gehen. e2e-Dateien dürfen sich nicht gegenseitig drosseln.
    • Tests:
      • Task 19 unit uses only the database clock (no JS dates in the statement)
      • Task 19 e2e counts across two app instances sharing one namespace
      • Task 19 e2e 40 parallel requests over two instances yield exactly 10 × 429
      • Task 19 e2e separate namespaces never share counters

Abhängigkeiten und Parallelisierung

Welle Tasks (parallel, verschiedene Dateien) wartet auf
A 1, 2, 23, 26 –
B 3, 5, 20, 22 3 → 2; 20 → 1; 22 → 2
C 4 3
D 6, 19 6 → 1, 4, 5; 19 → 2, 4
E 7, 8 6
F 9 3, 7, 8
G 10 9
H 11, 12, 13, 14, 15 10 (15 → 3, 9)
I 16, 18, 21, 24 15 (21 → 20; 24 → 4, 7)
J 17, 25 17 → 11–16; 25 → 24, 7

Bekannte geteilte Dateien, deshalb jeweils nur ein Task pro Welle:

  • src/app.module.ts: 3 (B), 19 (D), 15 (H), 17 (J)
  • package.json: 5 (B), 6 (D), 17 (J)
  • test/utils/create-test-app.ts: nur 19
  • src/worker/worker-runtime.service.ts: 6 (D), 7 (E)
  • src/automation/automation.service.ts: 10 (G), 17 (J). Die Producer-Tasks 11–14 fassen die Datei nicht an; ihre Alt-Wrapper entfernt Task 17.

Tasks

Task 1: Laufrollen und gemeinsame Startprüfungen

Files

  • Create: ApiCore-Backend/src/runtime/role.ts, ApiCore-Backend/src/runtime/startup-checks.ts
  • Test: ApiCore-Backend/src/runtime/role.spec.ts, ApiCore-Backend/src/runtime/startup-checks.spec.ts
  • Modify:
    • ApiCore-Backend/src/main.ts: REQUIRED_ENV_VARS, MIN_JWT_SECRET_LENGTH, assertRequiredEnvVars, assertProductionSettings wandern nach startup-checks.ts.
    • ApiCore-Backend/.env.example: APICORE_ROLE und WORKER_HEALTH_PORT dokumentiert, auskommentiert.

Interfaces

  • Consumes: –
  • Produces:
export const APICORE_ROLES = ['api', 'worker', 'job', 'all'] as const;
export type ApicoreRole = (typeof APICORE_ROLES)[number];
export type EntryPoint = 'main' | 'worker' | 'cli';
export function resolveRole(entry: EntryPoint, env?: NodeJS.ProcessEnv): ApicoreRole;
export function assertStartupEnvironment(role: ApicoreRole, env?: NodeJS.ProcessEnv): void;
  • Schritt 1: Failing Tests schreiben (role.spec.ts, startup-checks.spec.ts)
it('defaults per entry point outside production', () => {
  expect(resolveRole('main', {})).toBe('all');
  expect(resolveRole('worker', {})).toBe('worker');
  expect(resolveRole('cli', {})).toBe('job');
});
it('requires APICORE_ROLE in production', () =>
  expect(() => resolveRole('main', { NODE_ENV: 'production' })).toThrow('APICORE_ROLE is required in production'));
it('rejects all in production', () =>
  expect(() => resolveRole('main', { NODE_ENV: 'production', APICORE_ROLE: 'all' }))
    .toThrow('APICORE_ROLE=all is for local development and tests only'));
it('rejects an unknown role', () =>
  expect(() => resolveRole('main', { APICORE_ROLE: 'admin' })).toThrow('APICORE_ROLE must be one of api, worker, job, all'));
it('rejects a role that does not belong to the entry point', () =>
  expect(() => resolveRole('main', { APICORE_ROLE: 'worker' })).toThrow('APICORE_ROLE=worker cannot start dist/main.js'));
it('accepts api for main in production', () =>
  expect(resolveRole('main', { NODE_ENV: 'production', APICORE_ROLE: 'api' })).toBe('api'));
// startup-checks.spec.ts – Verhalten 1:1 aus main.ts übernommen
it('lists missing DATABASE_URL', () =>
  expect(() => assertStartupEnvironment('api', { JWT_ACCESS_SECRET: 'x'.repeat(32) })).toThrow('Missing required environment variable(s): DATABASE_URL'));
it('rejects a JWT secret shorter than 32 characters', ...);
it('requires an https FRONTEND_ORIGIN in production', ...);
it('rejects COOKIE_SECURE=false in production', ...);
  • Schritt 2: npx jest src/runtime → FAIL („Cannot find module './role'“).
  • Schritt 3: Implementieren
    • Erlaubte Rollen je Einstieg: main: ['api','all'], worker: ['worker'], cli: ['job'].
    • Fehlermeldungen exakt wie in den Tests.
    • main.ts: const role = resolveRole('main'); assertStartupEnvironment(role); vor NestFactory.create. Die Modulwahl je Rolle folgt in Task 6.
  • Schritt 4: npx jest src/runtime → PASS.
  • Schritt 5: Verifikation
    • npm run typecheck && npx eslint "{src,test}/**/*.ts" && npm test -- --ci
    • Kein Commit. Geänderte Dateien an den Orchestrator melden.

Task 2: Schema und Migration des Rückgrats

Files

  • Modify: ApiCore-Backend/prisma/schema.prisma
  • Create: ApiCore-Backend/prisma/migrations/20261013100000_platform_backbone/migration.sql
  • Modify: ApiCore-Backend/src/backup/backup.service.ts (MODELS_EXCLUDED_FROM_BACKUP)
  • Test: ApiCore-Backend/src/backup/backup.service.spec.ts (bestehender Wächter), neu ApiCore-Backend/test/platform-schema.e2e-spec.ts
  • Docs: ARCHITECTURE.md §8, Bullet „Export/Import“: Ausnahmeliste um die fünf Modelle ergänzen.

Interfaces

  • Produces (Prisma, ladungstragend):
enum DomainEventStatus { PENDING PROCESSING DONE FAILED }
model DomainEvent {
  id            String            @id @default(cuid())
  type          String
  aggregateType String
  aggregateId   String
  payload       Json
  actorUserId   String?
  correlationId String
  dedupeKey     String?           @unique
  occurredAt    DateTime          @default(now())
  status        DomainEventStatus @default(PENDING)
  attempts      Int               @default(0)
  availableAt   DateTime          @default(now())
  claimedAt     DateTime?
  processedAt   DateTime?
  lastError     String?
  deliveries    DomainEventDelivery[]
  @@index([status, availableAt])
  @@index([correlationId])
  @@index([aggregateType, aggregateId])
}
enum DomainEventDeliveryStatus { PENDING DONE FAILED }
model DomainEventDelivery {
  id          String                    @id @default(cuid())
  eventId     String
  event       DomainEvent               @relation(fields: [eventId], references: [id], onDelete: Cascade)
  handler     String
  status      DomainEventDeliveryStatus @default(PENDING)
  attempts    Int                       @default(0)
  lastError   String?
  completedAt DateTime?
  createdAt   DateTime                  @default(now())
  updatedAt   DateTime                  @updatedAt
  @@unique([eventId, handler])
}
enum WebhookDeliveryStatus { PENDING PROCESSING DELIVERED FAILED }
model WebhookDelivery {
  id             String                @id @default(cuid())
  subscriptionId String
  subscription   WebhookSubscription   @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
  event          String
  body           String
  status         WebhookDeliveryStatus @default(PENDING)
  attempts       Int                   @default(0)
  availableAt    DateTime              @default(now())
  claimedAt      DateTime?
  lastStatusCode Int?
  lastError      String?
  deliveredAt    DateTime?
  createdAt      DateTime              @default(now())
  @@index([status, availableAt])
  @@index([subscriptionId, createdAt])
}
enum JobRunStatus { RUNNING SUCCEEDED FAILED }
model JobRun {
  id         String       @id @default(cuid())
  job        String
  status     JobRunStatus @default(RUNNING)
  startedAt  DateTime     @default(now())
  finishedAt DateTime?
  result     Json?
  error      String?
  @@index([job, startedAt])
  @@index([status])
}
// UNLOGGED in der Migration: kein WAL, nach Absturz geleert – nur Zähler gehen verloren.
model ThrottlerHit {
  key           String
  throttlerName String
  hits          Int
  expiresAt     DateTime
  blockedUntil  DateTime?
  @@id([key, throttlerName])
  @@index([expiresAt])
}
  • Dazu:

    • WebhookSubscription.deliveries WebhookDelivery[]
    • Apiary.publicLocationLabel String?
    • SiteSettings.originCountry String @default("Deutschland")
  • dedupeKey als nullbares @unique statt partiellem Index. Das umgeht die Drift, vor der Spec §3.5 warnt.

  • Schritt 1: Failing Tests

    • Erst die Modelle in schema.prisma eintragen und npx prisma generate laufen lassen. Danach muss der bestehende Wächter scheitern.
    • Neu test/platform-schema.e2e-spec.ts:
it('creates ThrottlerHit as an UNLOGGED table', async () => {
  const [row] = await prisma.$queryRaw<{ relpersistence: string }[]>`SELECT relpersistence FROM pg_class WHERE relname = 'ThrottlerHit'`;
  expect(row.relpersistence).toBe('u');
});
it('keeps at most one event per dedupe key', async () => {
  const data = { type: 'TaskDue', aggregateType: 'Task', aggregateId: `t-${suffix}`, payload: {}, correlationId: 'c', dedupeKey: `TaskDue:t-${suffix}:2026-10-13` };
  expect((await prisma.domainEvent.createMany({ data: [data], skipDuplicates: true })).count).toBe(1);
  expect((await prisma.domainEvent.createMany({ data: [data], skipDuplicates: true })).count).toBe(0);
});
  • Schritt 2:
    • npx jest src/backup/backup.service.spec.ts → FAIL: der Wächter listet DomainEvent, DomainEventDelivery, WebhookDelivery, JobRun und ThrottlerHit als „weder gesichert noch ausgenommen“.
    • e2e → FAIL: Tabelle fehlt.
  • Schritt 3: Implementieren
    • Migration erzeugen (Test-DB, siehe Global Constraints): npx prisma migrate deploy, dann npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script in die neue migration.sql.
    • Von Hand nachbearbeiten:
      • CREATE TABLE "ThrottlerHit" → CREATE UNLOGGED TABLE "ThrottlerHit"
      • Kopfkommentar im Stil von 20261012100000_calendar_sync_claims
    • Danach npx prisma migrate deploy && npx prisma generate.
    • MODELS_EXCLUDED_FROM_BACKUP ergänzen (englische Begründungen):
      • DomainEvent: 'Operational outbox - side effects of restored data must never be replayed after a restore.'
      • DomainEventDelivery: 'Per-handler progress of DomainEvent rows, excluded with them.'
      • WebhookDelivery: 'Delivery log and retry queue - restoring it would resend old events to receivers.'
      • JobRun: 'Run history of this deployment's CronJobs, meaningless elsewhere.'
      • ThrottlerHit: 'Rate-limit counters (UNLOGGED), valid for seconds to minutes.'
  • Schritt 4:
    • Backup-Spec → PASS.
    • npx jest --config ./test/jest-e2e.json test/platform-schema.e2e-spec.ts --runInBand → PASS.
    • Drift-Check npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --exit-code → Exit 0.
  • Schritt 5: Verifikation
    • typecheck, lint, npm test -- --ci
    • ARCHITECTURE §8 ergänzen
    • kein Commit

Task 3: Schreibbausteine in der Transaktion – Audit, Benachrichtigungen, Ereignis-Kern

Files

  • Create:
    • src/prisma/db-client.ts
    • src/common/worker-wakeup.ts
    • src/domain-events/domain-event-types.ts
    • src/domain-events/domain-event-context.ts
    • src/domain-events/domain-events.service.ts
    • src/domain-events/on-domain-event.decorator.ts
    • src/domain-events/domain-event-registry.ts
    • src/domain-events/domain-events.module.ts (@Global, importiert DiscoveryModule)
  • Modify:
    • src/audit/audit-log.service.ts
    • src/notifications/notifications.service.ts
    • src/winter-checks/winter-checks.service.ts: tx.auditLog.create → auditLog.record(…, tx)
    • src/app.module.ts: Import DomainEventsModule
  • Test:
    • src/audit/audit-log.service.spec.ts
    • src/notifications/notifications.service.spec.ts
    • src/domain-events/domain-events.service.spec.ts
    • src/domain-events/domain-event-registry.spec.ts
    • src/winter-checks/winter-checks.service.spec.ts

Alle Pfade relativ zu ApiCore-Backend/.

Interfaces

  • Consumes: Prisma-Modelle aus Task 2.
  • Produces:
// src/prisma/db-client.ts
export type DbClient = PrismaService | Prisma.TransactionClient;
// src/common/worker-wakeup.ts
export const WAKE_CHANNELS = { domainEvents: 'domain_event', calendarSync: 'calendar_sync', webhookDeliveries: 'webhook_delivery' } as const;
export type WakeChannel = (typeof WAKE_CHANNELS)[keyof typeof WAKE_CHANNELS];
export function notifyWorker(db: DbClient, channel: WakeChannel): Promise<void>; // SELECT pg_notify(channel, '')
// Audit / Benachrichtigungen
AuditLogService.record(input: RecordAuditLogInput, tx?: Prisma.TransactionClient): Promise<void>;
NotificationsService.record(input: CreateNotificationInput, tx?: Prisma.TransactionClient): Promise<Notification>;
NotificationsService.recordForPermission(permission: PermissionKey, input: Omit<CreateNotificationInput, 'userId'>, tx?: Prisma.TransactionClient): Promise<number>;
// Ereignis-Katalog (vollständig, wird später nicht mehr erweitert)
export interface ColonyStateSnapshot { status: ColonyStatus; healthStatus: ColonyCondition | null }
export interface DomainEventPayloads {
  InspectionRecorded: { colonyId: string };   InspectionCorrected: { colonyId: string };
  VarroaMeasured: { colonyId: string };       VarroaCorrected: { colonyId: string };
  TreatmentRecorded: { colonyId: string };    TreatmentCorrected: { colonyId: string };
  FeedingRecorded: { colonyId: string };      FeedingCorrected: { colonyId: string };
  HarvestRecorded: { apiaryId: string; colonyIds: string[] };
  HarvestCorrected: { apiaryId: string; colonyIds: string[] };
  ColonyStatusChanged: { from: ColonyStateSnapshot; to: ColonyStateSnapshot };
  ColonyEnded: { status: ColonyStatus };
  BatchBottled: { honeyBatchId: string };
  BottlingCorrected: { honeyBatchId: string; removed: boolean };
  StockChanged: { bottlingBatchIds: string[]; productIds: string[]; rawMaterialKinds: RawMaterialKind[] };
  TaskCreated: { relatedType: TaskRelatedType; relatedId: string | null; dueDate: string | null };
  TaskUpdated: { status: TaskStatus; dueDate: string | null };
  TaskCompleted: { relatedType: TaskRelatedType; relatedId: string | null };
  WithdrawalEnded: { colonyId: string; endedOn: string };
  TaskDue: { dueOn: string };
  QueenAged: { hatchYear: number; colonyId: string | null };
  VarroaMeasurementDue: { dueOn: string; lastMeasuredOn: string | null };
  InspectionOverdue: { dueOn: string; lastInspectedOn: string | null };
}
export type DomainEventType = keyof DomainEventPayloads;
export const DOMAIN_EVENT_TYPES: readonly DomainEventType[];
export type AggregateType = 'Inspection' | 'VarroaMeasurement' | 'Treatment' | 'Feeding' | 'Harvest' | 'Colony'
  | 'BottlingBatch' | 'ProductionBatch' | 'BatchMovement' | 'ShopStockRequest' | 'Task' | 'Queen';
export interface NewDomainEvent<T extends DomainEventType = DomainEventType> {
  type: T; aggregateType: AggregateType; aggregateId: string; payload: DomainEventPayloads[T];
  actorUserId?: string | null; dedupeKey?: string;
}
export interface DomainEventEnvelope<T extends DomainEventType = DomainEventType> {
  id: string; type: T; aggregateType: AggregateType; aggregateId: string; payload: DomainEventPayloads[T];
  actorUserId: string | null; correlationId: string; occurredAt: Date; attempts: number;
}
export type DomainEventHandlerFn<T extends DomainEventType = DomainEventType> =
  (event: DomainEventEnvelope<T>, tx: Prisma.TransactionClient) => Promise<void>;
export function dailyDedupeKey(type: DomainEventType, aggregateId: string, day: string): string; // `${type}:${aggregateId}:${day}`
export const domainEventContext: AsyncLocalStorage<{ correlationId: string }>;
@Injectable() class DomainEventsService {
  record<T extends DomainEventType>(tx: Prisma.TransactionClient, event: NewDomainEvent<T>): Promise<void>;
  recordAll(tx: Prisma.TransactionClient, events: readonly NewDomainEvent[]): Promise<number>;
}
export function OnDomainEvent(types: DomainEventType | readonly DomainEventType[], handlerId: string): MethodDecorator;
export const HANDLER_ID_PATTERN = /^[a-z0-9-]+\.[a-z0-9-]+$/;
export interface RegisteredDomainEventHandler { id: string; invoke: DomainEventHandlerFn }
@Injectable() class DomainEventRegistry implements OnModuleInit {
  handlersFor(type: DomainEventType): readonly RegisteredDomainEventHandler[];
  handlerIds(): string[];
}
  • Schritt 1: Failing Tests
// audit-log.service.spec.ts
it('writes through the transaction client when one is given', async () => {
  const tx = { auditLog: { create: jest.fn() } };
  await service.record({ entityType: 'Inspection', entityId: 'i1', action: 'CREATE' }, tx as never);
  expect(tx.auditLog.create).toHaveBeenCalledTimes(1);
  expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
// notifications.service.spec.ts
it('notifies every active user whose active role holds the permission', async () => {
  prisma.user.findMany.mockResolvedValue([{ id: 'u1' }, { id: 'u2' }]);
  await expect(service.recordForPermission('backup:configure', { priority: 'WARNUNG', message: 'Job „App-Backup“ fehlgeschlagen' })).resolves.toBe(2);
  expect(prisma.notification.createMany.mock.calls[0][0].data.map((n) => n.userId)).toEqual(['u1', 'u2']);
});
// domain-events.service.spec.ts
it('records the event and the wake-up on the same transaction client', async () => {
  await service.record(tx, { type: 'InspectionRecorded', aggregateType: 'Inspection', aggregateId: 'i1', payload: { colonyId: 'c1' }, actorUserId: 'u1' });
  expect(tx.domainEvent.createMany).toHaveBeenCalledWith({ data: [expect.objectContaining({ type: 'InspectionRecorded', aggregateId: 'i1', actorUserId: 'u1', correlationId: expect.any(String) })], skipDuplicates: true });
  expect(tx.$executeRaw).toHaveBeenCalledTimes(1); // pg_notify('domain_event','')
});
it('refuses the root PrismaService (must run inside the business transaction)', () =>
  expect(service.record(prismaServiceMock as never, event)).rejects.toThrow('DomainEventsService.record needs the transaction client'));
it('inherits the correlation id of the event being handled', async () => {
  await domainEventContext.run({ correlationId: 'corr-1' }, () => service.record(tx, event));
  expect(tx.domainEvent.createMany.mock.calls[0][0].data[0].correlationId).toBe('corr-1');
});
it('wakes the worker only when something was inserted', async () => {
  tx.domainEvent.createMany.mockResolvedValue({ count: 0 });
  await expect(service.recordAll(tx, [daily])).resolves.toBe(0);
  expect(tx.$executeRaw).not.toHaveBeenCalled();
});
// domain-event-registry.spec.ts
it('finds handlers by type, also when one method serves several types', ...);
it('refuses two handlers with the same id for one type', () => expect(() => registry.onModuleInit()).toThrow('Duplicate domain event handler'));
it('rejects a handler id that is not "<consumer>.<name>"', ...);
  • Schritt 2: npx jest src/audit src/notifications src/domain-events src/winter-checks → FAIL (Module fehlen, record ignoriert tx).
  • Schritt 3: Implementieren
    • record delegiert an recordAll([event]). recordAll macht tx.domainEvent.createMany({ data, skipDuplicates: true }) und bei count > 0 notifyWorker(tx, 'domain_event').
    • correlationId = domainEventContext.getStore()?.correlationId ?? randomUUID().
    • Prüfung „nicht der Root-Client“ über '$connect' in tx (Prisma 7.9: der Transaktions-Client hat ebenfalls $transaction; geteilter Helfer assertTransactionClient in src/prisma/db-client.ts).
    • Payload-JSON enthält nur IDs und Werte aus DomainEventPayloads, Datumswerte als YYYY-MM-DD.
    • Registry: DiscoveryService.getProviders() + MetadataScanner. Der Decorator hängt Metadaten an ein Array an, damit Stapeln möglich ist. Bei doppelten IDs oder einem ungültigen handlerId bricht der Boot ab.
    • recordForPermission: user.findMany({ where: { isActive: true, role: { status: 'ACTIVE', permissions: { some: { permission: { key } } } } } }), danach createMany.
  • Schritt 4: gleiche Befehle → PASS.
  • Schritt 5: Verifikation
    • typecheck, lint, npm test -- --ci
    • npx jest --config ./test/jest-e2e.json test/winter-checks.e2e-spec.ts --runInBand → PASS (Audit jetzt über den Service)
    • kein Commit

Task 4: Dispatcher

Files

  • Create:
    • src/common/retry-policy.ts (+ .spec.ts)
    • src/common/outbox/outbox-claims.ts
    • src/domain-events/domain-event-dispatcher.ts (+ .spec.ts)
    • src/domain-events/handler-contract.spec.ts (Wächter)
    • test/utils/domain-events.ts
    • test/domain-event-dispatcher.e2e-spec.ts
  • Modify: src/domain-events/domain-events.module.ts (Provider DomainEventDispatcher)
  • Docs: ARCHITECTURE.md, neuer Abschnitt „## 10. Ereignis-Rückgrat und Laufrollen“, Unterabschnitt „Ereignisse“: Outbox, Claim, Backoff, FAILED, Delivery-Sperre, Handler-Vertrag.

Interfaces

  • Consumes: Task 3 (DomainEventRegistry, domainEventContext, Typen).
  • Produces:
// retry-policy.ts
export const RETRY_DELAYS_MS = [30_000, 120_000, 600_000, 3_600_000, 21_600_000] as const;
export const MAX_ATTEMPTS = 8;
export const STALE_CLAIM_MS = 300_000;
export function retryDelayMs(attempts: number): number;
// outbox-claims.ts (Tabellenname nur aus geschlossener Union -> Prisma.raw sicher)
export type OutboxTable = 'DomainEvent' | 'WebhookDelivery';
export function claimBatch<Row>(db: PrismaService, table: OutboxTable, options: { limit: number; filter?: Prisma.Sql; orderBy: 'occurredAt' | 'createdAt' }): Promise<Row[]>;
export function releaseStaleClaims(db: PrismaService, table: OutboxTable): Promise<number>;
export function settleSuccess(db: DbClient, table: OutboxTable, id: string, claimedAt: Date, status: 'DONE' | 'DELIVERED'): Promise<boolean>;
export function settleFailure(db: DbClient, table: OutboxTable, id: string, claimedAt: Date, attemptsBefore: number, lastError: string): Promise<'retry' | 'failed'>;
// domain-event-dispatcher.ts
export const DOMAIN_EVENT_BATCH_SIZE = 50;
export const HANDLER_TX_TIMEOUT_MS = 10_000;
export const DRAIN_MAX_ROUNDS = 20;
export interface DispatchFilter { correlationIds?: readonly string[] }
@Injectable() class DomainEventDispatcher {
  get lastTickAt(): Date | null;
  runOnce(filter?: DispatchFilter): Promise<number>;            // Anzahl übernommener Ereignisse
  drain(filter?: DispatchFilter, maxRounds?: number): Promise<number>;
  releaseStaleClaims(): Promise<number>;
}
// test/utils/domain-events.ts
export function drainDomainEvents(app: INestApplicationContext, rootAggregateIds: readonly string[]): Promise<void>;
  • Gepinnte SQL-Kerne:

    • Claim: UPDATE "<T>" SET "status"='PROCESSING', "claimedAt"=now() WHERE "id" IN (SELECT "id" FROM "<T>" WHERE "status"='PENDING' AND "availableAt" <= now() [AND filter] ORDER BY "<orderBy>" LIMIT ${limit} FOR UPDATE SKIP LOCKED) RETURNING *
    • Stale: … SET "status" = CASE WHEN "attempts"+1 >= 8 THEN 'FAILED' ELSE 'PENDING' END, "attempts"="attempts"+1, "claimedAt"=NULL, "availableAt"=now(), "lastError"='claim expired (worker restarted?)' WHERE "status"='PROCESSING' AND "claimedAt" < now() - interval '5 minutes'
    • Fehler: "availableAt" = now() + (${retryDelayMs(n)} * interval '1 millisecond'), CAS über id + claimedAt.
  • Ablauf je Ereignis und Handler:

    1. prisma.$transaction(tx => …, { timeout: 10_000 })
    2. domainEventDelivery.createMany({ skipDuplicates })
    3. SELECT "status" … FOR UPDATE, bei DONE überspringen
    4. Handler in domainEventContext.run({ correlationId })
    5. Delivery DONE, attempts + 1
  • Fehler (außerhalb der Transaktion): Delivery attempts + 1, lastError = loggableError(e).message.

  • Abschluss je Ereignis:

    • alle DONE → Ereignis DONE (processedAt = now())
    • sonst settleFailure mit lastError = "<handlerId>: <message>"; bei failed werden offene Deliveries FAILED
  • Ereignisse ohne Handler → DONE.

  • Schritt 1: Failing Tests

// retry-policy.spec.ts
expect([1, 2, 3, 4, 5, 6, 7].map(retryDelayMs)).toEqual([30_000, 120_000, 600_000, 3_600_000, 21_600_000, 21_600_000, 21_600_000]);
expect(MAX_ATTEMPTS).toBe(8);
// domain-event-dispatcher.spec.ts (Prisma/Registry gemockt)
it('marks an event DONE when every handler succeeded', ...);
it('retries only the failed handler and schedules the event 30 s ahead after the first failure', ...); // SQL-Parameter enthält 30000
it('marks the event and its open deliveries FAILED after the 8th failed attempt', ...);
it('skips a handler whose delivery is already DONE', ...);
it('runs the handler inside the delivery transaction with the event correlation id', ...);
it('updates lastTickAt even when nothing was claimed', ...);
it('stores lastError without the Prisma message', ...); // PrismaClientKnownRequestError mit "secret-host" -> nicht im lastError
// handler-contract.spec.ts (Wächter, Review Focus 2)
it('handler files never import network clients', () => {
  for (const file of glob('src/**/*.handlers.ts')) {
    expect(read(file)).not.toMatch(/graph-client|webhook-delivery(?!\.worker)|node:https?|\bfetch\(/);
  }
});
// test/domain-event-dispatcher.e2e-spec.ts – eigenes Testmodul mit CountingHandler
// @OnDomainEvent('TaskDue', 'test.counter') -> tx.auditLog.create({ entityType: 'DispatcherE2E', entityId: e.aggregateId, action: 'CREATE' })
// @OnDomainEvent('QueenAged', 'test.failing') -> throw new Error('boom')   (beide Typen haben im Plan keine echten Handler)
it('two dispatchers in parallel produce exactly one effect per event and handler', async () => {
  await recordTaskDue(20);
  await Promise.all([appA.get(DomainEventDispatcher).drain(filter), appB.get(DomainEventDispatcher).drain(filter)]);
  expect(await prisma.auditLog.count({ where: { entityType: 'DispatcherE2E', entityId: { startsWith: suffix } } })).toBe(20);
});
it('drains a backlog of 120 events in batches of at most 50', ...); // runOnce-Ergebnisse [50, 50, 20, 0]
it('a stale-released event does not repeat a handler that already committed', ...); // claimedAt per SQL auf now()-6 min
it('schedules a failed event about 30 s ahead by the database clock', async () => {
  const [{ s }] = await prisma.$queryRaw<{ s: number }[]>`SELECT EXTRACT(EPOCH FROM "availableAt" - now())::float AS s FROM "DomainEvent" WHERE id = ${id}`;
  expect(s).toBeGreaterThan(25); expect(s).toBeLessThanOrEqual(30.5);
});
  • Schritt 2: npx jest src/common/retry-policy.spec.ts src/domain-events und die e2e-Datei → FAIL.
  • Schritt 3: Implementieren wie oben beschrieben.
    • drainDomainEvents sucht die correlationIds der Wurzel-Aggregate und ruft dispatcher.drain({ correlationIds }) auf.
    • Der Filter verhindert, dass parallele e2e-Dateien fremde Ereignisse abarbeiten.
  • Schritt 4: gleiche Befehle → PASS.
  • Schritt 5: Verifikation
    • typecheck, lint, npm test -- --ci
    • ARCHITECTURE §10 „Ereignisse“
    • kein Commit

Task 5: Worker-Bausteine (Takt, LISTEN, Health)

Files

  • Create:
    • src/common/with-timeout.ts (+ .spec.ts)
    • src/worker/polling-loop.ts (+ .spec.ts)
    • src/worker/pg-listener.ts (+ .spec.ts)
    • src/worker/worker-health.ts (+ .spec.ts)
  • Modify:
    • src/health/health.controller.ts: lokales withTimeout durch den gemeinsamen Helfer ersetzen
    • package.json: npm install pg@^8.23.0 && npm install -D @types/pg

Interfaces

  • Produces:
export function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T>;
export class PollingLoop {
  constructor(options: { name: string; intervalMs: number; tick: () => Promise<unknown>; logger: Logger });
  start(): void; wake(): void; stop(): Promise<void>;
}
export interface ListenClient { connect(): Promise<void>; query(sql: string): Promise<unknown>; on(event: 'notification' | 'error' | 'end', cb: (arg: unknown) => void): void; end(): Promise<void> }
export class PgListener {
  constructor(options: { connectionString: string; channels: readonly WakeChannel[]; onNotify: (channel: WakeChannel) => void; logger: Logger; clientFactory?: (connectionString: string) => ListenClient });
  start(): Promise<void>; stop(): Promise<void>;
}
export const HEALTH_STALE_MS = 120_000;
export const DEFAULT_WORKER_HEALTH_PORT = 3002;
export interface WorkerHealth { ok: boolean; reason?: 'database' | 'dispatcher-stale' }
export function workerHealthPort(env?: NodeJS.ProcessEnv): number;
export function startWorkerHealthServer(options: { port: number; check: () => Promise<WorkerHealth> }): Promise<http.Server>;
  • Schritt 1: Failing Tests (Jest Fake Timers)
// polling-loop.spec.ts
it('ticks every intervalMs', ...);
it('never runs two ticks at once', ...);
it('wake() during a tick runs exactly one more tick right after', ...);
it('logs a throwing tick via loggableError and keeps going', ...);
it('stop() waits for the running tick and schedules nothing more', ...);
// pg-listener.spec.ts (Fake-Client)
it('LISTENs on every channel after connecting', () => expect(client.query.mock.calls.map((c) => c[0])).toEqual(['LISTEN "domain_event"', 'LISTEN "calendar_sync"', 'LISTEN "webhook_delivery"']));
it('reconnects after an error with 1 s, 2 s, 4 s … at most 30 s', ...);
it('stop() ends the client and does not reconnect', ...);
// worker-health.spec.ts (echter Server auf Port 0)
it('answers 200 {"status":"ok"} on GET /health when healthy', ...);
it('answers 503 with only status and reason when unhealthy', async () => {
  expect(await getJson('/health')).toEqual({ status: 503, body: { status: 'unhealthy', reason: 'dispatcher-stale' } });
});
it('answers 404 for any other path or method', ...);
it('treats a throwing check as 503 database', ...);
  • Schritt 2: npx jest src/worker src/common/with-timeout.spec.ts src/health → FAIL.
  • Schritt 3: Implementieren
    • PollingLoop mit setTimeout, kein setInterval, damit der Wächter aus Task 17 greift.
    • Single-Flight mit Flag rerunRequested.
    • Kanalnamen in LISTEN mit doppelten Anführungszeichen; Werte kommen nur aus WAKE_CHANNELS.
    • Health-Server mit node:http, ohne Express.
  • Schritt 4: → PASS.
  • Schritt 5: Verifikation
    • typecheck, lint, npm test -- --ci
    • npx jest --config ./test/jest-e2e.json test/health.e2e-spec.ts --runInBand → PASS
    • kein Commit

Task 6: Worker-Prozess und Einstiegspunkte

Files

  • Create:
    • src/worker/worker-runtime.service.ts (+ .spec.ts)
    • src/worker/worker-runtime.module.ts
    • src/worker/worker-health-server.service.ts
    • src/worker/worker.module.ts
    • src/all-in-one.module.ts
    • src/runtime/root-module.ts (+ .spec.ts)
    • src/worker.ts
    • test/worker-runtime.e2e-spec.ts
  • Modify:
    • src/main.ts: Modul je Rolle
    • src/calendar-sync/calendar-sync.service.ts: @Cron scheduledRun, startRun, activeRun und onModuleDestroy entfernen; neu requestRun
    • src/calendar-sync/calendar-sync.controller.ts
    • src/calendar-sync/calendar-sync.service.spec.ts
    • test/calendar-sync.e2e-spec.ts: 202-Test
    • package.json: Skripte "worker": "node dist/worker.js", "job": "node dist/cli.js"
  • Docs: ARCHITECTURE.md §10, Unterabschnitt „Laufrollen“ (Tabelle aus Spec §2). Annahme 9: Outbox-Takt im Worker statt @Cron, „Jetzt synchronisieren“ weckt den Worker.

Interfaces

  • Consumes: Task 1, 4, 5.
  • Produces:
export const DOMAIN_EVENT_POLL_MS = 5_000;
export const CALENDAR_OUTBOX_POLL_MS = 60_000;
@Injectable() class WorkerRuntime implements OnApplicationBootstrap, OnApplicationShutdown { health(): Promise<WorkerHealth> }
@Module({ providers: [WorkerRuntime], exports: [WorkerRuntime] }) class WorkerRuntimeModule {}
// WorkerModule imports: ConfigModule.forRoot({ isGlobal: true }), PrismaModule, AuditModule, NotificationsModule,
//   DomainEventsModule, WebhooksModule, CalendarSyncModule, TasksModule, AutomationModule, WorkerRuntimeModule
// WorkerModule providers: WorkerHealthServer (nur hier, nicht in AllInOneModule)
@Module({ imports: [AppModule, WorkerRuntimeModule] }) class AllInOneModule {}
export function rootModuleFor(role: 'api' | 'all'): Type<unknown>;
CalendarSyncService.requestRun(): Promise<void>; // FAILED -> PENDING, notifyWorker(prisma, 'calendar_sync')
// getStatus().running = Anzahl gültiger Claims > 0
  • Schleifen in WorkerRuntime (Tabelle im Code, Task 7 ergänzt eine Zeile):

    • domain-events: 5 000 ms, domain_event, releaseStaleClaims() und dann drain()
    • calendar-outbox: 60 000 ms, calendar_sync, processOutbox()
  • Schritt 1: Failing Tests

// root-module.spec.ts – „API-Rolle startet nie Hintergrundschleifen“
it('the api module never imports the worker runtime', () =>
  expect(Reflect.getMetadata('imports', AppModule)).not.toContain(WorkerRuntimeModule));
it('all adds the worker runtime to the API', () => expect(rootModuleFor('all')).toBe(AllInOneModule));
// worker-runtime.service.spec.ts
it('starts the listener and every loop at bootstrap', ...);
it('wakes the domain-events loop on a domain_event notification', ...);
it('is healthy after a recent tick and dispatcher-stale after 120 s without one', ...);
it('reports database when SELECT 1 fails or exceeds 5 s', ...);
it('stops loops and listener on shutdown', ...);
// calendar-sync.service.spec.ts
it('requestRun resets FAILED entries and wakes the worker without calling Graph', async () => {
  await service.requestRun();
  expect(graph.createEvent).not.toHaveBeenCalled();
  expect(prisma.$executeRaw).toHaveBeenCalled(); // pg_notify('calendar_sync')
});
// test/worker-runtime.e2e-spec.ts (WorkerModule + CountingHandler auf 'TaskDue')
it('wakes up on NOTIFY and processes a new event well before the 5 s poll', async () => {
  await recordTaskDueInTx();
  await expectWithin(1_500, () => countAuditRows('DispatcherE2E'), 1);
});
// test/calendar-sync.e2e-spec.ts: „Jetzt synchronisieren“ -> 202, FAILED wird PENDING, kein Graph-Aufruf aus der API
  • Schritt 2: npx jest src/runtime src/worker src/calendar-sync und beide e2e-Dateien → FAIL.
  • Schritt 3: Implementieren
    • worker.ts: resolveRole('worker') → assertStartupEnvironment → NestFactory.createApplicationContext(WorkerModule) → enableShutdownHooks().
    • main.ts: NestFactory.create(rootModuleFor(role)).
    • WorkerHealthServer startet in onApplicationBootstrap auf workerHealthPort() und schließt in onApplicationShutdown.
  • Schritt 4: → PASS.
    • npm run build && ls dist/main.js dist/worker.js
    • Optional, mit lokaler Dev-DB: APICORE_ROLE=worker node dist/worker.js und curl -s localhost:3002/health → {"status":"ok"}
  • Schritt 5: Verifikation
    • typecheck, lint, npm test -- --ci
    • Docs
    • kein Commit

Carry-forward (Orchestrator): worker.ts ruft wie main.ts zuerst resolveRole('worker') und assertStartupEnvironment; Health-Check = withTimeout(SELECT 1, 5000) → database, Alter des letzten Dispatcher-Durchlaufs (lastTickAt, wird je Ereignis aktualisiert) ≤ HEALTH_STALE_MS → sonst dispatcher-stale, gemessen mit monotoner Uhr (performance.now()).

Task 7: Webhook-Zustellung mit Wiederholung und Zustellprotokoll

Files

  • Create:
    • src/webhooks/webhook-delivery.worker.ts (+ .spec.ts)
    • src/webhooks/webhook.handlers.ts (+ .spec.ts)
    • src/webhooks/dto/list-webhook-deliveries.dto.ts
    • test/utils/webhooks.ts
  • Modify:
    • src/webhooks/webhooks.service.ts: enqueueDeliveries; dispatch (Altpfad) nutzt sie; deliver() entfällt
    • src/webhooks/webhook-events.ts: + varroa.measured, treatment.created, feeding.created
    • src/webhooks/webhooks.controller.ts, src/webhooks/webhooks.module.ts, src/worker/worker-runtime.service.ts (Schleife)
    • src/webhooks/webhooks.service.spec.ts, test/webhooks.e2e-spec.ts
    • Frontend ApiCore-Frontend/src/lib/webhooks/types.ts und labels.ts: + stock.changed „Bestand einer Charge geändert“, varroa.measured „Varroamessung erfasst“, treatment.created „Behandlung erfasst“, feeding.created „Fütterung erfasst“
  • Docs: ARCHITECTURE.md §9, Zeile „Ausgehende Aufrufe“: Zustelltabelle, Wiederholung, stabile Delivery-ID, nur 2xx. §10: Webhook-Zustellung.

Interfaces

  • Consumes: Task 3, 4, 6.
  • Produces:
WebhooksService.enqueueDeliveries(db: DbClient, event: WebhookEvent, payload: unknown): Promise<number>; // + notifyWorker('webhook_delivery')
WebhooksService.dispatch(event: WebhookEvent, payload: unknown): Promise<void>;                     // Altpfad, runAfterCommit
WebhooksService.findDeliveries(subscriptionId: string, limit: number): Promise<WebhookDeliveryView[]>;
export interface WebhookDeliveryView { id: string; event: string; status: WebhookDeliveryStatus; attempts: number; lastStatusCode: number | null; lastError: string | null; createdAt: Date; deliveredAt: Date | null }
export const WEBHOOK_DELIVERY_POLL_MS = 5_000;
export const WEBHOOK_DELIVERY_CONCURRENCY = 5;
@Injectable() class WebhookDeliveryWorker {
  runOnce(filter?: { subscriptionIds?: readonly string[] }): Promise<number>;
  drain(filter?: { subscriptionIds?: readonly string[] }, maxRounds?: number): Promise<number>;
  releaseStaleClaims(): Promise<number>;
}
export const DOMAIN_EVENT_WEBHOOKS: Readonly<Partial<Record<DomainEventType, WebhookEvent>>> = {
  InspectionRecorded: 'inspection.created', VarroaMeasured: 'varroa.measured', TreatmentRecorded: 'treatment.created',
  FeedingRecorded: 'feeding.created', HarvestRecorded: 'harvest.created', BatchBottled: 'batch.updated',
  BottlingCorrected: 'batch.updated', StockChanged: 'stock.changed', TaskCreated: 'task.created', TaskCompleted: 'task.completed',
};
class WebhookEventHandlers {
  @OnDomainEvent(Object.keys(DOMAIN_EVENT_WEBHOOKS) as DomainEventType[], 'webhooks.forward')
  forward(event: DomainEventEnvelope, tx: Prisma.TransactionClient): Promise<void>;
}
// GET /webhooks/:id/deliveries?limit=1..100 (Standard 50), @RequirePermission('webhooks:read'), ohne body
  • Inhalt (data) wie heute: der Datensatz, geladen über tx.

    • Harvest mit colonies: { include: { colony: true } }
    • batch.updated → honeyBatch
    • stock.changed → { bottlingBatchIds, productIds }, und nur wenn eines davon nicht leer ist
    • Fehlt der Datensatz → nichts einreihen.
  • body = JSON.stringify({ event, data }) wird beim Einreihen festgehalten, damit jede Wiederholung dieselben Bytes sendet.

  • Schritt 1: Failing Tests

// webhook-delivery.worker.spec.ts
it('signs "<t>.<delivery id>.<body>" and sends the delivery id as X-ApiCore-Delivery', ...);
it('counts only 2xx as delivered and stores the status code', ...);
it('schedules a retry 30 s ahead after a 500 and keeps the same id', ...);
it('marks FAILED after the 8th attempt', ...);
it('fails a delivery for an inactive subscription without sending ("subscription inactive")', ...);
it('never stores the response body or the secret in lastError', ...);
it('sends at most 5 at once', ...);
// webhook.handlers.spec.ts
it('enqueues inspection.created with the inspection as data', ...);
it('enqueues nothing for StockChanged that only touches raw material', ...);
it('skips a record that no longer exists', ...);
// webhooks.service.spec.ts
it('dispatch never throws when enqueuing fails (legacy path)', ...);
it('enqueueDeliveries writes one row per active subscription for the event and wakes the worker', ...);
// test/webhooks.e2e-spec.ts (Altpfad colony.created, die Producer folgen später)
it('delivers colony.created through the queue with a stable delivery id', async () => {
  await post('/api/v1/colonies', body);
  await deliverWebhooks(app, [subId]);
  expect(received[0].headers['x-apicore-delivery']).toBe((await prisma.webhookDelivery.findFirstOrThrow({ where: { subscriptionId: subId } })).id);
});
it('retries a 500 with the same delivery id and then marks it DELIVERED', ...); // availableAt per SQL auf now()
it('lists deliveries for admins and refuses staff', ...);                         // 200 / 403
  • Schritt 2: npx jest src/webhooks und test/webhooks.e2e-spec.ts → FAIL.
  • Schritt 3: Implementieren
    • Claims und Settle über outbox-claims.ts (orderBy: 'createdAt').
    • postWebhook bleibt.
    • Worker-Schleife webhook-deliveries: 5 000 ms, Kanal webhook_delivery, releaseStaleClaims() und dann drain().
    • test/utils/webhooks.ts: deliverWebhooks(app, subscriptionIds).
  • Schritt 4: → PASS.
  • Schritt 5: Verifikation
    • typecheck, lint, npm test -- --ci
    • Frontend: npm run lint && npm run typecheck
    • Docs
    • kein Commit

Carry-forward (Orchestrator, aus Review Task 6): Ein Test stellt sicher, dass die unter WorkerModule entdeckten Handler-IDs denen unter AppModule entsprechen (sonst läuft ein Handler, der in einem nicht vom Worker importierten Modul liegt, in Produktion nie). Neue Schleifen nur als Zeile in der Loop-Tabelle von WorkerRuntime; sie respektieren das Stop-Signal aus onModuleDestroy.

Task 8: Kalender-Handler und „Quelle weg“-Semantik

Files

  • Create: src/calendar-sync/calendar-sync.handlers.ts (+ .spec.ts)
  • Modify:
    • src/calendar-sync/calendar-sync.service.ts:
      • enqueueIn als Kern; enqueue als Altpfad für Königinnen
      • cancel delegiert vorläufig an enqueue; Task 9 entfernt es
      • buildEventInput('TASK') liefert null bei DONE/CANCELLED oder ohne dueDate
      • processEntry: bei null und vorhandenem graphEventId das Graph-Event löschen, dann SKIPPED
    • src/calendar-sync/calendar-sync.module.ts, src/calendar-sync/calendar-sync.service.spec.ts, test/calendar-sync.e2e-spec.ts
  • Docs: ARCHITECTURE.md Annahme 9: Abmelden über erneutes Einreihen, der Worker löscht das Event.

Interfaces

  • Consumes: Task 3, 6.
  • Produces:
CalendarSyncService.enqueueIn(db: DbClient, sourceType: CalendarSyncSourceType, sourceId: string): Promise<void>; // wirft weiter
CalendarSyncService.enqueue(sourceType: CalendarSyncSourceType, sourceId: string): Promise<void>;                  // Altpfad, wirft nie
class CalendarSyncEventHandlers {
  @OnDomainEvent(['InspectionRecorded', 'InspectionCorrected'], 'calendar-sync.inspection') inspection(e, tx): Promise<void>;
  @OnDomainEvent(['TreatmentRecorded', 'TreatmentCorrected'], 'calendar-sync.treatment') treatment(e, tx): Promise<void>;
  @OnDomainEvent(['FeedingRecorded', 'FeedingCorrected'], 'calendar-sync.feeding') feeding(e, tx): Promise<void>;
  @OnDomainEvent(['HarvestRecorded', 'HarvestCorrected'], 'calendar-sync.harvest') harvest(e, tx): Promise<void>;
  @OnDomainEvent(['TaskCreated', 'TaskUpdated', 'TaskCompleted'], 'calendar-sync.task') task(e, tx): Promise<void>; // TaskCreated ohne dueDate: nichts
}
  • Schritt 1: Failing Tests
it('enqueueIn writes through the given client and lets errors propagate (handler retry)', ...);
it('enqueue (legacy) never throws', ...);
it('deletes the Graph event of a task that is DONE and settles the entry SKIPPED', ...);
it('treats 404/410 on delete as already deleted', ...);
it('counts any other delete error as a failed attempt', ...);
it('TaskCreated without a due date enqueues nothing', ...); // handlers.spec
// e2e: Aufgabe mit Event erledigen -> Eintrag neu eingereiht -> processOutbox() ruft deleteEvent und setzt SKIPPED
  • Schritt 2: npx jest src/calendar-sync und test/calendar-sync.e2e-spec.ts → FAIL.
  • Schritt 3: Implementieren wie in Files beschrieben.
  • Schritt 4: → PASS.
  • Schritt 5: Verifikation
    • typecheck, lint, npm test -- --ci
    • Docs
    • kein Commit

Task 9: Aufgaben über TasksService mit Ereignissen

Files

  • Modify:
    • src/tasks/tasks.service.ts (+ .spec.ts)
    • src/tasks/tasks.module.ts: Import TaskTemplatesModule entfällt
    • src/task-templates/task-templates.service.ts (+ .spec.ts): generateDueTasks und die WebhooksService-Abhängigkeit entfallen
    • src/inspections/inspections.service.ts: createTaskFromInspection über createInTx
    • src/inspections/inspections.module.ts: TasksModule importieren
    • src/calendar-sync/calendar-sync.service.ts: cancel entfernen
  • Test:
    • test/tasks.e2e-spec.ts, test/task-templates.e2e-spec.ts
    • test/data-integrity.e2e-spec.ts („gives a task generated from a recurring template its due date“)
    • test/webhooks.e2e-spec.ts: task.created/task.completed über Drain + Zustellung

Interfaces

  • Consumes: Task 3, 7, 8.
  • Produces:
export interface NewTaskInput {
  title: string; description?: string | null; priority?: TaskPriority; dueDate?: Date | null;
  assignedUserId?: string | null; relatedType?: TaskRelatedType; relatedId?: string | null;
  sourceInspectionId?: string | null; sourceTemplateId?: string | null; sourceAutomationTrigger?: AutomationTriggerType | null;
}
TasksService.createInTx(tx: Prisma.TransactionClient, input: NewTaskInput, actingUserId: string | null): Promise<Task>; // Task + Audit + TaskCreated
TasksService.create(dto: CreateTaskDto, actingUserId: string): Promise<Task>;
TasksService.update(id: string, dto: UpdateTaskDto, actingUserId: string): Promise<Task>;   // + TaskUpdated
TasksService.complete(id: string, actingUserId: string): Promise<Task>;                     // + TaskCompleted, bei Wiederholung nichts
TasksService.generateFromTemplates(now?: Date): Promise<number>; // je Vorlage: CAS-Claim + createInTx in EINER Transaktion
  • Schritt 1: Failing Tests
it('createInTx writes task, audit entry and TaskCreated on the same client', async () => {
  await service.createInTx(tx, { title: 'T', dueDate: new Date('2027-05-01T00:00:00Z') }, 'u1');
  expect(audit.record).toHaveBeenCalledWith(expect.objectContaining({ entityType: 'Task', action: 'CREATE' }), tx);
  expect(events.record).toHaveBeenCalledWith(tx, expect.objectContaining({ type: 'TaskCreated', payload: { relatedType: 'GENERAL', relatedId: null, dueDate: '2027-05-01' } }));
});
it('complete records TaskCompleted once and nothing on a repeated complete', ...);
it('generateFromTemplates creates no task when another replica already claimed the occurrence', ...);
it('a template task gets an audit entry without a user', ...);
// e2e
it('POST /tasks leaves a TaskCreated event whose calendar and webhook deliveries are DONE after draining', ...);
  • Schritt 2: npx jest src/tasks src/task-templates src/inspections und die e2e-Dateien → FAIL.
  • Schritt 3: Implementieren
    • findAll ruft weiter lazy generateFromTemplates() auf (Spec §4).
    • createdByUserId: actingUserId ?? undefined.
  • Schritt 4: → PASS.
  • Schritt 5: Verifikation
    • typecheck, lint, npm test -- --ci
    • kein Commit

Task 10: Automationen als Handler

Files

  • Create: src/automation/automation.handlers.ts (+ .spec.ts)
  • Modify:
    • src/automation/automation.service.ts (+ .spec.ts)
    • src/automation/automation.module.ts: importiert TasksModule, NotificationsModule, StockModule; WebhooksService entfällt
  • Docs: ARCHITECTURE.md Annahme 12: Automatik-Aufgaben laufen über TasksService mit Audit, Kalender und Webhook.

Interfaces

  • Consumes: Task 3, 9.
  • Produces:
AutomationService.evaluateInspectionIn(tx: Prisma.TransactionClient, inspection: Inspection): Promise<void>;
AutomationService.evaluateVarroaMeasurementIn(tx, measurement: VarroaMeasurement): Promise<void>;
AutomationService.evaluateTreatmentIn(tx, treatment: Treatment): Promise<void>;
AutomationService.evaluateFeedingIn(tx, feeding: Feeding): Promise<void>;
AutomationService.evaluateColonyHealthIn(tx, colonyId: string, from: ColonyStateSnapshot, to: ColonyStateSnapshot): Promise<void>;
AutomationService.evaluateRawMaterialLowIn(tx, kind: RawMaterialKind, remainingGrams: number): Promise<void>;
AutomationService.evaluateSwarmControlCheckIn(tx, check: SwarmControlCheck): Promise<void>;
AutomationService.evaluateStockItemIn(tx, item: StockItem): Promise<void>;
// Altpfade bleiben bis Task 17: evaluateInspection, evaluateVarroaMeasurement, evaluateTreatment, evaluateFeeding,
//   evaluateColonyHealth, evaluateRawMaterialLow, evaluateSwarmControlCheck, evaluateStockItem
//   = runAfterCommit(() => prisma.$transaction((tx) => xIn(tx, …)))
// privat: triggerActionIn(tx, rule, relatedType, relatedId, sourceInspectionId?, options?) – Sperre auf die Regel,
//   Dublettenprüfung, tasks.createInTx(tx, …, null), notifications.record(…, tx), auditLog.record(AutomationRule CREATE, tx)
class AutomationEventHandlers {
  @OnDomainEvent('InspectionRecorded', 'automation.inspection-rules')
  @OnDomainEvent(['VarroaMeasured', 'VarroaCorrected'], 'automation.varroa-threshold')
  @OnDomainEvent('TreatmentRecorded', 'automation.treatment-follow-up')
  @OnDomainEvent('FeedingRecorded', 'automation.feeding-follow-up')
  @OnDomainEvent('ColonyStatusChanged', 'automation.colony-health')
  @OnDomainEvent('StockChanged', 'automation.raw-material-low') // je rawMaterialKind: StockService.remainingByRawMaterialKind
}
  • Schritt 1: Failing Tests
it('triggerActionIn creates the task through TasksService.createInTx on the handler transaction', ...);
it('leaves no task, notification or audit entry when the notification write fails (one rollback)', ...);
it('still creates at most one open task per rule and object', ...);
it('handlers load the record via tx and skip a missing one', ...);
it('colony-health fires only on the transition into CRITICAL', ...);
it('legacy wrappers never throw', ...);
  • Schritt 2: npx jest src/automation → FAIL.
  • Schritt 3: Implementieren wie beschrieben.
    • Gemeinsamer Kern: Altpfade und Handler rufen dieselben …In-Funktionen auf.
    • ensureDefaults läuft über den übergebenen Client.
  • Schritt 4:
    • npx jest src/automation → PASS
    • npx jest --config ./test/jest-e2e.json test/automation.e2e-spec.ts test/follow-up-controls.e2e-spec.ts --runInBand → PASS (Altpfade noch aktiv)
  • Schritt 5: Verifikation
    • typecheck, lint, npm test -- --ci
    • Docs
    • kein Commit

Carry-forward (Orchestrator, aus T3 Task 2): Der VARROA_THRESHOLD-Handler nutzt varroaThresholdOf aus src/automation/varroa-threshold.ts (einzige Quelle für den Standardwert 10); das inline ?? 10 in automation.service.ts entfällt. Das ist T3-Plan Task 2 Schritt 9.

Task 11: Erzeuger Durchsichten und Varroa

Files

  • Modify:
    • src/inspections/inspections.service.ts (+ .spec.ts), src/inspections/inspections.module.ts (AutomationModule-Import entfällt)
    • src/varroa/varroa.service.ts (+ .spec.ts), src/varroa/varroa.module.ts
  • Test: test/post-commit-side-effects.e2e-spec.ts (alleiniger Besitzer dieser Datei), test/automation.e2e-spec.ts, test/inspections.e2e-spec.ts, test/varroa.e2e-spec.ts

Interfaces

  • Consumes: Task 3, 10.

  • Produces, je Schreibmethode prisma.$transaction(async (tx) => { write; auditLog.record(…, tx); events.record(tx, …) }):

    • InspectionsService.create → InspectionRecorded { colonyId }
    • InspectionsService.update → InspectionCorrected { colonyId }. Bewusst wie heute ohne Automatik: eine Korrektur löst keine Regel aus.
    • VarroaService.create → VarroaMeasured { colonyId }, update → VarroaCorrected { colonyId }
  • Direkte Aufrufe webhooks.dispatch, automation.evaluate* und calendarSync.enqueue entfallen.

  • Schritt 1: Failing Tests

// unit
it('create writes inspection, audit entry and InspectionRecorded in one transaction and calls no side effect directly', ...);
// test/post-commit-side-effects.e2e-spec.ts (Review Focus 2, Spec §9)
it('creates exactly one automation task when the handler fails once and is retried', async () => {
  jest.spyOn(app.get(NotificationsService), 'record').mockRejectedValueOnce(new Error('notifications down'));
  const { body } = await post('/api/v1/inspections', { colonyId, date: '2026-07-01T00:00:00.000Z', queenSeen: false, eggsPresent: false }).expect(201);
  await drainDomainEvents(app, [body.id]);
  expect(await taskCount('QUEENLESS_NO_EGGS', colonyId)).toBe(0);
  await prisma.$executeRaw`UPDATE "DomainEvent" SET "availableAt" = now() WHERE "aggregateId" = ${body.id}`;
  await drainDomainEvents(app, [body.id]);
  expect(await taskCount('QUEENLESS_NO_EGGS', colonyId)).toBe(1);
  const deliveries = await prisma.domainEventDelivery.findMany({ where: { event: { aggregateId: body.id } } });
  expect(deliveries.find((d) => d.handler === 'automation.inspection-rules')).toMatchObject({ status: 'DONE', attempts: 2 });
  expect(deliveries.find((d) => d.handler === 'webhooks.forward')).toMatchObject({ status: 'DONE', attempts: 1 });
});
it('still answers 201 and replays on retry with the same Idempotency-Key (no side effect in the request)', ...);
it('leaves one open task when several triggers for the same colony arrive at once', ...); // nach Drain
  • Schritt 2: → FAIL.
  • Schritt 3: Implementieren
    • automation.e2e: die Assertions auf Aufgaben und Benachrichtigungen kommen nach drainDomainEvents.
  • Schritt 4: npx jest src/inspections src/varroa und die vier e2e-Dateien → PASS.
  • Schritt 5: Verifikation
    • typecheck, lint, npm test -- --ci
    • kein Commit

Task 12: Erzeuger Behandlungen, Fütterungen und Ernten

Files

  • Modify: src/treatments/treatments.service.ts, src/feedings/feedings.service.ts, src/harvests/harvests.service.ts mit Specs und Modulen
  • Test: test/follow-up-controls.e2e-spec.ts, test/treatments.e2e-spec.ts, test/feedings.e2e-spec.ts, test/harvests.e2e-spec.ts

Interfaces

  • Consumes: Task 3, 8, 10.

  • Produces:

    • TreatmentRecorded/TreatmentCorrected { colonyId }
    • FeedingRecorded/FeedingCorrected { colonyId }
    • HarvestRecorded/HarvestCorrected { apiaryId, colonyIds }
    • jeweils mit Audit in der Transaktion
  • Webhook harvest.created und der Kalender kommen jetzt aus den Handlern. HarvestsService.create braucht kein findById mehr für den Webhook.

  • Schritt 1: Failing Tests

it('create writes treatment, audit entry and TreatmentRecorded in one transaction', ...);
it('update records TreatmentCorrected and no automation', ...);
it('harvest create records HarvestRecorded with the colony ids', ...);
// follow-up-controls.e2e: Folgekontrolle erst nach drainDomainEvents, Fälligkeit wie bisher ab Maßnahme-Datum
  • Schritt 2: → FAIL.
  • Schritt 3: Implementieren.
  • Schritt 4: npx jest src/treatments src/feedings src/harvests und die e2e-Dateien → PASS.
    • test/post-commit-side-effects.e2e-spec.ts bleibt ohne Änderung grün; der Behandlungs-Test prüft nur 201 und Replay.
  • Schritt 5: Verifikation
    • typecheck, lint, npm test -- --ci
    • kein Commit

Task 13: Erzeuger Völker und Überwinterung

Files

  • Modify:
    • src/colonies/colonies.service.ts (+ .spec.ts), src/colonies/colonies.module.ts
    • src/winter-checks/winter-checks.service.ts (+ .spec.ts)
  • Test: test/colonies.e2e-spec.ts, test/winter-checks.e2e-spec.ts

Interfaces

  • Consumes: Task 3, 10.

  • Produces:

    • update, merge (für jedes vereinigte Volk), dissolve, reactivate und markDeceasedIfRequested schreiben ColonyStatusChanged { from, to }, sobald sich status oder healthStatus ändert.
    • Beim Übergang von lebend zu beendet (DISSOLVED, SOLD, DECEASED, MERGED, siehe isLivingColonyStatus) zusätzlich ColonyEnded { status }.
    • Audit überall in der Transaktion, auch bei create, move und split.
    • colony.created bleibt im Altpfad webhooks.dispatch; dafür gibt es in dieser Stufe kein Ereignis.
  • Schritt 1: Failing Tests

it('update into CRITICAL health records ColonyStatusChanged with from/to snapshots', ...);
it('dissolve records ColonyStatusChanged and ColonyEnded { status: "DISSOLVED" }', ...);
it('merge records ColonyEnded "MERGED" for every merged colony', ...);
it('a winter loss with markColonyDeceased records ColonyEnded "DECEASED" in the same transaction', ...);
// e2e: COLONY_HEALTH_CRITICAL-Aufgabe erst nach drainDomainEvents
  • Schritt 2: → FAIL.
  • Schritt 3: Implementieren.
  • Schritt 4: → PASS.
  • Schritt 5: Verifikation
    • typecheck, lint, npm test -- --ci
    • kein Commit

Task 14: Erzeuger Bestand und Abfüllung (mit Herstellung und Rohstoffen)

Files

  • Modify:
    • src/stock/stock.service.ts (+ .spec.ts): notify() entfällt, neu recordChange. consumeForProduction und returnProductionInputs geben die verbrauchten Sorten zurück.
    • src/bottling/bottling.service.ts (+ .spec.ts)
    • src/production-batches/production-batches.service.ts (+ .spec.ts): checkRawMaterialLow entfällt
    • src/raw-materials/raw-materials.service.ts (+ .spec.ts): checkLowStock entfällt
    • die zugehörigen Module (AutomationModule- und WebhooksService-Abhängigkeiten entfallen)
  • Test: test/stock.e2e-spec.ts, test/bottling.e2e-spec.ts, test/shop-sync.e2e-spec.ts, test/raw-materials.e2e-spec.ts, test/data-integrity.e2e-spec.ts (Abschnitt „Bottling quantities“ unverändert grün)

Interfaces

  • Consumes: Task 3, 7, 10.
  • Produces:
export interface StockChange { bottlingBatchIds: string[]; productIds: string[]; rawMaterialKinds: RawMaterialKind[] }
StockService.recordChange(tx: Prisma.TransactionClient, change: StockChange, actingUserId: string | null,
  aggregate: { type: 'BatchMovement' | 'BottlingBatch' | 'ProductionBatch' | 'ShopStockRequest'; id: string }): Promise<void>; // nichts bei leerer Änderung
StockService.consumeForProduction(tx, productionBatchId, lotNumber, inputs, actingUserId): Promise<RawMaterialKind[]>;
StockService.returnProductionInputs(tx, productionBatchId, lotNumber, actingUserId): Promise<RawMaterialKind[]>;
  • Wer welches Ereignis schreibt:

    • withdraw und correct (inkl. Rohstoff-Ziel mit der Sorte der Charge) → StockChanged in ihrer Transaktion.
    • Shop-Buchung → ein StockChanged je angenommener Anfrage, in deren Transaktion.
    • Abfüllung: create → BatchBottled { honeyBatchId } + StockChanged; update/remove → BottlingCorrected { honeyBatchId, removed } + StockChanged.
    • Herstellung create/update/remove → StockChanged mit productIds und den verbrauchten Sorten.
  • Schritt 1: Failing Tests

it('withdraw books the movement, the audit entry and StockChanged in one transaction', ...);
it('a raw material withdrawal records StockChanged with the lot kind and no shop ids', ...);
it('a shop request records one StockChanged per accepted booking', ...);
it('bottling create records BatchBottled and StockChanged together', ...);
// raw-materials.e2e: RAW_MATERIAL_LOW-Aufgabe erst nach drainDomainEvents (minGrams gesetzt)
  • Schritt 2: → FAIL.
  • Schritt 3: Implementieren.
  • Schritt 4: npx jest src/stock src/bottling src/production-batches src/raw-materials und die e2e-Dateien → PASS.
  • Schritt 5: Verifikation
    • typecheck, lint, npm test -- --ci
    • kein Commit

Task 15: Job-Grundgerüst (JobRun, cli.ts, cleanup, task-templates)

Files

  • Create:
    • src/system/system.module.ts
    • src/system/job-run.service.ts (+ .spec.ts)
    • src/jobs/job-names.ts
    • src/jobs/job.ts
    • src/jobs/job-registry.ts (+ .spec.ts)
    • src/jobs/jobs.module.ts
    • src/jobs/run-cli.ts (+ .spec.ts)
    • src/jobs/cleanup.job.ts (+ .spec.ts)
    • src/jobs/task-templates.job.ts
    • Platzhalter src/jobs/daily-evaluation.job.ts, src/jobs/app-backup.job.ts, src/jobs/rekey.job.ts (werfen Error('job not implemented yet'), werden in 16/18/21 ersetzt)
    • src/cli.ts
    • test/jobs.e2e-spec.ts
  • Modify:
    • src/common/idempotency/idempotency-cleanup.service.ts (+ .spec.ts): @Cron und scheduledCleanup entfallen
    • src/app.module.ts: IdempotencyCleanupService-Provider entfällt, SystemModule wird importiert
  • Docs: ARCHITECTURE.md §8 „Automatischer Zeitplan“ (Idempotenz-Aufräumen per CronJob) und §10 „Jobs“.

Interfaces

  • Consumes: Task 2, 3, 9.
  • Produces:
export const JOB_NAMES = ['daily-evaluation', 'task-templates', 'cleanup', 'app-backup', 'rekey'] as const;
export type JobName = (typeof JOB_NAMES)[number];
export const JOB_LABELS_DE: Record<JobName, string> = { 'daily-evaluation': 'Tägliche Auswertung', 'task-templates': 'Aufgaben aus Vorlagen', cleanup: 'Aufräumen', 'app-backup': 'App-Backup', rekey: 'Schlüssel umstellen' };
export const JOB_FAILURE_PERMISSION: Record<JobName, PermissionKey> = { 'daily-evaluation': 'automation:update', 'task-templates': 'automation:update', cleanup: 'system:manage', 'app-backup': 'backup:configure', rekey: 'system:manage' };
export type JobResult = Record<string, number | string | boolean | null>;
export interface Job { readonly name: JobName; run(args: readonly string[]): Promise<JobResult> }
@Injectable() class JobRunService {
  run(job: JobName, work: () => Promise<JobResult>): Promise<JobRun>; // RUNNING -> SUCCEEDED|FAILED; bei FAILED: recordForPermission(…, 'Job „<Label>“ fehlgeschlagen')
  recent(limit: number, job?: JobName): Promise<JobRun[]>;
  latestPerJob(): Promise<Record<JobName, JobRun | null>>;
}
export function runCli(argv: readonly string[], createContext?: () => Promise<INestApplicationContext>): Promise<0 | 1 | 2>;
// JobsModule imports: ConfigModule.forRoot({ isGlobal: true }), PrismaModule, AuditModule, NotificationsModule,
//   DomainEventsModule, TasksModule, BackupModule, SystemModule; providers: alle Job-Klassen, JobRegistry, IdempotencyCleanupService
export const DOMAIN_EVENT_RETENTION_DAYS = 30; export const WEBHOOK_DELIVERY_RETENTION_DAYS = 30;
export const FAILED_RETENTION_DAYS = 90; export const JOB_RUN_RETENTION_DAYS = 90; export const JOB_RUN_STALE_HOURS = 6;
  • cleanup löscht:

    • Idempotenz-Einträge und verbrauchte Tokens (wie bisher)
    • abgelaufene ThrottlerHit ("expiresAt" < now() und keine aktive Sperre)
    • DONE-Ereignisse und DELIVERED-Zustellungen älter als 30 Tage
    • FAILED-Einträge älter als 90 Tage
    • JobRun älter als 90 Tage
  • Außerdem setzt cleanup RUNNING-Läufe, die älter als 6 h sind, auf FAILED mit error: 'aborted (pod ended without result)'.

  • Schritt 1: Failing Tests

it('the registry covers every JOB_NAME exactly once', ...);
it('rejects an unknown job with exit code 2 and writes no JobRun', async () => expect(await runCli(['nope'], ctx)).toBe(2));
it('returns 1 and stores the error via loggableError when the job fails', ...);
it('passes extra arguments such as --dry-run to the job', ...);
it('notifies holders of the job permission when a run fails', ...);
it('cleanup removes only expired rows and reports counts', ...);
// test/jobs.e2e-spec.ts
it('node dist/cli.js cleanup equivalent: runCli(["cleanup"]) writes a SUCCEEDED JobRun with counts', ...);
it('task-templates creates the due task once even when run twice', ...);
  • Schritt 2: npx jest src/jobs src/system src/common/idempotency und test/jobs.e2e-spec.ts → FAIL.
  • Schritt 3: Implementieren
    • cli.ts: resolveRole('cli'), assertStartupEnvironment, dann runCli(process.argv.slice(2)).then((code) => { process.exitCode = code; }).
  • Schritt 4: → PASS. npm run build && ls dist/cli.js dist/worker.js dist/main.js
  • Schritt 5: Verifikation
    • typecheck, lint, npm test -- --ci
    • Docs
    • kein Commit

Carry-forward (Orchestrator, aus Reviews der Tasks 3, 19):

  • cleanup löscht zusätzlich abgelaufene ThrottlerHit-Zeilen (expiresAt < now(), SQL-Uhr).
  • Benachrichtigungen über recordForPermission bei gescheiterten Jobs werden dedupliziert (z. B. eine je Job und Tag), sonst meldet der stündliche app-backup-Check jede Stunde.
  • cli.ts ruft wie main.ts zuerst resolveRole und assertStartupEnvironment auf.

Task 16: app-backup als CronJob mit Fälligkeitsprüfung

Files

  • Modify:
    • src/backup/backup-scheduler.service.ts (+ .spec.ts): SchedulerRegistry, reload(), runScheduled() und der mtime-Guard entfallen
    • src/backup/backup.controller.ts (+ .spec.ts): keine reload()-Aufrufe mehr
    • src/backup/backup.module.ts: exportiert BackupSchedulerService
    • src/jobs/app-backup.job.ts: Platzhalter ersetzen
    • Frontend ApiCore-Frontend/src/app/(app)/settings/backup/page.tsx (+ page.spec.tsx) und src/lib/backup/types.ts
  • Create: src/backup/backup-due.ts (+ .spec.ts), test/app-backup-job.e2e-spec.ts
  • Docs: ARCHITECTURE.md §8 „Automatischer Zeitplan“. Der CronJob prüft stündlich gegen den Zeitplan aus Env oder GUI. Es gibt keinen mtime-Guard mehr; „Jetzt sichern“ bleibt in der API.

Interfaces

  • Consumes: Task 15.
  • Produces:
export const BACKUP_SCHEDULE_TIME_ZONE = 'Europe/Berlin';
export function isBackupDue(cronSchedule: string, lastBackupAt: Date | null, now: Date, timeZone?: string): boolean; // null -> true; sonst nächster Termin nach lastBackupAt <= now
export type AppBackupResult = { written: true; fileName: string; sizeBytes: number; pruned: number } | { written: false; reason: 'no-path' | 'not-due' };
BackupSchedulerService.runIfDue(now?: Date): Promise<AppBackupResult>; // lastBackupAt = mtime der neuesten Backup-Datei
// BackupScheduleStatus += lastJobRun: { status: JobRunStatus; startedAt: string; finishedAt: string | null; error: string | null } | null
  • Schritt 1: Failing Tests
it('is not due again on the same day for "0 3 * * *"', () =>
  expect(isBackupDue('0 3 * * *', new Date('2026-10-12T01:00:00Z'), new Date('2026-10-13T00:30:00Z'))).toBe(false));
it('is due after the next 03:00 Europe/Berlin', () =>
  expect(isBackupDue('0 3 * * *', new Date('2026-10-12T01:00:00Z'), new Date('2026-10-13T01:05:00Z'))).toBe(true));
it('runIfDue writes nothing without a path', ...);
// e2e: Job zweimal in derselben Stunde -> eine Datei; JobRun.result.written true, dann false
// Frontend: zeigt „Wird stündlich geprüft · letzter Lauf {Datum} · Fehlgeschlagen: {error}“
  • Schritt 2:
    • Backend: npx jest src/backup src/jobs und test/app-backup-job.e2e-spec.ts → FAIL
    • Frontend: npx vitest run "src/app/(app)/settings/backup/page.spec.tsx" → FAIL
  • Schritt 3: Implementieren.
  • Schritt 4: → PASS.
  • Schritt 5: Verifikation
    • Backend typecheck, lint, npm test -- --ci
    • Frontend npm run lint && npm run typecheck
    • Docs
    • kein Commit

Task 17: In-Prozess-Pfade abbauen und Wächtertests

Files

  • Modify:
    • src/app.module.ts: ScheduleModule.forRoot() entfällt
    • package.json: npm uninstall @nestjs/schedule && npm install cron@^4.4.0
    • src/automation/automation.service.ts (+ .spec.ts): ungenutzte Altpfade entfernen (evaluateInspection, evaluateVarroaMeasurement, evaluateTreatment, evaluateFeeding, evaluateColonyHealth, evaluateRawMaterialLow)
  • Create:
    • src/runtime/no-in-process-schedulers.spec.ts
    • src/common/after-commit-usage.spec.ts
  • Docs: ARCHITECTURE.md Annahme 11 neu fassen. Seiteneffekte laufen über das Ereignis-Rückgrat; runAfterCommit nur noch für die Liste unten. Nachzügler für eine spätere Stufe: Königinnen, Honigchargen, Lager, Schwarmkontrolle.

Interfaces

  • Consumes: Task 11–16.

  • Schritt 1: Failing Tests

// no-in-process-schedulers.spec.ts (Spec §9 Wächter)
it('no @Cron/@Interval/@Timeout, SchedulerRegistry, ScheduleModule or setInterval in src', () => {
  expect(offenders(/@(Cron|Interval|Timeout)\(|SchedulerRegistry|ScheduleModule|setInterval\(/)).toEqual([]);
});
it('package.json no longer depends on @nestjs/schedule', () => expect(pkg.dependencies['@nestjs/schedule']).toBeUndefined());
// after-commit-usage.spec.ts
it('runAfterCommit is only used by the remaining legacy paths', () => {
  expect(filesCalling('runAfterCommit(')).toEqual(['src/automation/automation.service.ts', 'src/calendar-sync/calendar-sync.service.ts', 'src/webhooks/webhooks.service.ts']);
  expect(filesCalling('calendarSync.enqueue(')).toEqual(['src/queens/queens.service.ts']);
  expect(filesCalling('webhooks.dispatch(')).toEqual(['src/colonies/colonies.service.ts', 'src/honey-batches/honey-batches.service.ts', 'src/queens/queens.service.ts', 'src/storage/storage.service.ts']);
});
  • Schritt 2: npx jest src/runtime src/common/after-commit-usage.spec.ts → FAIL (ScheduleModule und Altaufrufe noch vorhanden).
  • Schritt 3: Implementieren
    • In jest.transformIgnorePatterns bleibt cron.
    • @nestjs/schedule aus beiden Jest-Konfigurationen entfernen.
  • Schritt 4: → PASS.
  • Schritt 5: Verifikation
    • typecheck, lint, npm test -- --ci, npm run build
    • Docs
    • kein Commit

Task 18: daily-evaluation – zeitbasierte Ereignisse

Files

  • Create: src/jobs/daily-evaluation.rules.ts (+ .spec.ts), test/daily-evaluation.e2e-spec.ts
  • Modify: src/jobs/daily-evaluation.job.ts (Platzhalter ersetzen)
  • Docs: ARCHITECTURE.md §10, „Zeitbasierte Ereignisse“ (Regeln und Werte; die Handler folgen mit T4 aus Spec §10)

Interfaces

  • Consumes: Task 3, 15.
  • Produces:
export const CATCH_UP_DAYS = 7;
export const QUEEN_AGED_MIN_YEARS = 2;
export const VARROA_MEASUREMENT_INTERVAL_DAYS = 28;
export const INSPECTION_INTERVAL_DAYS = 21;
export const ACTIVE_SEASON_MONTHS: readonly number[] = [3, 4, 5, 6, 7, 8, 9, 10];
export function utcDay(date: Date): string; // YYYY-MM-DD
export function withdrawalEndDay(t: Pick<Treatment, 'date' | 'endDate' | 'withdrawalPeriodDays'>): string | null;
export function latestCycleOccurrence(base: Date, intervalDays: number, today: Date): string | null; // größtes base+k*Intervall <= heute, k>=1
export function inCatchUpWindow(day: string, today: Date): boolean;
DailyEvaluationJob.run(): Promise<JobResult>; // { WithdrawalEnded: n, TaskDue: n, QueenAged: n, VarroaMeasurementDue: n, InspectionOverdue: n }
  • Regeln, jeweils mit Tag im Nachholfenster [heute − 7, heute]:

    • WithdrawalEnded (Aggregat Treatment): (endDate ?? date) + withdrawalPeriodDays
    • TaskDue (Task, Status OPEN/IN_PROGRESS): dueDate
    • QueenAged (Queen ACTIVE mit currentYear − hatchYear ≥ 2): Tag <YYYY>-01-01, also einmal pro Jahr
    • VarroaMeasurementDue und InspectionOverdue (lebende Völker über LIVING_COLONY_WHERE): Basis ist die letzte Messung bzw. Durchsicht, sonst colony.createdAt. Nur wenn der Monat des Tags in ACTIVE_SEASON_MONTHS liegt.
  • Geschrieben wird je Typ in einer Transaktion über events.recordAll(tx, …) mit dailyDedupeKey.

  • Schritt 1: Failing Tests

it('a 14-day withdrawal of a treatment ending 2026-10-01 ends on 2026-10-15', () =>
  expect(withdrawalEndDay({ date: d('2026-09-28'), endDate: d('2026-10-01'), withdrawalPeriodDays: 14 })).toBe('2026-10-15'));
it('catches up occurrences of the last 7 days exactly once', ...);
it('emits no inspection or varroa event outside March–October', ...);
// e2e
it('running the job twice yields exactly one WithdrawalEnded per treatment and day', ...);
  • Schritt 2: → FAIL.
  • Schritt 3: Implementieren.
  • Schritt 4: → PASS.
  • Schritt 5: Verifikation
    • typecheck, lint, npm test -- --ci
    • Docs
    • kein Commit

Task 19: Throttler über die Datenbank

Files

  • Create:
    • src/common/throttler/prisma-throttler.storage.ts (+ .spec.ts)
    • src/common/throttler/throttler-storage.module.ts
    • test/throttler.e2e-spec.ts
  • Modify:
    • src/app.module.ts: ThrottlerModule.forRootAsync
    • test/utils/create-test-app.ts: eigener Namespace je Instanz, optional geteilt
  • Docs: ARCHITECTURE.md §9, Zeile „Rate-Limits“ (über alle Pods, DB-Uhr). Den ersten „Bekannte Restpunkte“-Bullet entfernen.

Interfaces

  • Consumes: Task 2, 4.
  • Produces:
export const THROTTLER_NAMESPACE = Symbol('THROTTLER_NAMESPACE'); // Produktion: ''
@Injectable() class PrismaThrottlerStorage implements ThrottlerStorage {
  increment(key: string, ttl: number, limit: number, blockDuration: number, throttlerName: string): Promise<ThrottlerStorageRecord>;
}
// ThrottlerModule.forRootAsync({ imports: [ThrottlerStorageModule], inject: [PrismaThrottlerStorage],
//   useFactory: (storage) => ({ throttlers: [{ name: 'default', ttl: 60000, limit: 100 }], storage }) })
export function createTestApp(options?: { throttlerNamespace?: string }): Promise<INestApplication<App>>; // Standard: randomUUID()
  • Semantik in einer Anweisung: INSERT … ON CONFLICT ("key","throttlerName") DO UPDATE … RETURNING.

    • Gespeichert wird der Schlüssel ${namespace}:${key} (key kommt vom Guard bereits als SHA-256).
    • Aktive Sperre (blockedUntil > now()): hits bleibt.
    • Abgelaufenes Fenster oder abgelaufene Sperre: hits = 1, expiresAt = now() + ttl, blockedUntil = NULL.
    • Sonst hits + 1.
    • Werden die neuen hits größer als limit und es gibt keine aktive Sperre: blockedUntil = now() + blockDuration.
    • Rückgabe aus SQL:
      • totalHits
      • timeToExpire = ceil(EXTRACT(EPOCH FROM "expiresAt" - now()))
      • isBlocked = "blockedUntil" > now()
      • timeToBlockExpire, sonst 0
  • Schritt 1: Failing Tests

it('uses only the database clock (no JS dates in the statement)', () => {
  const values = prisma.$queryRaw.mock.calls[0].slice(1);
  expect(values.some((v) => v instanceof Date)).toBe(false);
  expect(sqlText()).toContain('now()');
});
// test/throttler.e2e-spec.ts
it('counts across two app instances sharing one namespace', async () => {
  const ns = randomUUID(); const [a, b] = [await createTestApp({ throttlerNamespace: ns }), await createTestApp({ throttlerNamespace: ns })];
  for (let i = 0; i < 30; i++) await request((i % 2 ? a : b).getHttpServer()).get(`/api/v1/public/batches/NONE-${suffix}`).expect(404);
  await request(a.getHttpServer()).get(`/api/v1/public/batches/NONE-${suffix}`).expect(429);
});
it('40 parallel requests over two instances yield exactly 10 × 429', ...);
it('separate namespaces never share counters', ...);
it('an expired window starts again at 1', ...); // expiresAt per SQL in die Vergangenheit
  • Schritt 2: → FAIL.
  • Schritt 3: Implementieren.
  • Schritt 4: → PASS. Zusätzlich test/auth-throttle.e2e-spec.ts und test/account-guard.e2e-spec.ts → PASS.
  • Schritt 5: Verifikation
    • typecheck, lint, npm test -- --ci
    • komplette e2e-Suite npm run test:e2e (prüft, dass parallele Dateien sich nicht drosseln)
    • Docs
    • kein Commit

Task 20: Schlüsselbund und getrennte Schlüssel

Files

  • Create: src/common/crypto/key-ring.ts (+ .spec.ts)
  • Modify:
    • src/auth/mfa/mfa-crypto.util.ts (+ Spec)
    • src/webhooks/webhook-secret.ts (+ Spec)
    • src/backup/backup-crypto.ts (+ Spec)
    • src/runtime/startup-checks.ts (+ Spec)
    • .env.example
    • deploy/k8s/base/backend/secret.example.yaml
  • Docs: ARCHITECTURE.md Annahme 19 anpassen (Schlüssel getrennt). Neue Annahme 22 „Schlüsselbund“: Format, Rotation über *_PREVIOUS, Altchiffrate sind nur lesbar, solange JWT_ACCESS_SECRET unverändert ist.

Interfaces

  • Consumes: Task 1.
  • Produces:
export const MIN_ENCRYPTION_KEY_LENGTH = 32;
export const KEY_ID_PATTERN = /^k[0-9a-f]{8}:/;
export type SecretPurpose = 'mfa' | 'webhook';
export interface RingKey { id: string; key: Buffer }
export interface KeyRing { current: RingKey; previous: RingKey | null }
export function loadKeyRing(purpose: SecretPurpose, env?: NodeJS.ProcessEnv): KeyRing;   // Prod: Env-Schlüssel Pflicht; Dev-Rückfall: HKDF aus JWT_ACCESS_SECRET je Zweck
export function sealWithRing(ring: KeyRing, plaintext: string): string;                    // `${id}:${iv}.${tag}.${data}`
export function openWithRing(ring: KeyRing, stored: string): { plaintext: string; keyId: string };
export function legacyJwtKey(env?: NodeJS.ProcessEnv): Buffer;                             // sha256(JWT_ACCESS_SECRET), nur zum Entschlüsseln
export function encryptMfaSecret(plaintext: string): string;
export function decryptMfaSecret(stored: string): string;
export function mfaSecretNeedsRekey(stored: string): boolean;
export function sealWebhookSecret(secret: string): string;
export function openWebhookSecret(stored: string): string;      // k…: | enc:v1: (alt) | Klartext (alt)
export function webhookSecretNeedsRekey(stored: string): boolean;
// backup-crypto: Ring = BACKUP_ENCRYPTION_KEY (current), BACKUP_ENCRYPTION_KEY_PREVIOUS, legacy JWT (nur Entschlüsseln), Treffer über sealed.keyId; Dateiformat v3 unverändert
  • Ableitung: hkdfSync('sha256', secret, 'apicore-key-ring', 'apicore-<purpose>-v1', 32). Key-ID: 'k' + sha256(key).hex.slice(0, 8).

  • Startprüfung in Produktion: alle drei Variablen gesetzt, je ≥ 32 Zeichen, paarweise verschieden, keine gleich JWT_ACCESS_SECRET oder JWT_REFRESH_SECRET.

  • Schritt 1: Failing Tests

it('seals with the current key and prefixes its id', () => expect(sealWithRing(ring, 's')).toMatch(KEY_ID_PATTERN));
it('opens a value sealed with the previous key', ...);
it('refuses an unknown key id', () => expect(() => openWithRing(ring, 'k00000000:a.b.c')).toThrow('unknown key id'));
it('fails on a tampered ciphertext (GCM)', ...);
it('derives different keys for mfa and webhook from the same dev fallback', ...);
it('still opens a legacy MFA secret encrypted with the JWT-derived key', ...);
it('opens legacy enc:v1 and plaintext webhook secrets and flags them for rekey', ...);
it('opens a backup sealed with BACKUP_ENCRYPTION_KEY_PREVIOUS', ...);
it('production refuses to start without MFA_/WEBHOOK_/BACKUP_ENCRYPTION_KEY', ...);
it('refuses two purposes sharing one key or a key equal to JWT_ACCESS_SECRET', ...);
  • Schritt 2: npx jest src/common/crypto src/auth/mfa src/webhooks src/backup src/runtime → FAIL.
  • Schritt 3: Implementieren wie beschrieben.
    • Liest das System ein Altchiffrat, schreibt es einmal je Prozess warn „legacy ciphertext read – run the rekey job“, ohne Werte.
  • Schritt 4: → PASS.
    • Zusätzlich npx jest --config ./test/jest-e2e.json test/auth.e2e-spec.ts test/webhooks.e2e-spec.ts test/backup.e2e-spec.ts --runInBand → PASS
  • Schritt 5: Verifikation
    • typecheck, lint, npm test -- --ci
    • Docs
    • kein Commit

Task 21: rekey-Job

Files

  • Modify: src/jobs/rekey.job.ts (Platzhalter ersetzen, + .spec.ts)
  • Create: test/rekey.e2e-spec.ts
  • Docs: ARCHITECTURE.md Annahme 22, Ablauf rekey (Reihenfolge siehe Runbook Schritt 4).

Interfaces

  • Consumes: Task 15, 20.
  • Produces:
export interface RekeyCounts { total: number; rekeyed: number; alreadyCurrent: number; plaintextSealed: number; changedMeanwhile: number; failed: number }
RekeyJob.run(args: readonly string[]): Promise<JobResult>;
// args enthält '--dry-run' -> nichts schreiben, nur zählen
// Je Zeile einzeln, kein großer Transaktionsblock; CAS: updateMany({ where: { id, mfaSecret: alterWert } }) bzw. { id, secret: alterWert }
// failed > 0 -> Job wirft nach Abschluss aller Zeilen (JobRun FAILED, Zähler bleiben im Fehlertext)
  • Schritt 1: Failing Tests
it('rekeys legacy MFA and webhook secrets and seals legacy plaintext webhook secrets', ...);
it('resumes after an interruption: current rows are skipped, the rest are rekeyed', async () => {
  updateMany.mockRejectedValueOnce(new Error('connection reset'));
  await expect(job.run([])).rejects.toThrow();            // 1 failed
  updateMany.mockResolvedValue({ count: 1 });
  expect(await job.run([])).toMatchObject({ 'mfa.alreadyCurrent': 2, 'mfa.rekeyed': 1, 'mfa.failed': 0 });
});
it('skips a row changed since it was read and reports it', ...); // CAS count 0 -> changedMeanwhile 1, kein Überschreiben
// e2e
it('a second run rekeys nothing; dry-run writes nothing', ...);
it('rekeyed values open to the original plaintext', ...);
  • Schritt 2: → FAIL.
  • Schritt 3: Implementieren.
  • Schritt 4: → PASS.
  • Schritt 5: Verifikation
    • typecheck, lint, npm test -- --ci
    • Docs
    • kein Commit

Task 22: Öffentliche Herkunftsseite ohne genauen Standort

Files

  • Backend:
    • src/public/public.service.ts (+ Spec)
    • src/public/public.controller.ts
    • src/common/middleware/no-store.middleware.ts (+ neue Spec): die Präfix-Ausnahme entfällt, alles ist no-store
    • src/apiaries/dto/create-apiary.dto.ts
    • src/site-settings/dto/update-site-settings.dto.ts, src/site-settings/site-settings.service.ts
    • src/common/validation/update-dto-null-handling.spec.ts
    • test/public.e2e-spec.ts, test/apiaries.e2e-spec.ts
  • Frontend:
    • src/lib/public-batch/types.ts
    • neu src/lib/public-batch/origin-text.ts (+ .spec.ts)
    • src/app/herkunft/[lot]/page.tsx
    • src/lib/apiaries/types.ts
    • src/app/(app)/apiaries/[id]/apiary-edit-card.tsx (+ Spec)
    • src/app/(app)/apiaries/new/page.tsx, apiary-form-values.ts (+ Spec)
    • src/lib/site-settings/types.ts
    • src/app/(app)/settings/automation/social-media-tab.tsx (+ neue social-media-tab.spec.tsx)
  • Docs: ARCHITECTURE.md §9 „Transport/Antworten“ (Cache-Kopf nur bei 200). Neue Annahme 23: öffentliche Herkunft nur über publicLocationLabel und Land, Diebstahlschutz.

Interfaces

  • Consumes: Task 2.
  • Produces:
export const PUBLIC_BATCH_CACHE_CONTROL = 'public, max-age=300, s-maxage=3600';
export interface PublicOrigin { regions: string[]; country: string } // regions = eindeutige, getrimmte, nicht leere publicLocationLabels, sortiert
// PublicBatch: `apiaries` entfällt, neu `origin: PublicOrigin`. Name, location, address und Koordinaten des Stands verlassen den Server nicht mehr.
// CreateApiaryDto.publicLocationLabel?: string – @IsOptional() @IsString() @MaxLength(100); in UpdateApiaryDto per null leerbar
// UpdateSiteSettingsDto.originCountry?: string – @IsOptionalNotNull() @IsString() @MinLength(2) @MaxLength(80)
// Frontend: export function originText(origin: PublicOrigin): string // "Nordhessen, Deutschland" | "Deutschland"
  • Controller (nach erfolgreicher Suche, @Res({ passthrough: true })): res.setHeader('Cache-Control', PUBLIC_BATCH_CACHE_CONTROL); res.removeHeader('Pragma').

  • UI-Texte:

    • Stand: Feld „Ort für die Herkunftsseite“, Hinweis „Wird öffentlich auf der Herkunftsseite der Honiggläser gezeigt, z. B. „Nordhessen“. Nie die genaue Adresse eintragen. Leer lassen: Es erscheint nur das Land.“
    • Website: Feld „Herkunftsland“, Hinweis „Erscheint auf der Herkunftsseite, z. B. „Deutschland“.“
  • Schritt 1: Failing Tests

it('never returns apiary name, location, address or coordinates', async () => {
  const json = JSON.stringify(await service.findBatchByLot('WH-1'));
  for (const secret of ['Stand Wald', 'Waldweg 3', '51.2']) expect(json).not.toContain(secret);
});
it('returns distinct labels plus the country; only the country when no label is set', ...);
// e2e
it('sends the public cache header on 200 and no-store on 404', async () => {
  (await get(`/api/v1/public/batches/${lot}`)).expect(200).expect('Cache-Control', 'public, max-age=300, s-maxage=3600');
  (await get('/api/v1/public/batches/NONE')).expect(404).expect('Cache-Control', 'no-store');
});
it('lets publicLocationLabel be set, changed and cleared with null', ...);
// Null-Vertrag: UpdateApiaryDto.clearable += 'publicLocationLabel'; UpdateSiteSettingsDto notNull: ['originCountry']
// Frontend: originText({ regions: [], country: 'Deutschland' }) === 'Deutschland'; Edit-Karte sendet emptyToNull(publicLocationLabel)
  • Schritt 2:
    • Backend: npx jest src/public src/common und die e2e-Dateien → FAIL
    • Frontend: npx vitest run src/lib/public-batch "src/app/(app)/apiaries" "src/app/(app)/settings/automation/social-media-tab.spec.tsx" → FAIL
  • Schritt 3: Implementieren.
  • Schritt 4: → PASS.
  • Schritt 5: Verifikation
    • Backend: typecheck, lint, npm test -- --ci
    • Frontend: npm run lint && npm run typecheck
    • Docs
    • kein Commit

Task 23: Shop-Schlüssel getrennt nach Lesen und Buchen

Files

  • Backend: src/api-keys/api-keys.service.ts (+ neue .spec.ts), test/api-keys.e2e-spec.ts
  • Frontend:
    • src/lib/api-keys/types.ts
    • neu src/lib/api-keys/scopes.ts (+ .spec.ts)
    • src/app/(app)/settings/automation/api-keys-tab.tsx (+ neue api-keys-tab.spec.tsx)
  • Docs: ARCHITECTURE.md Annahme 14 und §9 „Maschinen-Zugriff“: Lesen und Buchen sind getrennte Schlüssel.

Interfaces

  • Produces:
// Backend: ApiKeysService.create wirft BadRequestException('Lese- und Buchungsrecht bitte als getrennte Schlüssel anlegen'),
//   wenn scopes 'shop-sync:read' UND 'shop-sync:write' enthält. Bestehende Schlüssel bleiben gültig.
// Frontend
export type ApiKeyScope = 'shop-sync:read' | 'shop-sync:write';
export const API_KEY_SCOPE_LABELS: Record<ApiKeyScope, string> = { 'shop-sync:read': 'Shop-Daten lesen (Wabenhain)', 'shop-sync:write': 'Verkäufe und Rückbuchungen buchen (Wabenhain)' };
export function hasCombinedShopScopes(apiKey: Pick<ApiKey, 'scopes'>): boolean;
  • UI:

    • Beim Anlegen eine Auswahl „Berechtigung“ mit dem bestehenden ChoiceGroup (Lesen / Buchen), immer genau ein Scope.
    • In der Liste Scope-Badges je Schlüssel.
    • Hinweis bei kombinierten Scopes (role="status"): „Dieser Schlüssel darf lesen und buchen. Sicherer sind zwei getrennte Schlüssel: einer zum Lesen, einer zum Buchen. Neue Schlüssel anlegen, in Wabenhain eintragen, dann diesen widerrufen.“
  • Schritt 1: Failing Tests

it('refuses a new key with both shop scopes', ...);   // Backend unit + e2e 400
expect(hasCombinedShopScopes({ scopes: ['shop-sync:read', 'shop-sync:write'] })).toBe(true);
it('shows the separation hint only for a key with both scopes', ...);         // vitest
it('creates a write key when "Buchen" is chosen', ...);                        // createApiKey mit scopes ['shop-sync:write']
  • Schritt 2: → FAIL.
  • Schritt 3: Implementieren.
  • Schritt 4: → PASS.
  • Schritt 5: Verifikation
    • Backend: typecheck, lint, npm test -- --ci
    • Frontend: npm run lint && npm run typecheck
    • Docs
    • kein Commit

Task 24: System-API (Läufe, Warteschlange, Wiederholen)

Files

  • Create:
    • src/system/system.controller.ts
    • src/system/system-queue.service.ts (+ .spec.ts)
    • src/system/dto/system-list-query.dto.ts
    • test/system.e2e-spec.ts
  • Modify:
    • src/system/system.module.ts (Controller und Provider)
    • src/auth/permissions/permission-catalog.ts: neue Gruppe
    • test/authorization-coverage.e2e-spec.ts, falls dort eine Routenliste gepflegt wird
  • Docs: ARCHITECTURE.md §10, „Verwaltung > System“.

Interfaces

  • Consumes: Task 4, 7, 15.
  • Produces:
// permission-catalog.ts
...group('System', 'system', [['read', 'Hintergrundjobs und Warteschlangen ansehen'], ['manage', 'Fehlgeschlagene Ereignisse erneut anstoßen']], 3500),
export interface JobRunView { id: string; job: JobName; status: JobRunStatus; startedAt: Date; finishedAt: Date | null; error: string | null; result: JobResult | null }
export interface SystemOverview {
  jobs: { job: JobName; lastRun: JobRunView | null }[];
  domainEvents: { pending: number; processing: number; failed: number; oldestPendingAgeSeconds: number | null };
  webhookDeliveries: { pending: number; failed: number };
}
export interface FailedDomainEventView { id: string; type: string; aggregateType: string; aggregateId: string; occurredAt: Date; attempts: number; lastError: string | null; failedHandlers: { handler: string; lastError: string | null }[] }
export interface FailedWebhookDeliveryView { id: string; subscriptionId: string; targetHost: string; event: string; attempts: number; lastStatusCode: number | null; lastError: string | null; createdAt: Date }
SystemQueueService.overview(): Promise<SystemOverview>;                                // Alter per SQL: EXTRACT(EPOCH FROM now() - MIN("occurredAt"))
SystemQueueService.failedDomainEvents(limit: number): Promise<FailedDomainEventView[]>;
SystemQueueService.retryDomainEvent(id: string, actingUserId: string): Promise<void>;   // 404 unbekannt, 409 nicht FAILED; Ereignis + FAILED-Deliveries -> PENDING, attempts 0; Audit (DomainEvent UPDATE); notifyWorker
SystemQueueService.failedWebhookDeliveries(limit: number): Promise<FailedWebhookDeliveryView[]>;
SystemQueueService.retryWebhookDelivery(id: string, actingUserId: string): Promise<void>;
// Routen (@RequirePermission):
// GET  /system/overview                       system:read
// GET  /system/job-runs?job=&limit=1..100     system:read  (Standard 50)
// GET  /system/domain-events/failed?limit=    system:read
// POST /system/domain-events/:id/retry        system:manage  (204)
// GET  /system/webhook-deliveries/failed?limit= system:read
// POST /system/webhook-deliveries/:id/retry   system:manage  (204)
  • Schritt 1: Failing Tests
it('reports the oldest pending age from the database clock', ...); // $queryRaw, kein Date.now()
it('retry resets a FAILED event and its failed deliveries, writes an audit entry and wakes the worker', ...);
it('retry of a DONE event is 409', ...);
it('shows only the host of a webhook target', ...);
// e2e
it('staff without system:read gets 403; API keys never reach /system', ...);
it('admin retries a FAILED event and the next drain completes it', ...);
  • Schritt 2: → FAIL.
  • Schritt 3: Implementieren.
  • Schritt 4: → PASS, inklusive test/authorization-coverage.e2e-spec.ts.
  • Schritt 5: Verifikation
    • typecheck, lint, npm test -- --ci
    • Docs
    • kein Commit

Task 25: Seite „Verwaltung > System“ und Webhook-Zustellprotokoll

Files (alle in ApiCore-Frontend/)

  • Create:
    • src/lib/system/api.ts, src/lib/system/types.ts
    • src/lib/system/labels.ts (+ .spec.ts)
    • src/app/(app)/settings/system/page.tsx (+ page.spec.tsx)
    • src/app/(app)/settings/automation/webhook-deliveries-dialog.tsx (+ .spec.tsx)
  • Modify:
    • src/app/(app)/sidebar.tsx: ADMIN_GROUP + { label: "System", href: "/settings/system", icon: Activity, permission: "system:read" }
    • src/app/(app)/settings/layout.tsx: SETTINGS_AREA_PERMISSIONS + "system:read"
    • src/app/(app)/settings/automation/webhooks-tab.tsx: Knopf „Zustellprotokoll“
    • src/lib/webhooks/api.ts: fetchWebhookDeliveries

Interfaces

  • Consumes: Task 24 (Routen und Typen), Task 7 (GET /webhooks/:id/deliveries).
  • Produces:
export function fetchSystemOverview(): Promise<SystemOverview>;
export function fetchJobRuns(job?: JobName): Promise<JobRunView[]>;
export function fetchFailedDomainEvents(): Promise<FailedDomainEventView[]>;
export function retryDomainEvent(id: string): Promise<void>;
export function fetchFailedWebhookDeliveries(): Promise<FailedWebhookDeliveryView[]>;
export function retryWebhookDelivery(id: string): Promise<void>;
export function fetchWebhookDeliveries(subscriptionId: string): Promise<WebhookDeliveryView[]>;
export const JOB_LABELS: Record<JobName, string>;                 // wie JOB_LABELS_DE im Backend
export const JOB_RUN_STATUS_LABELS = { RUNNING: 'Läuft', SUCCEEDED: 'Erfolgreich', FAILED: 'Fehlgeschlagen' } as const;
export const DOMAIN_EVENT_LABELS: Record<string, string>;         // z. B. InspectionRecorded: 'Durchsicht erfasst'
export const QUEUE_STALE_WARNING_SECONDS = 600;
export function formatAge(seconds: number): string;                // "vor 3 Min." / "vor 2 Std."
  • UI-Texte:

    • Überschrift „System“, Abschnitte „Hintergrundjobs“, „Ereignis-Warteschlange“ und „Webhook-Zustellungen“.
    • „Offen: {n}“, „Ältestes offenes Ereignis: {formatAge}“, „Keine offenen Ereignisse“.
    • Warnung ab 600 s: „Der Worker arbeitet die Warteschlange gerade nicht ab.“
    • „Keine fehlgeschlagenen Ereignisse.“, Knopf „Erneut versuchen“ (nur mit system:manage), Toast „Wird erneut versucht“.
    • Dialog „Zustellprotokoll“ mit Status „Zugestellt / Offen / Fehlgeschlagen“, HTTP-Code und Versuchen.
  • Schritt 1: Failing Tests

it('lists every job with its last run and status label', ...);
it('warns when the oldest open event is older than 10 minutes', ...);
it('retries a failed event and refetches the overview', ...);
it('hides "Erneut versuchen" without system:manage', ...);
it('shows the delivery log of a webhook', ...); // webhook-deliveries-dialog.spec.tsx
expect(formatAge(180)).toBe('vor 3 Min.');
  • Schritt 2: npx vitest run src/lib/system "src/app/(app)/settings/system" "src/app/(app)/settings/automation/webhook-deliveries-dialog.spec.tsx" → FAIL.
  • Schritt 3: Implementieren
    • Vorher node_modules/next/dist/docs/ lesen.
    • Bestehende Bausteine nutzen: Card, Badge, ConfirmDialog, QueryState, EmptyState.
  • Schritt 4: → PASS.
  • Schritt 5: Verifikation
    • npm run lint && npm run typecheck && npm test && npm run build
    • kein Commit

Task 26: Kubernetes (Kustomize), Dockerfiles und CI

Files

  • Move (mit git mv, Geschichte bleibt erhalten):
    • deploy/k8s/backend/* → deploy/k8s/base/backend/
    • deploy/k8s/frontend/* → deploy/k8s/base/frontend/
    • deploy/k8s/postgres/* → deploy/k8s/base/postgres/
    • ingress.yaml beider Projekte → deploy/k8s/overlays/encry/ingress-backend.yaml und ingress-frontend.yaml
  • Create:
    • deploy/k8s/base/kustomization.yaml. Die Dateien *.example.yaml stehen nicht unter resources.
    • deploy/k8s/components/network-policies/ mit kustomization.yaml (kind: Component), default-deny.yaml, allow-dns.yaml, frontend.yaml, api.yaml, worker.yaml, jobs.yaml, postgres.yaml
    • deploy/k8s/components/pod-hardening/ mit kustomization.yaml, patch-frontend.yaml
    • deploy/k8s/components/backend-roles/ mit kustomization.yaml, patch-api.yaml, worker-deployment.yaml, cronjobs.yaml
    • deploy/k8s/components/api-hpa/ mit kustomization.yaml, hpa.yaml, patch-api-replicas.yaml (JSON6902 remove /spec/replicas)
    • deploy/k8s/overlays/encry/ mit kustomization.yaml, configmap-backend.yaml, configmap-frontend.yaml
    • deploy/k8s/tests/manifest-policy.test.mjs
    • .gitea/workflows/manifests.yml
  • Modify:
    • ApiCore-Backend/Dockerfile:
      • FROM node:22-alpine@sha256:<digest> in beiden Stages
      • CMD ["node", "dist/main.js"]
      • EXPOSE 3001 3002
      • Kommentar zum initContainer
    • ApiCore-Frontend/Dockerfile: Digest-Pin
    • .gitea/workflows/backend-image.yml und frontend-image.yml: Zeile type=raw,value=latest,enable={{is_default_branch}} entfernen
  • Docs: ARCHITECTURE.md §7 neu (Struktur base/components/overlay, Rollen-Deployments, CronJobs, NetworkPolicy-Tabelle) und §8 „Schema-Migrationen“ (initContainer statt CMD, Seed nur api).

Interfaces (YAML-Eckdaten, gepinnt)

  • api (base Deployment apicore-backend, Selektor unverändert):

    • base replicas: 1, strategy: RollingUpdate (maxSurge 1, maxUnavailable 0)
    • Image ohne Tag; den Digest setzt das Overlay.
  • backend-roles-Patch für api:

    • env APICORE_ROLE=api, HOME=/tmp
    • Pod-Label db-client: "true", automountServiceAccountToken: false
    • Container readOnlyRootFilesystem: true, emptyDir auf /tmp, Ressourcen laut Tabelle
    • initContainers:
      • migrate: ["node", "node_modules/prisma/build/index.js", "migrate", "deploy"], env HOME=/tmp, CHECKPOINT_DISABLE=1
      • seed: ["node", "dist/seed.js"]
      • beide mit RORFS, /tmp und den Job-Ressourcen
    • Probes wie bisher.
  • worker (Deployment apicore-worker):

    • replicas: 1, Labels app: apicore-worker, db-client: "true"
    • env APICORE_ROLE=worker, WORKER_HEALTH_PORT=3002, HOME=/tmp
    • command: ["node", "dist/worker.js"], initContainer migrate (ohne Seed)
    • Port 3002 (health); startupProbe (/health, failureThreshold 30, periodSeconds 10), livenessProbe (periodSeconds 30, failureThreshold 3), readinessProbe (periodSeconds 15)
    • terminationGracePeriodSeconds: 30, kein Service, kein Ingress
    • envFrom derselben ConfigMap und desselben Secrets
  • CronJobs apicore-job-<name>:

    • command: ["node", "dist/cli.js", "<name>"], Labels app: apicore-job, apicore.io/job: <name>, db-client: "true"
    • restartPolicy: Never, Werte laut Global Constraints
    • app-backup bindet apicore-data auf /data ein
    • apicore-job-rekey-dry-run mit ["node", "dist/cli.js", "rekey", "--dry-run"]
  • frontend (pod-hardening): RORFS, emptyDir auf /tmp und /app/.next/cache, automountServiceAccountToken: false, Ressourcen laut Tabelle.

  • Postgres bleibt in diesem Task unverändert. Die Live-Härtung übernimmt Runbook Schritt 1.

  • HPA: autoscaling/v2, minReplicas: 1, maxReplicas: 2, CPU averageUtilization: 70.

  • NetworkPolicies (Spec §5.2):

    • Default-Deny für Ingress und Egress.
    • DNS-Egress für alle Pods zu namespaceSelector kubernetes.io/metadata.name: kube-system + podSelector k8s-app: kube-dns, UDP und TCP 53.
    • Traefik-Quelle: namespaceSelector kube-system + podSelector app.kubernetes.io/name: rke2-traefik. Wird im Runbook Schritt 1 gegen die Live-Policies geprüft.
    • frontend: Ingress von Traefik auf 3000; Egress zu app: apicore-backend auf 3001.
    • api: Ingress von Traefik und vom Frontend auf 3001; Egress zu Postgres 5432 und 443 öffentlich.
    • worker: kein Ingress; Egress zu Postgres 5432 und 443 öffentlich.
    • jobs (app in (apicore-job, apicore-db-backup)): Egress zu Postgres 5432.
    • postgres: Ingress nur von db-client: "true" auf 5432, kein Egress.
    • 443 öffentlich bedeutet:
      • IPv4 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
      • IPv6 ::/0 ohne fc00::/7, fe80::/10, ::1/128
  • Overlay encry:

    • namespace: apicore
    • components: alle vier (Zielstand)
    • images: Backend, Frontend und postgres mit digest: sha256:0000…0000 als Platzhalter (wird im Runbook Schritt 0 ersetzt)
    • Ingress-Hosts und ConfigMap-Werte als Platzhalter (Runbook Schritt 1); ingressClassName: traefik und Middleware-Annotation wie live
  • CI manifests.yml:

    • Trigger: push auf main und pull_request mit paths: ['deploy/k8s/**', '.gitea/workflows/manifests.yml'], dazu workflow_dispatch
    • runs-on: docker-builder, actions/setup-node@v4 (22)
    • kubectl, kubeconform und yq als Binär-Downloads mit festen Versionen und festen SHA-256-Werten (sha256sum -c)
    • Ablauf: kubectl kustomize deploy/k8s/overlays/encry > "$RUNNER_TEMP/rendered.yaml" → kubeconform -strict -summary "$RUNNER_TEMP/rendered.yaml" → yq ea -o=json '[.]' … > rendered.json → RENDERED_MANIFESTS=… node --test deploy/k8s/tests/manifest-policy.test.mjs
  • Schritt 1: Failing Test deploy/k8s/tests/manifest-policy.test.mjs (node:test, liest das JSON aus RENDERED_MANIFESTS):

test('default deny for ingress and egress exists', ...);
test('every pod template can resolve DNS (UDP+TCP 53 to kube-dns)', ...);
test('postgres accepts 5432 only from db-client pods; only api, worker, jobs and db-backup carry db-client', ...);
test('frontend has no path to 5432 and no ipBlock egress', ...);
test('worker and job pods accept no ingress', ...);
test('every 0.0.0.0/0 and ::/0 egress excludes private ranges and allows only TCP 443', ...);
test('all app pods: automountServiceAccountToken false, readOnlyRootFilesystem true, /tmp emptyDir', ...);
test('cronjobs: Forbid, Europe/Berlin, command node dist/cli.js <known job>', ...);
test('resources match the spec table; HPA api 1–2 at 70 % CPU', ...);
test('api and worker run prisma migrate deploy as initContainer; only api seeds', ...);
  • Schritt 2: kubectl kustomize deploy/k8s/overlays/encry → FAIL (Overlay fehlt). Falls lokal kein kubectl oder yq vorhanden ist, übernimmt der Orchestrator oder die CI diesen Lauf.
  • Schritt 3: Implementieren
    • Manifeste wie oben.
    • Base-Image-Digest auflösen mit docker buildx imagetools inspect node:22-alpine --format '{{json .Manifest.Digest}}'. Ohne Docker:
TOKEN=$(curl -s "https://auth.docker.io/token?service=registry.docker.io&scope=repository:library/node:pull" | sed -E 's/.*"token":"([^"]+)".*/\1/')
curl -sI -H "Authorization: Bearer $TOKEN" -H "Accept: application/vnd.oci.image.index.v1+json" \
  https://registry-1.docker.io/v2/library/node/manifests/22-alpine | grep -i docker-content-digest
  • Versionen und SHA-256 der CI-Werkzeuge von den offiziellen Release-Seiten eintragen: https://dl.k8s.io/release/<v>/bin/linux/amd64/kubectl.sha256, CHECKSUMS bei kubeconform, checksums bei yq.
  • Blocker vor dem Commit: Weder der Dockerfile-Digest noch die CI-Checksummen dürfen Platzhalter bleiben. Die Overlay-Digests und Hosts sind dagegen gewollte Platzhalter (Runbook).
  • Schritt 4:
    • kubectl kustomize deploy/k8s/overlays/encry | kubeconform -strict -summary - → 0 Fehler
    • node --test deploy/k8s/tests/manifest-policy.test.mjs → PASS
    • docker build ApiCore-Backend (falls Docker vorhanden) → Image startet mit CMD node dist/main.js
  • Schritt 5: Verifikation
    • git status zeigt die Verschiebungen als Renames.
    • Docs.
    • Kein Apply gegen den Cluster, kein Commit.

Abschluss (Orchestrator)

  • Backend:
    • npm run lint && npm run typecheck && npm test && npm run build
    • Test-DB migrieren, dann npm run test:e2e, komplett
    • ls dist/main.js dist/worker.js dist/cli.js dist/seed.js
  • Frontend: npm run lint && npm run typecheck && npm test && npm run build
  • Manifeste: kubectl kustomize deploy/k8s/overlays/encry | kubeconform -strict -summary - und node --test deploy/k8s/tests/manifest-policy.test.mjs
  • Drift-Check gegen die Test-DB: npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --exit-code → 0
  • Platzhalter-Kontrolle: grep -rn "REPLACE\|sha256:0000" ApiCore-Backend/Dockerfile ApiCore-Frontend/Dockerfile .gitea/workflows → leer. Treffer nur im Overlay sind erlaubt.
  • ARCHITECTURE.md auf Widersprüche lesen: §7, §8, §9, §10, Annahmen 9, 11, 12, 14, 19, 22, 23.
  • Ein zentraler Commit, Nachricht endet mit Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>.

Rollout-Runbook (nicht Teil der Tasks; jeder Schritt braucht die Freigabe des Inhabers)

Schritt 0 – Vorbereitung (Freigabe)

  • Im Overlay zunächst die Live-Digests eintragen (aktuell laufende Images), nicht die neuen: Das neue Backend-Image verlangt APICORE_ROLE und migriert nicht mehr selbst – es geht erst in Schritt 3 zusammen mit backend-roles live. Neue Digests ermitteln (docker buildx imagetools inspect git.encry.de/<owner>/apicore-backend:sha-<commit>) und für Schritt 3 bereithalten; Postgres-Digest = live.
  • Kubernetes-Version prüfen (kubectl version) und optional in kubeconform pinnen (-kubernetes-version).

Schritt 1 – Overlay auf den Ist-Stand bringen (Freigabe)

  • In overlays/encry/kustomization.yaml alle components vorübergehend auskommentieren.
  • Live-Stand exportieren: kubectl -n apicore get deploy,sts,cronjob,ingress,cm,networkpolicy -o yaml.
  • Übernehmen:
    • Hosts, TLS-Secrets und ConfigMap-Werte ins Overlay.
    • Die Postgres-Härtung wörtlich in base/postgres/statefulset.yaml.
    • Die Selektoren der 3 Live-NetworkPolicies (Traefik-Labels, db-client) in components/network-policies.
  • kubectl diff -k deploy/k8s/overlays/encry wiederholen, bis der Diff leer ist.
  • Prüfen: kubectl -n kube-system get pods -l k8s-app=kube-dns und das Traefik-Label.
  • Rückweg: keiner nötig, es wird nichts angewendet.
  • Achtung: base/postgres/data-pvc.yaml stellt das Live-PVC unter kubectl apply/delete -k. Nie kubectl delete -k auf das Overlay ausführen – das PV ist Retain, die Daten bleiben, aber das PVC müsste von Hand neu gebunden werden.

Schritt 2 – NetworkPolicies schärfen (Freigabe)

  • Hinweis: Die drei Policies aus dem rke2-cluster-Repo (allow-from-traefik, allow-same-namespace, default-deny-ingress, nur Ingress) bleiben bis zum Ende dieses Schritts parallel aktiv und lockern Ingress (Traefik erreicht bis dahin jeden Port, auch Postgres).
  • Reihenfolge: (a) components/network-policies aktivieren – enthält die ACME-HTTP01-Solver-Policy und das Label db-client: "true" am api-Pod –, kubectl diff -k …, kubectl apply -k …; (b) prüfen (unten); (c) erst danach im rke2-cluster-Repo die apicore-Kopien von allow-from-traefik und allow-same-namespace entfernen und erneut prüfen; (d) vor dem nächsten Zertifikats-Renewal (frontend 2026-10-25, backend 2026-10-26) mit kubectl -n apicore get certificate bestätigen, dass ein Renewal durchläuft (oder per cmctl renew testen).
  • Prüfen:
    • Alle Pods Ready, keine Probe-Fehler in kubectl get events.
    • DNS: kubectl -n apicore exec deploy/apicore-backend -- getent hosts apicore-postgres.
    • Frontend → Postgres blockiert: kubectl -n apicore exec deploy/apicore-frontend -- node -e "require('net').connect(5432,'apicore-postgres').on('connect',()=>console.log('OPEN')).on('error',e=>console.log(e.code)).setTimeout(3000,()=>{console.log('BLOCKED');process.exit()})" → BLOCKED.
    • Login funktioniert, SSO und Have-I-Been-Pwned sind erreichbar.
  • Rückweg: kubectl -n apicore delete networkpolicy <neue Namen>, alte Policies aus dem Export wieder anwenden.

Schritt 3 – Rollen, Worker, CronJobs, Pod-Härtung (Freigabe)

  • Vorab prüfen (Inhaber): SHOW TIME ZONE; auf der Live-DB (App pinnt jede Sitzung auf UTC; optional ALTER DATABASE apicore SET timezone TO 'UTC';) und dass DATABASE_URL kein options= enthält – sonst verweigert die App den Start.
  • Vorab prüfen (Inhaber, ohne Werte auszugeben): Das Live-Secret apicore-backend-secret enthält keinen Wert, der mit change-me beginnt – besonders JWT_REFRESH_SECRET (alter Beispielwert war kurz und wurde nie geprüft). Sonst verweigert jede Rolle den Start.
  • Secret ergänzen, außerhalb des Repos:
    • MFA_ENCRYPTION_KEY und WEBHOOK_ENCRYPTION_KEY neu erzeugen (openssl rand -hex 32, alle verschieden).
    • Ein vorhandenes BACKUP_ENCRYPTION_KEY unverändert lassen. Fehlt es, neu setzen; alte JWT-abgeleitete Backups bleiben bis Schritt 4 lesbar.
  • components/pod-hardening und components/backend-roles aktivieren, neue Image-Digests, diff, apply.
  • Prüfen:
    • initContainer migrate erfolgreich; im API-Log APICORE_ROLE=api.
    • Worker Ready, kubectl -n apicore exec deploy/apicore-worker -- wget -qO- localhost:3002/health.
    • kubectl -n apicore create job cleanup-test --from=cronjob/apicore-job-cleanup → in „Verwaltung > System“ Erfolgreich.
    • Testdurchsicht anlegen → „Offen: 0“ nach Sekunden; Webhook-Testziel bekommt die Zustellung.
  • Zusatz mit eigener Freigabe: Cloudflare-Cache-Regel und Rate-Limit-Regel für /api/v1/public/batches/* (Spec §5.4).
  • Rückweg: vorigen Digest zurücksetzen und die Komponenten deaktivieren. Die Migration ist additiv, das alte Image läuft weiter.

Schritt 4 – Schlüssel trennen (Freigabe)

  1. kubectl -n apicore create job rekey-dry-$(date +%s) --from=cronjob/apicore-job-rekey-dry-run → Zähler prüfen, failed: 0.
  2. kubectl -n apicore create job rekey-$(date +%s) --from=cronjob/apicore-job-rekey → JobRun Erfolgreich. Ein zweiter Lauf meldet rekeyed: 0.
  3. MFA-Login testen, Webhook-Testzustellung prüfen.
  4. „Jetzt sichern“: neues Backup mit den neuen Schlüsseln.
  5. Erst danach JWT_ACCESS_SECRET und JWT_REFRESH_SECRET rotieren und kubectl rollout restart deploy/apicore-backend deploy/apicore-worker. Alle Sitzungen enden einmal, das ist gewollt.
  • Rückweg vor Punkt 5: alte Werte zurück, Altchiffrate bleiben lesbar. Nach Punkt 5 gibt es keinen Rückweg für Altchiffrate, darum die Prüfungen in 1–4.

Schritt 5 – HPA einschalten (Freigabe)

  • components/api-hpa aktivieren, diff, apply.
  • Prüfen: kubectl -n apicore get hpa zeigt CPU-Ziel und aktuellen Wert. Der Throttler zählt über 2 Pods (optional ein kurzer Lasttest gegen /api/v1/public/batches/X: nach 30 Anfragen pro Minute kommt 429).
  • Rückweg: Komponente deaktivieren, replicas: 1.

Self-Review

Abdeckung Spec → Tasks

Spec-Abschnitt Tasks
§2 Laufrollen, Einstiege, WorkerModule, Health, Jobs, JobRun, Migration als initContainer 1, 5, 6, 15, 16, 18, 21, 26
§3.1 DomainEvent/DomainEventDelivery 2
§3.2 Schreiben in der Transaktion, Audit mit tx 3, 9, 11–14
§3.3 Verteilen (SKIP LOCKED, Backoff, FAILED, Stale, NOTIFY + 5 s) 4, 5, 6
§3.4 Katalog (Erzeuger und erste Handler) und zeitbasierte Ereignisse 3, 7, 8, 10, 11–14, 18
§3.5 Umstellung, Kalender aus Handlern, WebhookDelivery, Aufgaben über TasksService 7, 8, 9, 10, 17
§4 Mehrfach-Sicherheit (Backup, Idempotenz, Vorlagen, Throttler) 15, 16, 17, 19
§5.1 Schlüssel, Schlüsselbund, rekey, Startabbruch 20, 21
§5.2 NetworkPolicies 26
§5.3 Pod-Härtung, Digest, kein latest 26
§5.4 Cache-Kopf, publicLocationLabel 22 (Cloudflare: Runbook)
§5.5 Getrennte Shop-Schlüssel und UI-Hinweis 23
§6 Kustomize, Overlay, Rollout-Reihenfolge 26 und Runbook
§7 Ressourcen und HPA 26
§8 Fehlerfälle (Worker aus, Handler wirft, Ziel weg, CronJob scheitert, 2 API-Pods, Migration scheitert) 4, 7, 15, 19, 24, 25, 26
§9 Tests (Unit, e2e, Wächter, Manifeste) 1, 3, 4, 11, 17, 19, 20, 21, 26

Bewusste Entscheidungen und Abweichungen (bitte beim Review bestätigen)

  • Zusätzliche Katalog-Ereignisse: VarroaCorrected, FeedingCorrected, HarvestCorrected, TaskUpdated, BottlingCorrected. Sie halten das Muster Recorded/Corrected einheitlich, und Kalender sowie Automatik reagieren heute auch auf Änderungen.
  • Neue Webhook-Ereignisse: varroa.measured, treatment.created, feeding.created. Spec §3.4 nennt dafür einen Webhook-Handler.
  • InspectionCorrected ohne Automatik: wie heute keine Regelauswertung bei einer Korrektur.
  • Handler schreiben nur in die DB:
    • Kalender-„Abmelden“ heißt jetzt erneut einreihen; der Worker löscht das Graph-Event.
    • „Jetzt synchronisieren“ weckt nur noch den Worker.
  • Schlüssel-ID: Fingerabdruck k<8 hex> statt fortlaufend k1. Zwei verschiedene Schlüssel können so nie dieselbe ID tragen.
  • Herkunftsland: neues Feld SiteSettings.originCountry (Standard „Deutschland“, bearbeitbar), weil es kein Land-Feld gab.
    • Die öffentliche Antwort enthält auch den Stand-Namen nicht mehr, denn das Frontend fiel bisher auf ihn zurück.
  • app-backup: Der CronJob läuft stündlich und prüft den Zeitplan aus GUI oder Env. So bleibt der Zeitplan in der GUI bearbeitbar, ohne Kubernetes-API.
  • Bestand: StockChanged trägt zusätzlich rawMaterialKinds. Damit wird auch die Rohstoff-Mindestmenge („Mindestbestand“) zum Handler.
  • Berechtigungen:
    • backup:manage und automation:manage aus Spec §8 gibt es nicht. Sie werden auf backup:configure und automation:update abgebildet.
    • Neu sind system:read und system:manage.
  • Bleiben bewusst auf runAfterCommit, weil sie nicht im Katalog der ersten Stufe stehen: colony.created, Königinnen, Honigchargen, Lager (StockItem), Schwarmkontrolle. Der Wächter in Task 17 hält diese Liste fest.
  • Nicht in diesem Plan:
    • Spec §4 „Nummernkreise: Jahreswechsel“ und „NotificationRead je Nutzer“, laut Spec eigene Fixes bzw. T4.
    • Spec §10 komplett.