# Betriebsmodus und Betriebsbereitschaft — Umsetzungsplan

> **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:** Ein Test/Live-Umschalter, der je Zugangsdatum zwei Plätze verwaltet, und eine Konsolenseite, die jedes fehlende Pflichtfeld benennt, bevor ein Kunde bezahlt.

**Architecture:** `platform.mode` liegt in `Settings` (sofort wirksam, auch in laufenden Warteschlangen-Prozessen) und wird ausschließlich über das Enum `App\Support\OperatingMode` gelesen. Der Tresor bleibt eine kuratierte Liste; nur seine Zeilenschlüssel werden zu `{key}:{mode}` zweigeteilt, und `get()` löst mit Rückfall auf Live auf — außer bei Stripe, das ein `strict`-Merkmal trägt und in beide Richtungen nicht zurückfällt. `App\Support\Readiness` sammelt alle Pflichtfeldprüfungen an einer Stelle und berichtet nur; die harten Sperren bleiben im Ablauf, wo sie heute schon stehen.

**Tech Stack:** PHP 8.3, Laravel 13.8, Livewire 3, Pest 4.7, MariaDB, Tailwind v3, Docker Compose.

## Global Constraints

- **Vollfassung des Entwurfs:** `docs/superpowers/specs/2026-07-30-betriebsmodus-und-betriebsbereitschaft-design.md`. Bei Widerspruch gewinnt die Spec.
- **Commit-Disziplin, nicht verhandelbar.** Eine zweite Claude-Session arbeitet im selben Arbeitsverzeichnis und im selben Git-Index. **Immer** `git add -- <pfade>` und `git commit -F - -- <pfade>`. **Nie** `git add -A`, `git add .`, `git commit -a` oder ein nacktes `git commit`.
- **`pint` nur auf eigene Pfade, nie `--dirty`.** Das Repo ist unter der Standardvorgabe nicht durchgängig formatiert.
- **R22:** eine Prüfrunde, eine Fix-Runde, ein Re-Review über den Fix-Diff. Danach wird ein offener Befund geparkt.
- **R23:** kein `wire:confirm`, kein `confirm(` in JavaScript. Bestätigung im Modal.
- **R24:** jedes Modal mit einem Eingabefeld benutzt `<x-ui.modal>`.
- **R18:** Icons `size-4` in Tabellen und Buttons, `size-5` in der Navigation, einzeilig.
- **R19:** jede angezeigte Zeit geht durch `->local()`.
- **Tests laufen im Container:** `docker compose exec -T app php artisan test`. SQLite im Speicher, erzwungen von `phpunit.xml` — gleichzeitige Läufe kollidieren nicht.
- **Pest-Falle:** `toThrow(SomeInterface::class)` beweist nichts. Immer eine **konkrete Klasse** erwarten.
- **Bricht die ganze Suite auf einmal zusammen**, ist das fast immer eine Datei der anderen Session mitten im Schreiben — einmal neu laufen lassen, bevor man sucht.

---

## Dateistruktur

| Datei | Verantwortung |
|---|---|
| `app/Support/OperatingMode.php` | **neu** — Enum `Test`/`Live` und `current()`. Die einzige Stelle, die den String kennt. |
| `app/Services/Secrets/SecretVault.php` | Zeilenschlüssel je Modus, `strict`-Merkmal, Auflösungsregel. |
| `app/Services/Stripe/HttpStripeClient.php` | `secret()` wirft statt tokenlos zu senden. |
| `app/Exceptions/StripeNotConfigured.php` | **neu** — konkrete Ausnahme, damit Tests sie greifen können. |
| `app/Http/Controllers/StripeWebhookController.php` | Webhook-Schlüssel nach Modus. |
| `app/Livewire/Billing.php` | Kauf abweisen, wenn dem aktiven Modus der Stripe-Schlüssel fehlt. |
| `app/Support/Readiness.php` | **neu** — Sammler. Kennt keine einzelne Prüfung, nur die Liste. |
| `app/Support/Readiness/Check.php` | **neu** — ein Befund: Schlüssel, Gruppe, Grad, Text, Reiter. |
| `app/Support/Readiness/BillingChecks.php` | **neu** — Gruppe Abrechnung. |
| `app/Support/Readiness/OnboardingChecks.php` | **neu** — Gruppe Host-Onboarding. |
| `app/Support/Readiness/ProvisioningChecks.php` | **neu** — Gruppe Bereitstellung. |
| `app/Support/Readiness/DeliveryChecks.php` | **neu** — Gruppe Zustellung. |
| `app/Support/Readiness/OperationChecks.php` | **neu** — Gruppe Betrieb (Herzschläge). |
| `app/Services/Dns/DnsTokenCheck.php` | **neu** — Schreibrecht auf der Zone. |
| `app/Services/Vpn/WireguardEndpointCheck.php` | **neu** — Endpunkt ist öffentlich. |
| `app/Services/Proxmox/VmTemplateCheck.php` | **neu** — Vorlage liegt auf einem aktiven Host. |
| `app/Livewire/Admin/Readiness.php` | **neu** — die Seite. |
| `app/Livewire/Admin/ConfirmSwitchMode.php` | **neu** — Bestätigung nach R23. |
| `app/Livewire/Admin/Integrations.php` | Umschalter, zwei Felder je Eintrag. |
| `routes/console.php` | Zwei Herzschläge. |

Die Prüfgruppen sind **fünf kleine Dateien statt einer großen**: jede Gruppe hat eigene Abhängigkeiten (Stripe, Proxmox, DNS, Mail), und eine Datei, die alle vier importiert, wäre in jedem Test die ganze Welt.

---

### Task 1: Der Betriebsmodus als Enum

**Files:**
- Create: `app/Support/OperatingMode.php`
- Test: `tests/Feature/OperatingModeTest.php`

**Interfaces:**
- Consumes: `App\Support\Settings` (existiert).
- Produces: `OperatingMode::current(): OperatingMode`, Fälle `OperatingMode::Test` und `OperatingMode::Live`, `->value` als `'test'`/`'live'`, `OperatingMode::set(OperatingMode $mode): void`.

- [ ] **Step 1: Den fehlschlagenden Test schreiben**

```php
<?php

use App\Support\OperatingMode;
use App\Support\Settings;

/**
 * Der Modus entscheidet, welche Zugangsdaten gelten. Er wird an genau einer
 * Stelle gelesen, damit nirgends sonst ein String verglichen wird — ein
 * Tippfehler in einem Vergleich wäre sonst ein stiller Wechsel auf Live.
 */
it('is live when nothing has been stored', function () {
    expect(OperatingMode::current())->toBe(OperatingMode::Live);
});

it('reads the stored mode', function () {
    Settings::set('platform.mode', 'test');

    expect(OperatingMode::current())->toBe(OperatingMode::Test);
});

it('falls back to live when the stored value is not a mode', function () {
    // Eine Zeile, die von Hand oder von einer alten Fassung geschrieben wurde.
    // Live ist die sichere Richtung: eine echte Zahlung wird korrekt behandelt,
    // ein Testereignis scheitert laut.
    Settings::set('platform.mode', 'staging');

    expect(OperatingMode::current())->toBe(OperatingMode::Live);
});

it('writes the mode and takes effect immediately', function () {
    OperatingMode::set(OperatingMode::Test);

    expect(OperatingMode::current())->toBe(OperatingMode::Test);
});
```

- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen**

Run: `docker compose exec -T app php artisan test --filter=OperatingModeTest`
Expected: FAIL mit `Class "App\Support\OperatingMode" not found`

- [ ] **Step 3: Das Enum schreiben**

```php
<?php

namespace App\Support;

/**
 * Test oder Live — welcher Satz Zugangsdaten gilt.
 *
 * In `Settings` und nicht in der `.env`, weil `Settings` seinen Zwischenspeicher
 * beim Schreiben verwirft: ein Umlegen wirkt sofort, auch in den
 * Warteschlangen-Prozessen, die seit Stunden laufen. Eine `.env`-Variable
 * bräuchte einen Containerneustart — genau die Falle, die am 2026-07-30 eine
 * korrigierte DNS-Zone acht Stunden lang wirkungslos gelassen hat.
 *
 * Live ist die Vorgabe. Eine Installation, die nichts gespeichert hat, soll
 * nicht in einem Modus stehen, der stillschweigend andere Zugangsdaten benutzt.
 */
enum OperatingMode: string
{
    case Test = 'test';
    case Live = 'live';

    public const SETTING = 'platform.mode';

    public static function current(): self
    {
        // tryFrom, nicht from: ein unbekannter Wert in der Zeile ist ein
        // Datenfehler und darf nicht jede Anfrage in eine Ausnahme werfen.
        return self::tryFrom((string) Settings::get(self::SETTING, self::Live->value))
            ?? self::Live;
    }

    public static function set(self $mode): void
    {
        Settings::set(self::SETTING, $mode->value);
    }

    public function isTest(): bool
    {
        return $this === self::Test;
    }
}
```

- [ ] **Step 4: Test laufen lassen, Erfolg bestätigen**

Run: `docker compose exec -T app php artisan test --filter=OperatingModeTest`
Expected: PASS, 4 Tests

- [ ] **Step 5: Committen**

```bash
git add -- app/Support/OperatingMode.php tests/Feature/OperatingModeTest.php
git commit -F - -- app/Support/OperatingMode.php tests/Feature/OperatingModeTest.php <<'EOF'
Give the installation a test mode and a live mode

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
```

---

### Task 2: Der Tresor bekommt zwei Plätze je Eintrag

**Files:**
- Modify: `app/Services/Secrets/SecretVault.php`
- Test: `tests/Feature/SecretVaultModeTest.php`

**Interfaces:**
- Consumes: `OperatingMode::current()` aus Task 1.
- Produces: `SecretVault::get(string $key): ?string` — **Signatur unverändert**, der Modus wird innen aufgelöst. `put(string $key, string $value, Operator $by, ?OperatingMode $mode = null)`, `forget(string $key, ?OperatingMode $mode = null)`, `source(string $key, ?OperatingMode $mode = null)`, `outline(string $key, ?OperatingMode $mode = null)`, `updatedAt(string $key, ?OperatingMode $mode = null)`. `null` heißt „aktiver Modus".

**Wichtig:** `get()` behält seine Signatur mit Absicht. Die Aufrufer (`HttpStripeClient:367`, `StripeCheck:26`, `SshTraefikWriter:45`) bleiben unverändert — nur die Konsole muss beide Plätze kennen.

- [ ] **Step 1: Den fehlschlagenden Test schreiben**

```php
<?php

use App\Models\Operator;
use App\Services\Secrets\SecretVault;
use App\Support\OperatingMode;

/**
 * Zwei Plätze je Zugangsdatum, und eine Rückfallregel, die für vier von fünf
 * Einträgen richtig ist: es gibt bei Hetzner, Uptime Kuma und SSH nur ein
 * Konto, und zwei Felder mit zwingend gleichem Inhalt wären eine Attrappe.
 */
beforeEach(function () {
    $this->by = Operator::factory()->create();
    $this->vault = app(SecretVault::class);
});

it('reads the slot of the active mode', function () {
    $this->vault->put('dns.token', 'live-token', $this->by, OperatingMode::Live);
    $this->vault->put('dns.token', 'test-token', $this->by, OperatingMode::Test);

    OperatingMode::set(OperatingMode::Test);
    expect($this->vault->get('dns.token'))->toBe('test-token');

    OperatingMode::set(OperatingMode::Live);
    expect($this->vault->get('dns.token'))->toBe('live-token');
});

it('falls back to the live slot when the test slot is empty', function () {
    // Die Regel des Inhabers: wo es keinen Testzugang gibt, wird der echte
    // benutzt, damit der Testbetrieb überhaupt arbeiten kann.
    $this->vault->put('dns.token', 'live-token', $this->by, OperatingMode::Live);
    OperatingMode::set(OperatingMode::Test);

    expect($this->vault->get('dns.token'))->toBe('live-token');
});

it('does not fall back from live to test', function () {
    // Die andere Richtung wäre absurd: im Livebetrieb einen Testzugang zu
    // benutzen, weil der echte fehlt.
    $this->vault->put('dns.token', 'test-token', $this->by, OperatingMode::Test);
    OperatingMode::set(OperatingMode::Live);

    expect($this->vault->get('dns.token'))->toBeNull();
});

it('still falls back to the environment when neither slot is filled', function () {
    // Der Weg für eine Installation, in der noch gar nichts gespeichert ist.
    // Einwertig und modusfrei — den Anfangszustand gibt es nur einmal.
    config()->set('provisioning.dns.token', 'from-env');

    expect($this->vault->get('dns.token'))->toBe('from-env');
});

it('reports the source per slot', function () {
    $this->vault->put('dns.token', 'live-token', $this->by, OperatingMode::Live);

    expect($this->vault->source('dns.token', OperatingMode::Live))->toBe('stored');
    expect($this->vault->source('dns.token', OperatingMode::Test))->toBe('none');
});

it('forgets one slot without touching the other', function () {
    $this->vault->put('dns.token', 'live-token', $this->by, OperatingMode::Live);
    $this->vault->put('dns.token', 'test-token', $this->by, OperatingMode::Test);

    $this->vault->forget('dns.token', OperatingMode::Test);

    expect($this->vault->source('dns.token', OperatingMode::Test))->toBe('none');
    expect($this->vault->source('dns.token', OperatingMode::Live))->toBe('stored');
});
```

- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen**

Run: `docker compose exec -T app php artisan test --filter=SecretVaultModeTest`
Expected: FAIL — `put()` nimmt noch kein viertes Argument

- [ ] **Step 3: Den Tresor umbauen**

In `app/Services/Secrets/SecretVault.php`. Der Zeilenschlüssel wird zweigeteilt, alles andere bleibt:

```php
/** Der Zeilenschlüssel im Speicher: ein Eintrag hat zwei Plätze. */
private function rowKey(string $key, ?OperatingMode $mode = null): string
{
    return $key.':'.($mode ?? OperatingMode::current())->value;
}

private function row(string $key, ?OperatingMode $mode = null): ?object
{
    return DB::table('app_secrets')->where('key', $this->rowKey($key, $mode))->first();
}
```

`get()` bekommt die Auflösungsregel:

```php
public function get(string $key): ?string
{
    $this->assertKnown($key);

    $mode = OperatingMode::current();
    $row = $this->row($key, $mode);

    // Rückfall auf Live, wenn der Platz des aktiven Modus leer ist. NUR von
    // Test auf Live, nie umgekehrt: im Livebetrieb einen Testzugang zu
    // benutzen, weil der echte fehlt, wäre die gefährliche Richtung.
    if ($row === null && $mode->isTest() && ! $this->isStrict($key)) {
        $row = $this->row($key, OperatingMode::Live);
    }

    if ($row === null) {
        // Strikte Einträge sehen die Umgebung nicht: siehe isStrict().
        if ($this->isStrict($key)) {
            return null;
        }

        $configured = config(self::REGISTRY[$key]['config']);

        return $configured === null ? null : (string) $configured;
    }

    try {
        return app(SecretCipher::class)->decrypt($row->value);
    } catch (DecryptException $e) {
        Log::error('A stored secret could not be decrypted', ['key' => $key]);

        throw new RuntimeException("Stored secret [{$key}] cannot be decrypted.", previous: $e);
    }
}

/** Trägt dieser Eintrag das strict-Merkmal? Gefüllt in Task 3. */
private function isStrict(string $key): bool
{
    return (bool) (self::REGISTRY[$key]['strict'] ?? false);
}
```

`put()`, `forget()`, `source()`, `outline()`, `updatedAt()` bekommen je einen
`?OperatingMode $mode = null`-Parameter und reichen ihn an `rowKey()`/`row()`
durch. In `put()` wird `DB::table('app_secrets')->updateOrInsert(['key' => $this->rowKey($key, $mode)], $attributes)`.

- [ ] **Step 4: Test laufen lassen, Erfolg bestätigen**

Run: `docker compose exec -T app php artisan test --filter=SecretVaultModeTest`
Expected: PASS, 6 Tests

- [ ] **Step 5: Die bestehende Tresor-Suite laufen lassen**

Run: `docker compose exec -T app php artisan test --filter=Secret`
Expected: PASS — `get()` hat seine Signatur behalten, kein Aufrufer ändert sich

- [ ] **Step 6: Committen**

```bash
git add -- app/Services/Secrets/SecretVault.php tests/Feature/SecretVaultModeTest.php
git commit -F - -- app/Services/Secrets/SecretVault.php tests/Feature/SecretVaultModeTest.php <<'EOF'
Give every credential a test slot beside its live one

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
```

---

### Task 3: Stripe fällt nicht zurück

**Files:**
- Modify: `app/Services/Secrets/SecretVault.php` (Registry-Eintrag)
- Test: `tests/Feature/StripeStrictModeTest.php`

**Interfaces:**
- Consumes: `isStrict()` aus Task 2.
- Produces: `SecretVault::REGISTRY['stripe.secret']['strict'] === true`.

**Das ist die wichtigste Zeile dieses Plans.** Ohne sie bucht ein Testkauf bei
fehlendem Testschlüssel **echtes Geld** ab, während die Konsole „Testbetrieb"
anzeigt.

- [ ] **Step 1: Den fehlschlagenden Test schreiben**

```php
<?php

use App\Models\Operator;
use App\Services\Secrets\SecretVault;
use App\Support\OperatingMode;

/**
 * Stripe ist vom Rückfall ausgenommen — in BEIDE Richtungen.
 *
 * Fiele der Testbetrieb bei fehlendem Testschlüssel auf den Live-Schlüssel
 * zurück, bucht ein Testkauf echtes Geld ab, während die Konsole „Testbetrieb
 * aktiv" anzeigt. Das ist die eine Stelle in diesem Vorhaben, deren Ausfall
 * Geld kostet, und deshalb steht sie als eigener Test da statt als Kommentar.
 */
beforeEach(function () {
    $this->by = Operator::factory()->create();
    $this->vault = app(SecretVault::class);
});

it('does not use the live key while the test mode is active', function () {
    $this->vault->put('stripe.secret', 'sk_live_real', $this->by, OperatingMode::Live);
    OperatingMode::set(OperatingMode::Test);

    expect($this->vault->get('stripe.secret'))->toBeNull();
});

it('does not use the test key while the live mode is active', function () {
    $this->vault->put('stripe.secret', 'sk_test_fake', $this->by, OperatingMode::Test);
    OperatingMode::set(OperatingMode::Live);

    expect($this->vault->get('stripe.secret'))->toBeNull();
});

it('ignores the environment for stripe as well', function () {
    // Sonst wäre die .env die Hintertür, durch die der Live-Schlüssel doch in
    // den Testbetrieb kommt.
    config()->set('services.stripe.secret', 'sk_live_from_env');
    OperatingMode::set(OperatingMode::Test);

    expect($this->vault->get('stripe.secret'))->toBeNull();
});

it('uses the key of the active mode when it is there', function () {
    $this->vault->put('stripe.secret', 'sk_test_fake', $this->by, OperatingMode::Test);
    OperatingMode::set(OperatingMode::Test);

    expect($this->vault->get('stripe.secret'))->toBe('sk_test_fake');
});

it('lets every other entry keep falling back', function () {
    // Die Ausnahme ist eine Ausnahme, keine neue Regel.
    $this->vault->put('dns.token', 'live-token', $this->by, OperatingMode::Live);
    OperatingMode::set(OperatingMode::Test);

    expect($this->vault->get('dns.token'))->toBe('live-token');
});
```

- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen**

Run: `docker compose exec -T app php artisan test --filter=StripeStrictModeTest`
Expected: FAIL — die ersten drei Tests, weil noch zurückgefallen wird

- [ ] **Step 3: Das Merkmal setzen**

In `SecretVault::REGISTRY`, Eintrag `stripe.secret`:

```php
'stripe.secret' => [
    'config' => 'services.stripe.secret',
    'label' => 'secrets.item.stripe_secret',
    'check' => StripeCheck::class,
    'env_key' => 'STRIPE_SECRET',
    // Kein Rückfall, in keine Richtung, und auch nicht auf die Umgebung.
    // Ohne das benutzt ein Testkauf bei fehlendem Testschlüssel still den
    // Live-Schlüssel und bucht echtes Geld ab, während die Konsole
    // „Testbetrieb aktiv" anzeigt. Jeder andere Eintrag hier bezeichnet
    // dasselbe Konto in beiden Modi; dieser nicht.
    'strict' => true,
],
```

- [ ] **Step 4: Test laufen lassen, Erfolg bestätigen**

Run: `docker compose exec -T app php artisan test --filter=StripeStrictModeTest`
Expected: PASS, 5 Tests

- [ ] **Step 5: Committen**

```bash
git add -- app/Services/Secrets/SecretVault.php tests/Feature/StripeStrictModeTest.php
git commit -F - -- app/Services/Secrets/SecretVault.php tests/Feature/StripeStrictModeTest.php <<'EOF'
Never spend real money while the switch says test

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
```

---

### Task 4: Migration — bestehende Zeilen einsortieren, ohne zu raten

**Files:**
- Create: `database/migrations/2026_08_01_090000_give_every_secret_a_test_slot.php`
- Test: `tests/Feature/SecretSlotMigrationTest.php`

**Interfaces:**
- Consumes: `app_secrets.key`, `OperatingMode::SETTING`.
- Produces: keine neue Schnittstelle. Nach der Migration trägt jede Zeile einen Modus im Schlüssel.

- [ ] **Step 1: Den fehlschlagenden Test schreiben**

```php
<?php

use App\Support\OperatingMode;
use App\Support\Settings;
use Illuminate\Support\Facades\DB;

/**
 * Die Migration rät nicht.
 *
 * Der Stripe-Schlüssel sagt selbst, wohin er gehört: `sk_test_` ist ein
 * Testschlüssel, unabhängig davon, in welchem Feld er gerade liegt. Ihn stumpf
 * als „Live" einzusortieren, würde eine Installation, die erkennbar im
 * Testbetrieb läuft, beim ersten Kauf echtes Geld abbuchen lassen.
 */
function runSlotMigration(): void
{
    (require base_path('database/migrations/2026_08_01_090000_give_every_secret_a_test_slot.php'))->up();
}

it('files a stripe test key in the test slot and switches the mode', function () {
    DB::table('app_secrets')->insert([
        'key' => 'stripe.secret',
        'value' => encrypt('sk_test_abc'),
        'created_at' => now(), 'updated_at' => now(),
    ]);

    runSlotMigration();

    expect(DB::table('app_secrets')->where('key', 'stripe.secret:test')->exists())->toBeTrue();
    expect(DB::table('app_secrets')->where('key', 'stripe.secret:live')->exists())->toBeFalse();
    expect(Settings::get(OperatingMode::SETTING))->toBe('test');
});

it('files a stripe live key in the live slot and leaves the mode alone', function () {
    DB::table('app_secrets')->insert([
        'key' => 'stripe.secret',
        'value' => encrypt('sk_live_abc'),
        'created_at' => now(), 'updated_at' => now(),
    ]);

    runSlotMigration();

    expect(DB::table('app_secrets')->where('key', 'stripe.secret:live')->exists())->toBeTrue();
    expect(OperatingMode::current())->toBe(OperatingMode::Live);
});

it('files every other credential in the live slot', function () {
    DB::table('app_secrets')->insert([
        'key' => 'dns.token',
        'value' => encrypt('whatever'),
        'created_at' => now(), 'updated_at' => now(),
    ]);

    runSlotMigration();

    expect(DB::table('app_secrets')->where('key', 'dns.token:live')->exists())->toBeTrue();
    expect(DB::table('app_secrets')->where('key', 'dns.token')->exists())->toBeFalse();
});

it('leaves an installation with no stripe key on live', function () {
    runSlotMigration();

    expect(OperatingMode::current())->toBe(OperatingMode::Live);
});
```

- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen**

Run: `docker compose exec -T app php artisan test --filter=SecretSlotMigrationTest`
Expected: FAIL — die Migrationsdatei existiert nicht

- [ ] **Step 3: Die Migration schreiben**

Der entschlüsselte Wert wird gebraucht, um das Präfix zu lesen. Die Migration
benutzt denselben Chiffrierer wie der Tresor; scheitert die Entschlüsselung,
wandert die Zeile nach `:live` — das ist der Zustand vor dieser Migration und
damit die Fortsetzung des Status quo, nicht eine neue Behauptung.

```php
<?php

use App\Services\Secrets\SecretCipher;
use App\Support\OperatingMode;
use App\Support\Settings;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;

/**
 * Jedes Zugangsdatum bekommt zwei Plätze — und die vorhandene Zeile wird
 * einsortiert statt umbenannt.
 *
 * Für vier der fünf Einträge ist die Antwort dieselbe: was da liegt, wurde für
 * den echten Betrieb hinterlegt, also `:live`. Nur Stripe kann es selbst sagen,
 * weil sein Schlüssel sein Präfix trägt — und daran hängt mehr als eine Zeile:
 * liegt dort ein Testschlüssel, war diese Installation erkennbar im
 * Testbetrieb, und der Modus folgt daraus statt aus einer Vorgabe.
 */
return new class extends Migration
{
    public function up(): void
    {
        $stripeMode = null;

        foreach (DB::table('app_secrets')->get() as $row) {
            // Schon einsortiert (etwa bei einem zweiten Lauf): unangetastet.
            if (Str::contains($row->key, ':')) {
                continue;
            }

            $mode = OperatingMode::Live;

            if ($row->key === 'stripe.secret') {
                $mode = $this->stripeModeOf($row->value);
                $stripeMode = $mode;
            }

            DB::table('app_secrets')
                ->where('id', $row->id)
                ->update(['key' => $row->key.':'.$mode->value]);
        }

        // Nur wenn ein Stripe-Schlüssel da war. Ohne ihn bleibt die Vorgabe
        // stehen, und die ist live.
        if ($stripeMode !== null) {
            Settings::set(OperatingMode::SETTING, $stripeMode->value);
        }
    }

    private function stripeModeOf(string $stored): OperatingMode
    {
        try {
            $secret = app(SecretCipher::class)->decrypt($stored);
        } catch (\Throwable) {
            // Unlesbar heißt: wir wissen es nicht. `:live` ist der Zustand vor
            // dieser Migration, also die Fortsetzung des Status quo statt einer
            // neuen Behauptung. Die Bereitschaftsseite meldet ihn ohnehin.
            return OperatingMode::Live;
        }

        return Str::contains($secret, '_test_') ? OperatingMode::Test : OperatingMode::Live;
    }

    public function down(): void
    {
        foreach (DB::table('app_secrets')->get() as $row) {
            if (! Str::endsWith($row->key, [':live', ':test'])) {
                continue;
            }

            $bare = Str::beforeLast($row->key, ':');

            // Eine Zeile je Eintrag kann zurück; die zweite hat im alten Schema
            // keinen Platz und würde am Eindeutigkeitsindex scheitern.
            if (DB::table('app_secrets')->where('key', $bare)->exists()) {
                DB::table('app_secrets')->where('id', $row->id)->delete();

                continue;
            }

            DB::table('app_secrets')->where('id', $row->id)->update(['key' => $bare]);
        }

        Settings::forget(OperatingMode::SETTING);
    }
};
```

- [ ] **Step 4: Test laufen lassen, Erfolg bestätigen**

Run: `docker compose exec -T app php artisan test --filter=SecretSlotMigrationTest`
Expected: PASS, 4 Tests

- [ ] **Step 5: Auf dem Entwicklungsserver laufen lassen und nachsehen**

Nicht „grün heißt fertig". Die Migration fasst echte Zugangsdaten an.

```bash
docker compose exec -T app php artisan migrate --force
```

Dann prüfen, dass der vorhandene `sk_test_`-Schlüssel im Testplatz gelandet ist
und der Modus auf `test` steht:

```bash
docker compose exec -T app php artisan tinker --execute='
foreach(\Illuminate\Support\Facades\DB::table("app_secrets")->pluck("key") as $k) echo "  $k\n";
echo "Modus: ".\App\Support\OperatingMode::current()->value."\n";'
```

Expected: `stripe.secret:test`, alle übrigen auf `:live`, `Modus: test`

- [ ] **Step 6: Committen**

```bash
git add -- database/migrations/2026_08_01_090000_give_every_secret_a_test_slot.php tests/Feature/SecretSlotMigrationTest.php
git commit -F - -- database/migrations/2026_08_01_090000_give_every_secret_a_test_slot.php tests/Feature/SecretSlotMigrationTest.php <<'EOF'
Let the stored key say which mode it belongs to

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
```

---

### Task 5: Ohne Schlüssel wird nicht verkauft

**Files:**
- Create: `app/Exceptions/StripeNotConfigured.php`
- Modify: `app/Services/Stripe/HttpStripeClient.php:367`
- Modify: `app/Livewire/Billing.php` (`purchase()` bei Zeile 61)
- Test: `tests/Feature/CheckoutWithoutStripeKeyTest.php`

**Interfaces:**
- Consumes: `SecretVault::get('stripe.secret')` aus Task 3 (liefert `null`).
- Produces: `App\Exceptions\StripeNotConfigured` (konkrete Klasse — eine Schnittstelle im `toThrow()` bewiese nichts).

- [ ] **Step 1: Den fehlschlagenden Test schreiben**

```php
<?php

use App\Exceptions\StripeNotConfigured;
use App\Livewire\Billing;
use App\Models\Customer;
use App\Models\Order;
use App\Models\User;
use App\Services\Stripe\HttpStripeClient;
use App\Support\OperatingMode;
use Livewire\Livewire;

/**
 * Fehlt der Schlüssel des aktiven Modus, entsteht kein Auftrag — und der Kunde
 * liest einen Satz statt einer 500er-Seite.
 *
 * Die Ausnahme ist eine KONKRETE Klasse. `toThrow(Throwable::class)` würde hier
 * jeden beliebigen Fehler durchgehen lassen, auch einen Tippfehler im Test.
 */
it('refuses to reach stripe without a key for the active mode', function () {
    OperatingMode::set(OperatingMode::Test);

    expect(fn () => app(HttpStripeClient::class)->createPrice('prod_x', 100, 'EUR', 'month'))
        ->toThrow(StripeNotConfigured::class);
});

it('does not create an order when the key is missing', function () {
    OperatingMode::set(OperatingMode::Test);
    $customer = Customer::factory()->create();
    $user = User::factory()->for($customer)->create();

    Livewire::actingAs($user)
        ->test(Billing::class)
        ->call('purchase', 'upgrade', 'test')
        ->assertHasNoErrors();

    // Der Punkt: nach der Zahlung wäre es zu spät. Es darf gar nichts entstehen.
    expect(Order::count())->toBe(0);
});

it('says why instead of failing silently', function () {
    OperatingMode::set(OperatingMode::Test);
    $customer = Customer::factory()->create();
    $user = User::factory()->for($customer)->create();

    Livewire::actingAs($user)
        ->test(Billing::class)
        ->call('purchase', 'upgrade', 'test')
        ->assertSee(__('billing.stripe_not_configured'));
});
```

- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen**

Run: `docker compose exec -T app php artisan test --filter=CheckoutWithoutStripeKeyTest`
Expected: FAIL mit `Class "App\Exceptions\StripeNotConfigured" not found`

- [ ] **Step 3: Ausnahme und Sperre schreiben**

`app/Exceptions/StripeNotConfigured.php`:

```php
<?php

namespace App\Exceptions;

use RuntimeException;

/**
 * Der aktive Betriebsmodus hat keinen Stripe-Schlüssel.
 *
 * Eine eigene Klasse, damit Aufrufer und Tests genau diesen Fall greifen können
 * statt „irgendetwas ging schief" — und damit die Kasse ihn in einen Satz
 * übersetzen kann, den ein Kunde lesen kann.
 */
class StripeNotConfigured extends RuntimeException
{
    public static function forMode(string $mode): self
    {
        return new self("No Stripe secret is stored for the [{$mode}] operating mode.");
    }
}
```

In `HttpStripeClient`, die Methode bei Zeile 367:

```php
private function secret(): string
{
    $secret = app(SecretVault::class)->get('stripe.secret');

    // Ohne Token abzusenden hieße, Stripe mit einem leeren Bearer zu fragen
    // und einen 401 zu bekommen, den niemand einem fehlenden Schlüssel
    // zuordnet. Hier abzubrechen sagt, was fehlt.
    if (blank($secret)) {
        throw StripeNotConfigured::forMode(OperatingMode::current()->value);
    }

    return $secret;
}
```

In `app/Livewire/Billing.php`, ganz am Anfang von `purchase()` — vor
`requireCustomer()`, damit gar nichts angefasst wird:

```php
public function purchase(string $type, ?string $key = null, int $quantity = 1): void
{
    // Vor allem anderen: ohne Schlüssel des aktiven Modus entsteht kein
    // Auftrag. Nach der Zahlung wäre es zu spät, und ein halb angelegter
    // Auftrag ohne Stripe-Sitzung ist genau die Leiche, die niemand findet.
    if (blank(app(SecretVault::class)->get('stripe.secret'))) {
        $this->addError('purchase', __('billing.stripe_not_configured'));

        return;
    }

    $customer = $this->requireCustomer();
    // ... unverändert weiter
```

In `lang/de/billing.php` (und `lang/en/billing.php`, falls vorhanden):

```php
'stripe_not_configured' => 'Die Bezahlung ist derzeit nicht eingerichtet. Bitte versuchen Sie es später erneut.',
```

Der Kunde erfährt bewusst **nicht**, dass ein Schlüssel fehlt oder welcher
Betriebsmodus läuft — das ist eine Betreiberangelegenheit und steht auf der
Bereitschaftsseite.

- [ ] **Step 4: Test laufen lassen, Erfolg bestätigen**

Run: `docker compose exec -T app php artisan test --filter=CheckoutWithoutStripeKeyTest`
Expected: PASS, 3 Tests

- [ ] **Step 5: Die Kassen-Suite laufen lassen**

Run: `docker compose exec -T app php artisan test --filter=Billing`
Expected: PASS — bestehende Tests hinterlegen einen Schlüssel und laufen weiter

- [ ] **Step 6: Committen**

```bash
git add -- app/Exceptions/StripeNotConfigured.php app/Services/Stripe/HttpStripeClient.php app/Livewire/Billing.php lang/de/billing.php tests/Feature/CheckoutWithoutStripeKeyTest.php
git commit -F - -- app/Exceptions/StripeNotConfigured.php app/Services/Stripe/HttpStripeClient.php app/Livewire/Billing.php lang/de/billing.php tests/Feature/CheckoutWithoutStripeKeyTest.php <<'EOF'
Refuse the sale instead of reaching Stripe without a key

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
```

---

### Task 6: Der Webhook-Schlüssel folgt dem Modus

**Files:**
- Modify: `app/Http/Controllers/StripeWebhookController.php:23`
- Modify: `config/services.php:39`
- Modify: `.env.example`
- Test: `tests/Feature/StripeWebhookSecretByModeTest.php`

**Interfaces:**
- Consumes: `OperatingMode::current()`.
- Produces: `config('services.stripe.webhook_secret')` (live) und `config('services.stripe.webhook_secret_test')`.

Der Schlüssel bleibt in der `.env` — seine Begründung gilt unverändert: er wird
bei **jedem** eingehenden Zahlungsereignis gelesen, und ein Datenbankproblem
würde die Signaturprüfung still fehlschlagen lassen.

- [ ] **Step 1: Den fehlschlagenden Test schreiben**

```php
<?php

use App\Support\OperatingMode;
use App\Support\Settings;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

/**
 * Zwei Signaturschlüssel, weil Stripe je Modus einen eigenen vergibt.
 *
 * Der dritte Test ist der eigentliche Grund für diese Datei: die Auswahl liest
 * den Modus aus der Datenbank — aus genau der Quelle, die für diesen Wert
 * bewusst vermieden wurde. Fällt sie aus, muss die Auswahl auf LIVE fallen. Ein
 * echtes Zahlungsereignis wird dann weiter korrekt geprüft; ein Testereignis
 * scheitert laut. Die umgekehrte Vorgabe wäre die gefährliche.
 */
beforeEach(function () {
    config()->set('services.stripe.webhook_secret', 'whsec_live');
    config()->set('services.stripe.webhook_secret_test', 'whsec_test');
});

it('uses the live signing secret in live mode', function () {
    OperatingMode::set(OperatingMode::Live);

    expect(webhookSecret())->toBe('whsec_live');
});

it('uses the test signing secret in test mode', function () {
    OperatingMode::set(OperatingMode::Test);

    expect(webhookSecret())->toBe('whsec_test');
});

it('uses the live secret when the settings table cannot be read', function () {
    OperatingMode::set(OperatingMode::Test);

    // Der Ausfall, gegen den der Wert ursprünglich aus der Datenbank
    // herausgehalten wurde.
    Schema::drop('app_settings');

    expect(webhookSecret())->toBe('whsec_live');
});
```

Die Hilfsfunktion `webhookSecret()` gehört als `private static` auf den
Controller und wird im Test über eine kleine Brücke gerufen — in Pest genügt:

```php
function webhookSecret(): string
{
    return (new ReflectionMethod(\App\Http\Controllers\StripeWebhookController::class, 'signingSecret'))
        ->invoke(null);
}
```

- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen**

Run: `docker compose exec -T app php artisan test --filter=StripeWebhookSecretByModeTest`
Expected: FAIL — `signingSecret()` existiert nicht

- [ ] **Step 3: Auswahl einbauen**

`config/services.php`, im `stripe`-Block neben `webhook_secret`:

```php
'webhook_secret_test' => env('STRIPE_WEBHOOK_SECRET_TEST'),
```

`.env.example`, neben der bestehenden Zeile:

```
STRIPE_WEBHOOK_SECRET_TEST=
```

`StripeWebhookController`, Zeile 23 ersetzen:

```php
$secret = self::signingSecret();
```

und die Methode dazu:

```php
/**
 * Der Signaturschlüssel des aktiven Betriebsmodus.
 *
 * Beide bleiben in der Serverdatei, nicht im Tresor: dieser Wert wird bei jedem
 * eingehenden Zahlungsereignis gelesen, und ein Datenbankproblem würde die
 * Signaturprüfung still fehlschlagen lassen — die eine Fehlerart, die laut
 * bleiben muss.
 *
 * Die AUSWAHL fragt allerdings die Datenbank, was diese Abhängigkeit auf einem
 * Umweg wieder einführt. Entschärft durch die Richtung des Ausfalls:
 * `Settings::get()` liefert bei unerreichbarer Tabelle seinen Vorgabewert
 * `live`, also wird der Live-Schlüssel benutzt. Ein echtes Zahlungsereignis
 * wird weiter korrekt geprüft, ein Testereignis scheitert laut. Andersherum
 * wäre es die gefährliche Richtung.
 */
private static function signingSecret(): string
{
    return OperatingMode::current()->isTest()
        ? (string) config('services.stripe.webhook_secret_test')
        : (string) config('services.stripe.webhook_secret');
}
```

- [ ] **Step 4: Test laufen lassen, Erfolg bestätigen**

Run: `docker compose exec -T app php artisan test --filter=StripeWebhookSecretByModeTest`
Expected: PASS, 3 Tests

- [ ] **Step 5: Committen**

```bash
git add -- app/Http/Controllers/StripeWebhookController.php config/services.php .env.example tests/Feature/StripeWebhookSecretByModeTest.php
git commit -F - -- app/Http/Controllers/StripeWebhookController.php config/services.php .env.example tests/Feature/StripeWebhookSecretByModeTest.php <<'EOF'
Verify webhooks against the secret of the mode we are in

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
```

---

### Task 7: Der Sammler und die Gruppe Abrechnung

**Files:**
- Create: `app/Support/Readiness/Check.php`
- Create: `app/Support/Readiness/BillingChecks.php`
- Create: `app/Support/Readiness.php`
- Test: `tests/Feature/Readiness/BillingChecksTest.php`

**Interfaces:**
- Consumes: `SecretVault`, `CompanyProfile::missingForInvoicing()`, `OperatingMode`, `InvoiceSeries`, `PlanPrice`.
- Produces:
  - `Check::__construct(string $key, string $group, string $severity, string $label, string $breaks, string $tab, bool $satisfied)`
  - `Check::SEVERITY_BLOCKING = 'blocking'`, `Check::SEVERITY_WARNING = 'warning'`
  - `Readiness::all(): array<int, Check>`
  - `Readiness::blocking(): array<int, Check>` — nur unerfüllte mit Grad blocking
  - `Readiness::isReady(): bool`
  - `BillingChecks::all(): array<int, Check>`

- [ ] **Step 1: Den fehlschlagenden Test schreiben**

```php
<?php

use App\Models\Operator;
use App\Services\Secrets\SecretVault;
use App\Support\OperatingMode;
use App\Support\Readiness;
use App\Support\Readiness\Check;
use App\Support\Settings;

/**
 * Die Bereitschaftsprüfung berichtet, was fehlt — und sagt dazu, was
 * kaputtgeht. Ein Feldname allein ist keine Auskunft: „vat_id fehlt" sagt
 * nichts, „der Kunde bekommt keinen Beleg" sagt alles.
 */
function checkFor(string $key): ?Check
{
    return collect(Readiness::all())->firstWhere('key', $key);
}

it('reports the stripe key of the active mode as missing', function () {
    OperatingMode::set(OperatingMode::Test);

    expect(checkFor('billing.stripe_secret')->satisfied)->toBeFalse();
    expect(checkFor('billing.stripe_secret')->severity)->toBe(Check::SEVERITY_BLOCKING);
});

it('reports it as satisfied once the key of that mode is stored', function () {
    OperatingMode::set(OperatingMode::Test);
    app(SecretVault::class)->put('stripe.secret', 'sk_test_x', Operator::factory()->create(), OperatingMode::Test);

    expect(checkFor('billing.stripe_secret')->satisfied)->toBeTrue();
});

it('does not accept a live key as proof while the test mode is active', function () {
    // Sonst meldete die Seite Bereitschaft für einen Modus, in dem die Kasse
    // sperrt — die Seite widerspräche der Sperre aus Task 5.
    OperatingMode::set(OperatingMode::Test);
    app(SecretVault::class)->put('stripe.secret', 'sk_live_x', Operator::factory()->create(), OperatingMode::Live);

    expect(checkFor('billing.stripe_secret')->satisfied)->toBeFalse();
});

it('reports incomplete company details without repeating the list', function () {
    Settings::forget('company.name');

    expect(checkFor('billing.company_details')->satisfied)->toBeFalse();
});

it('names what breaks, not just what is missing', function () {
    expect(checkFor('billing.company_details')->breaks)->not->toBe('');
});

it('says the installation is not ready while something blocking is open', function () {
    expect(Readiness::isReady())->toBeFalse();
    expect(Readiness::blocking())->not->toBeEmpty();
});
```

- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen**

Run: `docker compose exec -T app php artisan test --filter=BillingChecksTest`
Expected: FAIL mit `Class "App\Support\Readiness" not found`

- [ ] **Step 3: Befund, Gruppe und Sammler schreiben**

`app/Support/Readiness/Check.php`:

```php
<?php

namespace App\Support\Readiness;

/**
 * Ein Befund der Bereitschaftsprüfung.
 *
 * `breaks` ist das Feld, an dem diese Seite hängt. Eine Liste von Feldnamen ist
 * keine Auskunft — sie sagt, was fehlt, nicht was daraus folgt. Am 2026-07-30
 * war jedes fehlende Stück in der Konsole unsichtbar, und mehrere brachen die
 * Kette erst NACH der Zahlung. Genau das gehört hier hinein.
 */
final class Check
{
    public const SEVERITY_BLOCKING = 'blocking';

    public const SEVERITY_WARNING = 'warning';

    public function __construct(
        public string $key,
        public string $group,
        public string $severity,
        public string $label,
        public string $breaks,
        public string $tab,
        public bool $satisfied,
    ) {}

    public function isBlocking(): bool
    {
        return $this->severity === self::SEVERITY_BLOCKING && ! $this->satisfied;
    }
}
```

`app/Support/Readiness/BillingChecks.php`:

```php
<?php

namespace App\Support\Readiness;

use App\Models\InvoiceSeries;
use App\Models\PlanPrice;
use App\Services\Secrets\SecretVault;
use App\Support\CompanyProfile;
use App\Support\OperatingMode;

/**
 * Was gesetzt sein muss, damit überhaupt ein Auftrag entstehen und ein Beleg
 * herauskommen kann.
 */
final class BillingChecks
{
    public const GROUP = 'billing';

    /** @return array<int, Check> */
    public static function all(): array
    {
        $mode = OperatingMode::current();

        return [
            new Check(
                key: 'billing.stripe_secret',
                group: self::GROUP,
                severity: Check::SEVERITY_BLOCKING,
                label: __('readiness.billing.stripe_secret', ['mode' => __('readiness.mode.'.$mode->value)]),
                breaks: __('readiness.billing.stripe_secret_breaks'),
                tab: 'services',
                // get() löst den aktiven Modus selbst auf UND ist für Stripe
                // strikt — ein Live-Schlüssel zählt im Testbetrieb nicht als
                // Nachweis, sonst widerspräche diese Seite der Kassensperre.
                satisfied: filled(app(SecretVault::class)->get('stripe.secret')),
            ),
            new Check(
                key: 'billing.webhook_secret',
                group: self::GROUP,
                severity: Check::SEVERITY_BLOCKING,
                label: __('readiness.billing.webhook_secret'),
                breaks: __('readiness.billing.webhook_secret_breaks'),
                tab: 'env',
                satisfied: filled($mode->isTest()
                    ? config('services.stripe.webhook_secret_test')
                    : config('services.stripe.webhook_secret')),
            ),
            new Check(
                key: 'billing.company_details',
                group: self::GROUP,
                severity: Check::SEVERITY_BLOCKING,
                label: __('readiness.billing.company_details'),
                breaks: __('readiness.billing.company_details_breaks'),
                tab: 'company',
                // Die vorhandene Liste wird gerufen, nicht verdoppelt: zwei
                // Quellen für eine Frage ist der Weg, auf dem sie auseinanderlaufen.
                satisfied: CompanyProfile::missingForInvoicing() === [],
            ),
            new Check(
                key: 'billing.invoice_series',
                group: self::GROUP,
                severity: Check::SEVERITY_BLOCKING,
                label: __('readiness.billing.invoice_series'),
                breaks: __('readiness.billing.invoice_series_breaks'),
                tab: 'company',
                satisfied: InvoiceSeries::query()
                    ->whereIn('kind', ['invoice', 'credit_note', 'cancellation'])
                    ->distinct()
                    ->count('kind') === 3,
            ),
            new Check(
                key: 'billing.catalogue_synced',
                group: self::GROUP,
                severity: Check::SEVERITY_BLOCKING,
                label: __('readiness.billing.catalogue_synced'),
                breaks: __('readiness.billing.catalogue_synced_breaks'),
                tab: 'services',
                satisfied: PlanPrice::query()->whereNull('stripe_price_id')->doesntExist(),
            ),
        ];
    }
}
```

`app/Support/Readiness.php`:

```php
<?php

namespace App\Support;

use App\Support\Readiness\BillingChecks;
use App\Support\Readiness\Check;

/**
 * Jedes Pflichtfeld dieser Installation an einer Stelle, gruppiert nach dem,
 * was es blockiert — nicht danach, wo der Wert gespeichert ist.
 *
 * Ein Betreiber, der eine Installation befüllt, denkt in „was kann ich noch
 * nicht", nicht in „liegt das im Tresor oder in den Einstellungen".
 *
 * Diese Klasse SPERRT NICHTS. Die harten Sperren bleiben, wo sie stehen —
 * IssueInvoice bei unvollständigen Firmendaten, VerifyVmTemplate bei fehlender
 * Vorlage, die Kasse bei fehlendem Stripe-Schlüssel. Eine Bereitschaftsseite,
 * die selbst sperrt, wäre eine zweite Wahrheit neben diesen Prüfungen.
 */
final class Readiness
{
    /** @return array<int, Check> */
    public static function all(): array
    {
        return BillingChecks::all();
    }

    /** @return array<int, Check> */
    public static function blocking(): array
    {
        return array_values(array_filter(self::all(), fn (Check $c) => $c->isBlocking()));
    }

    public static function isReady(): bool
    {
        return self::blocking() === [];
    }

    /** @return array<string, array<int, Check>> */
    public static function byGroup(): array
    {
        $grouped = [];

        foreach (self::all() as $check) {
            $grouped[$check->group][] = $check;
        }

        return $grouped;
    }
}
```

`lang/de/readiness.php` anlegen mit den benutzten Schlüsseln, darunter:

```php
'mode' => ['test' => 'Testbetrieb', 'live' => 'Livebetrieb'],
'billing' => [
    'stripe_secret' => 'Stripe-Schlüssel (:mode)',
    'stripe_secret_breaks' => 'Ohne ihn nimmt die Kasse keine Bestellung an — es entsteht gar kein Auftrag.',
    'webhook_secret' => 'Stripe-Signaturschlüssel',
    'webhook_secret_breaks' => 'Ohne ihn wird eine Zahlung nie verbucht: der Kunde zahlt, und nichts passiert.',
    'company_details' => 'Firmendaten vollständig',
    'company_details_breaks' => 'IssueInvoice verweigert die Ausstellung. Die Bereitstellung läuft trotzdem durch — der Kunde bekommt eine laufende Cloud ohne Beleg.',
    'invoice_series' => 'Rechnungsserie je Belegart',
    'invoice_series_breaks' => 'Ein Beleg kann keine Nummer ziehen.',
    'catalogue_synced' => 'Stripe-Katalog abgeglichen',
    'catalogue_synced_breaks' => 'Ein geprüfter EU-Firmenkunde kann nicht bestellen, weil sein Netto-Preis bei Stripe fehlt.',
],
```

- [ ] **Step 4: Test laufen lassen, Erfolg bestätigen**

Run: `docker compose exec -T app php artisan test --filter=BillingChecksTest`
Expected: PASS, 6 Tests

- [ ] **Step 5: Committen**

```bash
git add -- app/Support/Readiness.php app/Support/Readiness/ lang/de/readiness.php tests/Feature/Readiness/BillingChecksTest.php
git commit -F - -- app/Support/Readiness.php app/Support/Readiness/ lang/de/readiness.php tests/Feature/Readiness/BillingChecksTest.php <<'EOF'
Say what breaks, not just which field is empty

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
```

---

### Task 8: Die übrigen drei Gruppen

**Files:**
- Create: `app/Support/Readiness/OnboardingChecks.php`
- Create: `app/Support/Readiness/ProvisioningChecks.php`
- Create: `app/Support/Readiness/DeliveryChecks.php`
- Modify: `app/Support/Readiness.php` (die drei Gruppen einhängen)
- Modify: `lang/de/readiness.php`
- Test: `tests/Feature/Readiness/OnboardingChecksTest.php`, `tests/Feature/Readiness/ProvisioningChecksTest.php`, `tests/Feature/Readiness/DeliveryChecksTest.php`

**Interfaces:**
- Consumes: `Check` und `checkFor()`-Muster aus Task 7.
- Produces: `OnboardingChecks::all()`, `ProvisioningChecks::all()`, `DeliveryChecks::all()` — je `array<int, Check>`. `Readiness::all()` fügt sie in der Reihenfolge Abrechnung, Onboarding, Bereitstellung, Zustellung zusammen.

Schlüssel der Prüfungen, vollständig — die Gruppe ist das Präfix:

| Schlüssel | Grad | Erfüllt, wenn |
|---|---|---|
| `onboarding.ssh_key` | blocking | `SecretVault::get('ssh.private_key')` gefüllt |
| `onboarding.secrets_key` | blocking | `config('app.secrets_key')` bzw. `env('SECRETS_KEY')` gefüllt |
| `onboarding.vpn_config_key` | blocking | `env('VPN_CONFIG_KEY')` gefüllt |
| `onboarding.wg_hub` | blocking | Hub-Pubkey, Endpunkt und Subnetz alle gefüllt |
| `onboarding.datacenter` | blocking | `Datacenter::exists()` |
| `provisioning.dns_token` | blocking | `SecretVault::get('dns.token')` gefüllt |
| `provisioning.dns_zone` | blocking | `ProvisioningSettings::dnsZone()` gefüllt |
| `provisioning.traefik_path` | warning | `ProvisioningSettings::traefikDynamicPath()` gefüllt |
| `provisioning.usable_host` | blocking | `Host::where('status','active')->whereNotNull('api_token_ref')->exists()` |
| `provisioning.vm_template` | blocking | Reine Feldprüfung: jede veröffentlichte Version hat eine `template_vmid`. Ob sie auf einem Knoten **liegt**, beantwortet erst der Knopf aus Task 9 — dafür muss Proxmox befragt werden, und das gehört nicht in jeden Seitenaufruf. |
| `provisioning.monitoring_token` | warning | `SecretVault::get('monitoring.token')` gefüllt |
| `delivery.mailer_not_log` | blocking | `config('mail.default') !== 'log'` |
| `delivery.mailbox` | blocking | `Mailbox::exists()` |
| `delivery.mail_templates` | warning | `MailTemplate::exists()` |
| `delivery.inbound_password` | warning | `SecretVault::get('inbound_mail.password')` gefüllt |

- [ ] **Step 1: Die drei fehlschlagenden Testdateien schreiben**

Beispiel für die Prüfung, die den Befund vom 2026-07-30 fängt —
`tests/Feature/Readiness/ProvisioningChecksTest.php`:

```php
<?php

use App\Models\Host;
use App\Support\Readiness;
use App\Support\Readiness\Check;

function provisioningCheck(string $key): ?Check
{
    return collect(Readiness::all())->firstWhere('key', $key);
}

/**
 * Am 2026-07-30 standen zwei Hosts auf `active`, ohne api_token_ref und mit
 * Adressen aus dem Dokumentationsbereich (203.0.113.0/24, RFC 5737). In der
 * Konsole waren sie von einem echten Host nicht zu unterscheiden — und
 * ReserveResources wählt aus `status = active`. Ein Kauf hätte sich einen
 * dieser Phantom-Hosts reserviert und wäre NACH der Zahlung gestorben.
 */
it('does not accept an active host without a readable token', function () {
    Host::factory()->create(['status' => 'active', 'api_token_ref' => null]);

    expect(provisioningCheck('provisioning.usable_host')->satisfied)->toBeFalse();
});

it('accepts an active host that carries a token', function () {
    Host::factory()->create(['status' => 'active', 'api_token_ref' => 'ref-1']);

    expect(provisioningCheck('provisioning.usable_host')->satisfied)->toBeTrue();
});

it('does not accept a host that carries a token but is not active', function () {
    Host::factory()->create(['status' => 'onboarding', 'api_token_ref' => 'ref-1']);

    expect(provisioningCheck('provisioning.usable_host')->satisfied)->toBeFalse();
});

it('treats the monitoring token as a warning, never as a blocker', function () {
    // Überwachung darf eine Bereitstellung nie aufhalten.
    expect(provisioningCheck('provisioning.monitoring_token')->severity)
        ->toBe(Check::SEVERITY_WARNING);
});
```

Und in `tests/Feature/Readiness/DeliveryChecksTest.php` der Fall, der heute
still danebengeht:

```php
<?php

use App\Models\Mailbox;
use App\Support\Readiness;

function deliveryCheck(string $key): ?\App\Support\Readiness\Check
{
    return collect(Readiness::all())->firstWhere('key', $key);
}

/**
 * `mail.default = log` heißt: die VM läuft, der Beleg ist geschrieben, und die
 * Zugangsdaten-Mail liegt in einer Datei auf dem Server. Der Kunde erfährt nie,
 * dass er eine Cloud hat. Kein Fehler taucht irgendwo auf.
 */
it('refuses to call an installation ready while mail goes to the log file', function () {
    config()->set('mail.default', 'log');

    expect(deliveryCheck('delivery.mailer_not_log')->satisfied)->toBeFalse();
});

it('accepts a real mailer', function () {
    config()->set('mail.default', 'smtp');

    expect(deliveryCheck('delivery.mailer_not_log')->satisfied)->toBeTrue();
});

it('needs at least one mailbox to send from', function () {
    expect(deliveryCheck('delivery.mailbox')->satisfied)->toBeFalse();

    Mailbox::factory()->create();

    expect(deliveryCheck('delivery.mailbox')->satisfied)->toBeTrue();
});
```

`tests/Feature/Readiness/OnboardingChecksTest.php`:

```php
<?php

use App\Models\Datacenter;
use App\Models\Operator;
use App\Services\Secrets\SecretVault;
use App\Support\OperatingMode;
use App\Support\Readiness;
use App\Support\Settings;

function onboardingCheck(string $key): ?\App\Support\Readiness\Check
{
    return collect(Readiness::all())->firstWhere('key', $key);
}

it('needs at least one datacenter', function () {
    // ValidateHostInput ist der erste Schritt der Host-Kette und bricht ohne
    // Rechenzentrum ab — bevor überhaupt eine SSH-Verbindung versucht wird.
    expect(onboardingCheck('onboarding.datacenter')->satisfied)->toBeFalse();

    Datacenter::factory()->create();

    expect(onboardingCheck('onboarding.datacenter')->satisfied)->toBeTrue();
});

it('needs an ssh private key', function () {
    expect(onboardingCheck('onboarding.ssh_key')->satisfied)->toBeFalse();

    app(SecretVault::class)->put(
        'ssh.private_key',
        '-----BEGIN OPENSSH PRIVATE KEY-----',
        Operator::factory()->create(),
        OperatingMode::Live,
    );

    expect(onboardingCheck('onboarding.ssh_key')->satisfied)->toBeTrue();
});

it('needs all three wireguard values, not just some', function () {
    // Zwei von drei ist kein halber Tunnel, sondern gar keiner.
    Settings::set('wg.hub_pubkey', 'abc=');
    Settings::set('wg.endpoint', '198.51.45.9:51820');

    expect(onboardingCheck('onboarding.wg_hub')->satisfied)->toBeFalse();

    Settings::set('wg.subnet', '10.66.0.0/24');

    expect(onboardingCheck('onboarding.wg_hub')->satisfied)->toBeTrue();
});
```

Die `Settings`-Schlüssel für WireGuard bitte an `ProvisioningSettings` ablesen,
bevor die Prüfung geschrieben wird — dort steht, wie sie tatsächlich heißen.

- [ ] **Step 2: Tests laufen lassen, Fehlschlag bestätigen**

Run: `docker compose exec -T app php artisan test --filter=ChecksTest`
Expected: FAIL — die drei Gruppenklassen existieren nicht

- [ ] **Step 3: Die drei Gruppen schreiben**

Jede Klasse nach dem Vorbild von `BillingChecks`: eine `public const GROUP`, eine
`public static function all(): array`, ein `Check` je Zeile der Tabelle oben, mit
`label` und `breaks` aus `lang/de/readiness.php`. Beispiel für die entscheidende
Zeile in `ProvisioningChecks`:

```php
new Check(
    key: 'provisioning.usable_host',
    group: self::GROUP,
    severity: Check::SEVERITY_BLOCKING,
    label: __('readiness.provisioning.usable_host'),
    breaks: __('readiness.provisioning.usable_host_breaks'),
    tab: 'hosts',
    // `active` ALLEIN reicht nicht. Ein Host ohne lesbaren Token ist für
    // ReserveResources trotzdem wählbar, und der Auftrag stirbt dann in
    // CloneVirtualMachine — nach der Zahlung.
    satisfied: Host::query()
        ->where('status', 'active')
        ->whereNotNull('api_token_ref')
        ->exists(),
),
```

Danach `Readiness::all()` erweitern:

```php
public static function all(): array
{
    return [
        ...BillingChecks::all(),
        ...OnboardingChecks::all(),
        ...ProvisioningChecks::all(),
        ...DeliveryChecks::all(),
    ];
}
```

- [ ] **Step 4: Tests laufen lassen, Erfolg bestätigen**

Run: `docker compose exec -T app php artisan test --filter=ChecksTest`
Expected: PASS

- [ ] **Step 5: Gegen den echten Server halten**

Nicht „grün heißt fertig" — die Prüfungen müssen auf dieser Installation das
melden, was am 2026-07-30 gemessen wurde.

```bash
docker compose exec -T app php artisan tinker --execute='
foreach(\App\Support\Readiness::all() as $c)
  printf("  %-34s %-8s %s\n", $c->key, $c->severity, $c->satisfied ? "OK" : "FEHLT");'
```

Expected: `provisioning.usable_host` FEHLT (die zwei Phantom-Hosts), `delivery.mailer_not_log` FEHLT (`mail.default=log`), `delivery.inbound_password` FEHLT, Firmendaten OK, Rechenzentrum OK.

- [ ] **Step 6: Committen**

```bash
git add -- app/Support/Readiness.php app/Support/Readiness/ lang/de/readiness.php tests/Feature/Readiness/
git commit -F - -- app/Support/Readiness.php app/Support/Readiness/ lang/de/readiness.php tests/Feature/Readiness/
```

Nachricht:

```
Check the machines, the DNS and the post as well

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
```

---

### Task 9: Die drei Prüfungen, die wirklich nachsehen

**Files:**
- Create: `app/Services/Dns/DnsTokenCheck.php`
- Create: `app/Services/Vpn/WireguardEndpointCheck.php`
- Create: `app/Services/Proxmox/VmTemplateCheck.php`
- Test: `tests/Feature/Readiness/ActiveChecksTest.php`

**Interfaces:**
- Consumes: `HetznerDnsClient`, `ProvisioningSettings`, `ProxmoxClient::vmExists()`, `PlanVersion`.
- Produces: je eine `final class` mit `run(): array` in **genau der Form, die
  `StripeCheck` schon liefert** — `['ok' => bool, 'reason' => string]`, bei
  Bedarf weitere Schlüssel. Kein eigener Rückgabetyp daneben: die Konsole
  behandelt heute schon ein solches Array, und ein zweites Format wäre die
  zweite Wahrheit, die dieser Entwurf vermeidet.

Diese drei sind der Unterschied zwischen „gesetzt" und „funktioniert".

- [ ] **Step 1: Den fehlschlagenden Test schreiben**

```php
<?php

use App\Services\Vpn\WireguardEndpointCheck;

/**
 * Ein UDP-Port lässt sich von außen nicht sauber anklopfen — es gibt keinen
 * Handshake zu beobachten. Was ENTSCHEIDBAR ist: ob dort überhaupt eine
 * Adresse steht, die ein Host im Internet erreichen kann.
 *
 * Am 2026-07-30 stand `10.10.90.185:51820` in der Konfiguration. Das sieht
 * gesetzt aus und ist für jeden Host außerhalb dieses Netzes unerreichbar.
 */
it('rejects a private address', function () {
    expect((new WireguardEndpointCheck)->run('10.10.90.185:51820')['ok'])->toBeFalse();
});

it('rejects a documentation address', function () {
    // RFC 5737 — Seed- und Beispieldaten landen erfahrungsgemäß in echten
    // Installationen.
    expect((new WireguardEndpointCheck)->run('203.0.113.11:51820')['ok'])->toBeFalse();
});

it('rejects loopback', function () {
    expect((new WireguardEndpointCheck)->run('127.0.0.1:51820')['ok'])->toBeFalse();
});

it('accepts a public address', function () {
    expect((new WireguardEndpointCheck)->run('198.51.45.9:51820')['ok'])->toBeTrue();
});

it('accepts a hostname, which it cannot judge', function () {
    // Ein Name kann öffentlich auflösen; hier zu raten wäre schlechter als
    // durchzulassen — die Prüfung sagt, was sie weiß, nicht was sie vermutet.
    expect((new WireguardEndpointCheck)->run('vpn.clupilot.cloud:51820')['ok'])->toBeTrue();
});

it('rejects an endpoint without a port', function () {
    expect((new WireguardEndpointCheck)->run('198.51.45.9')['ok'])->toBeFalse();
});

it('names the offending address, so the page can show it', function () {
    expect((new WireguardEndpointCheck)->run('10.10.90.185:51820')['reason'])->toBe('not_public');
});
```

Für `DnsTokenCheck` mit `Http::fake()`: ein Fake, der die Zone listet und
Schreibrecht bestätigt (`ok`), einer der die Zone nicht enthält (`nicht ok`),
und einer, der bei einem Schreibversuch 403 liefert (`nicht ok` — **genau der
Leserecht-Token aus dem Handoff**).

Für `VmTemplateCheck` mit einem gefälschten `ProxmoxClient`: Vorlage vorhanden
(`ok`), Vorlage fehlt (`nicht ok`, mit der VMID im Text), kein aktiver Host
(`nicht ok`).

- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen**

Run: `docker compose exec -T app php artisan test --filter=ActiveChecksTest`
Expected: FAIL — die Klassen existieren nicht

- [ ] **Step 3: Die drei Prüfungen schreiben**

`WireguardEndpointCheck` — kein Netzverkehr, nur eine Entscheidung über die
Adresse:

```php
/** @return array<string, mixed> */
public function run(?string $endpoint = null): array
{
    $endpoint ??= ProvisioningSettings::wgEndpoint();

    if (blank($endpoint)) {
        return ['ok' => false, 'reason' => 'missing'];
    }

    if (! str_contains($endpoint, ':')) {
        return ['ok' => false, 'reason' => 'no_port'];
    }

    $host = Str::beforeLast($endpoint, ':');

    // Keine gültige IP heißt: ein Name. Der kann öffentlich auflösen, und hier
    // zu raten wäre schlechter als durchzulassen — die Prüfung sagt, was sie
    // weiß, nicht was sie vermutet.
    if (! filter_var($host, FILTER_VALIDATE_IP)) {
        return ['ok' => true, 'reason' => 'hostname', 'address' => $host];
    }

    // NO_PRIV_RANGE deckt RFC 1918 ab, NO_RES_RANGE unter anderem Loopback und
    // 203.0.113.0/24 — genau die Adressen, die am 2026-07-30 an zwei Hosts
    // standen.
    $public = filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);

    return $public === false
        ? ['ok' => false, 'reason' => 'not_public', 'address' => $host]
        : ['ok' => true, 'reason' => 'public', 'address' => $host];
}
```

`DnsTokenCheck` — die Zone zu **listen** beweist nichts, weil ein
Leserecht-Token das anstandslos tut. Also wird geschrieben und sofort
zurückgenommen:

```php
/** @return array<string, mixed> */
public function run(?string $candidate = null): array
{
    $token = $candidate ?: app(SecretVault::class)->get('dns.token');
    $zone = ProvisioningSettings::dnsZone();

    if (blank($token) || blank($zone)) {
        return ['ok' => false, 'reason' => 'missing'];
    }

    try {
        $zones = Http::withHeaders(['Auth-API-Token' => $token])->acceptJson()->timeout(15)
            ->get('https://dns.hetzner.com/api/v1/zones');
    } catch (Throwable) {
        return ['ok' => false, 'reason' => 'unreachable'];
    }

    if ($zones->status() === 401 || $zones->status() === 403) {
        return ['ok' => false, 'reason' => 'rejected'];
    }

    $zoneId = collect($zones->json('zones') ?? [])->firstWhere('name', $zone)['id'] ?? null;

    if ($zoneId === null) {
        return ['ok' => false, 'reason' => 'zone_not_found', 'zone' => $zone];
    }

    // Der eigentliche Punkt. Ein Leserecht-Token kommt bis hierher und sieht in
    // der Konsole aus wie ein funktionierender; er scheitert erst, wenn eine
    // Kunden-VM ihren A-Record braucht — nach der Zahlung.
    $probe = Http::withHeaders(['Auth-API-Token' => $token])->acceptJson()->timeout(15)
        ->post('https://dns.hetzner.com/api/v1/records', [
            'zone_id' => $zoneId,
            'type' => 'TXT',
            'name' => '_clupilot-write-probe-'.Str::lower(Str::random(12)),
            'value' => 'clupilot readiness probe',
            'ttl' => 60,
        ]);

    if (! $probe->successful()) {
        return ['ok' => false, 'reason' => 'read_only', 'status' => $probe->status()];
    }

    // Immer aufräumen, auch wenn das Löschen scheitert: ein liegengebliebener
    // TXT-Eintrag mit diesem Namen ist harmlos und wird im Ergebnis benannt,
    // damit ihn jemand von Hand entfernen kann.
    $recordId = $probe->json('record.id');
    $removed = Http::withHeaders(['Auth-API-Token' => $token])->acceptJson()->timeout(15)
        ->delete('https://dns.hetzner.com/api/v1/records/'.$recordId)
        ->successful();

    return ['ok' => true, 'reason' => 'writable', 'probe_removed' => $removed];
}
```

`VmTemplateCheck` stellt dieselbe Frage wie `VerifyVmTemplate` — jede
veröffentlichte Paketversion zeigt auf eine `template_vmid`, die auf einem Knoten
liegen muss —, nur über **alle aktiven Hosts** statt über einen, und **vor** dem
Kauf statt danach:

```php
/** @return array<string, mixed> */
public function run(): array
{
    $required = PlanVersion::query()
        ->whereNotNull('published_at')
        ->whereNotNull('template_vmid')
        ->where(fn ($q) => $q->whereNull('available_until')->orWhere('available_until', '>', now()))
        ->pluck('template_vmid')->map(fn ($v) => (int) $v)->unique()->values()->all();

    // Ein Katalog ohne veröffentlichte Version verlangt keine Vorlage. Derselbe
    // Fall, den VerifyVmTemplate ausdrücklich durchlässt.
    if ($required === []) {
        return ['ok' => true, 'reason' => 'nothing_published'];
    }

    $hosts = Host::query()->where('status', 'active')->whereNotNull('api_token_ref')->get();

    if ($hosts->isEmpty()) {
        return ['ok' => false, 'reason' => 'no_usable_host'];
    }

    $missing = [];

    foreach ($required as $vmid) {
        $found = false;

        foreach ($hosts as $host) {
            try {
                if ($this->pve->forHost($host)->vmExists($host->node ?? 'pve', $vmid)) {
                    $found = true;
                    break;
                }
            } catch (Throwable) {
                // Ein Host, der gerade nicht antwortet, ist kein Beweis für eine
                // fehlende Vorlage. Weitersuchen.
                continue;
            }
        }

        if (! $found) {
            $missing[] = $vmid;
        }
    }

    return $missing === []
        ? ['ok' => true, 'reason' => 'present']
        : ['ok' => false, 'reason' => 'template_missing', 'vmids' => $missing];
}
```

- [ ] **Step 4: Test laufen lassen, Erfolg bestätigen**

Run: `docker compose exec -T app php artisan test --filter=ActiveChecksTest`
Expected: PASS

- [ ] **Step 5: Gegen den echten Server halten**

```bash
docker compose exec -T app php artisan tinker --execute='
$r = app(\App\Services\Vpn\WireguardEndpointCheck::class)->run();
echo "WG:  ".($r["ok"] ? "OK" : "FEHLT")." — ".json_encode($r)."\n";
$d = app(\App\Services\Dns\DnsTokenCheck::class)->run();
echo "DNS: ".($d["ok"] ? "OK" : "FEHLT")." — ".json_encode($d)."\n";'
```

Expected: WG meldet die private Adresse. DNS sagt, ob der hinterlegte Token die
Zone `clupilot.cloud` wirklich beschreiben darf — die Frage, die seit dem
Handoff offen ist.

- [ ] **Step 6: Committen**

```bash
git add -- app/Services/Dns/DnsTokenCheck.php app/Services/Vpn/WireguardEndpointCheck.php app/Services/Proxmox/VmTemplateCheck.php lang/de/readiness.php tests/Feature/Readiness/ActiveChecksTest.php
git commit -F - -- app/Services/Dns/DnsTokenCheck.php app/Services/Vpn/WireguardEndpointCheck.php app/Services/Proxmox/VmTemplateCheck.php lang/de/readiness.php tests/Feature/Readiness/ActiveChecksTest.php
```

Nachricht:

```
Tell a read-only token apart from one that can write

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
```

---

### Task 10: Herzschläge — beweisen, dass die Worker leben

**Files:**
- Create: `app/Provisioning/Jobs/RecordProvisioningHeartbeat.php`
- Create: `app/Support/Readiness/OperationChecks.php`
- Modify: `routes/console.php`
- Modify: `app/Support/Readiness.php`
- Test: `tests/Feature/Readiness/HeartbeatTest.php`

**Interfaces:**
- Consumes: `Settings::set`/`get`, `OperationChecks` folgt dem Muster aus Task 7.
- Produces: `Settings`-Schlüssel `heartbeat.scheduler` und `heartbeat.queue_provisioning`, je ein ISO-8601-Zeitstempel. Prüfungen `operation.scheduler` und `operation.queue_provisioning`.

Beide in `Settings`, **nicht** im Zwischenspeicher: ein Herzschlag, der mit dem
Redis-Neustart verschwindet, meldet einen Ausfall, den es nicht gab.

- [ ] **Step 1: Den fehlschlagenden Test schreiben**

```php
<?php

use App\Provisioning\Jobs\RecordProvisioningHeartbeat;
use App\Support\Readiness;
use App\Support\Settings;

function operationCheck(string $key): ?\App\Support\Readiness\Check
{
    return collect(Readiness::all())->firstWhere('key', $key);
}

/**
 * „Ohne Worker passiert schlicht nichts, ohne Fehlermeldung." Genau dieser
 * Zustand war in der Konsole bisher nicht von „alles ruhig" zu unterscheiden.
 *
 * Zwei getrennte Herzschläge, weil Zeitplaner und Warteschlangen-Worker
 * getrennt ausfallen: der Zeitplaner kann laufen und Aufträge einstellen, die
 * niemand abholt.
 */
it('reports a missing heartbeat as not satisfied', function () {
    expect(operationCheck('operation.scheduler')->satisfied)->toBeFalse();
});

it('accepts a fresh heartbeat', function () {
    Settings::set('heartbeat.scheduler', now()->toIso8601String());

    expect(operationCheck('operation.scheduler')->satisfied)->toBeTrue();
});

it('rejects a heartbeat older than five minutes', function () {
    Settings::set('heartbeat.scheduler', now()->subMinutes(6)->toIso8601String());

    expect(operationCheck('operation.scheduler')->satisfied)->toBeFalse();
});

it('proves the worker, not just the scheduler', function () {
    // Der Zeitplaner läuft, der Bereitstellungs-Worker nicht: der eine
    // Herzschlag ist frisch, der andere nicht.
    Settings::set('heartbeat.scheduler', now()->toIso8601String());

    expect(operationCheck('operation.scheduler')->satisfied)->toBeTrue();
    expect(operationCheck('operation.queue_provisioning')->satisfied)->toBeFalse();
});

it('records the worker heartbeat when the job runs', function () {
    (new RecordProvisioningHeartbeat)->handle();

    expect(operationCheck('operation.queue_provisioning')->satisfied)->toBeTrue();
});
```

- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen**

Run: `docker compose exec -T app php artisan test --filter=HeartbeatTest`
Expected: FAIL — Job und Gruppe existieren nicht

- [ ] **Step 3: Job, Gruppe und Zeitplan schreiben**

`app/Provisioning/Jobs/RecordProvisioningHeartbeat.php`:

```php
<?php

namespace App\Provisioning\Jobs;

use App\Support\Settings;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;

/**
 * Ein Lebenszeichen des Bereitstellungs-Workers.
 *
 * Der Zeitplaner stellt ihn ein, der Worker führt ihn aus — also beweist er
 * genau das, was der Zeitplaner-Herzschlag NICHT beweist: dass jemand die
 * Warteschlange abholt. Die beiden fallen getrennt aus, und der Fall
 * „Zeitplaner läuft, Worker steht" ist der stille: Aufträge sammeln sich, und
 * nichts meldet etwas.
 */
class RecordProvisioningHeartbeat implements ShouldQueue
{
    use Queueable;

    public $queue = 'provisioning';

    public function handle(): void
    {
        Settings::set('heartbeat.queue_provisioning', now()->toIso8601String());
    }
}
```

In `routes/console.php`, bei den übrigen Zeitplänen:

```php
// Zwei Lebenszeichen für die Bereitschaftsseite. In Settings und nicht im
// Zwischenspeicher: ein Herzschlag, der mit dem Redis-Neustart verschwindet,
// meldete einen Ausfall, den es nicht gab.
Schedule::call(fn () => Settings::set('heartbeat.scheduler', now()->toIso8601String()))
    ->everyMinute()
    ->name('heartbeat-scheduler');

Schedule::job(new RecordProvisioningHeartbeat)
    ->everyMinute()
    ->name('heartbeat-provisioning');
```

`OperationChecks::all()` liest beide Zeitstempel und vergleicht gegen
`now()->subMinutes(5)`; ein fehlender Wert ist „nicht erfüllt".

Und `Readiness::all()` bekommt die fünfte Gruppe — sonst laufen die Prüfungen
ins Leere:

```php
public static function all(): array
{
    return [
        ...BillingChecks::all(),
        ...OnboardingChecks::all(),
        ...ProvisioningChecks::all(),
        ...DeliveryChecks::all(),
        ...OperationChecks::all(),
    ];
}
```

- [ ] **Step 4: Test laufen lassen, Erfolg bestätigen**

Run: `docker compose exec -T app php artisan test --filter=HeartbeatTest`
Expected: PASS, 5 Tests

- [ ] **Step 5: Am laufenden Server bestätigen**

Zwei Minuten warten, dann:

```bash
docker compose exec -T app php artisan tinker --execute='
echo "Zeitplaner: ".\App\Support\Settings::get("heartbeat.scheduler", "—")."\n";
echo "Worker:     ".\App\Support\Settings::get("heartbeat.queue_provisioning", "—")."\n";'
```

Expected: beide mit einem Zeitstempel der letzten Minuten. Bleibt der zweite
leer, während der erste läuft, ist das kein Testfehler — dann steht der
`queue-provisioning`-Worker, und die Prüfung hat beim ersten Versuch getan,
wofür sie gebaut wurde.

- [ ] **Step 6: Committen**

```bash
git add -- app/Provisioning/Jobs/RecordProvisioningHeartbeat.php app/Support/Readiness/OperationChecks.php app/Support/Readiness.php routes/console.php lang/de/readiness.php tests/Feature/Readiness/HeartbeatTest.php
git commit -F - -- app/Provisioning/Jobs/RecordProvisioningHeartbeat.php app/Support/Readiness/OperationChecks.php app/Support/Readiness.php routes/console.php lang/de/readiness.php tests/Feature/Readiness/HeartbeatTest.php
```

Nachricht:

```
Notice when nobody is picking up the queue

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
```

---

### Task 11: Der Umschalter in der Konsole

**Files:**
- Create: `app/Livewire/Admin/ConfirmSwitchMode.php`
- Create: `resources/views/livewire/admin/confirm-switch-mode.blade.php`
- Modify: `app/Livewire/Admin/Integrations.php`
- Modify: `resources/views/livewire/admin/integrations.blade.php`
- Modify: `resources/views/layouts/admin.blade.php` (Plakette)
- Test: `tests/Feature/SwitchOperatingModeTest.php`

**Interfaces:**
- Consumes: `OperatingMode::set()`, `ConfirmsPassword` (bestehendes Trait), das
  bestehende `openModal`-Muster.
- Produces: Livewire-Ereignis `mode-switch-confirmed` mit Argument `mode`.

Muster **wortgleich** zu `ConfirmSaveSecret`: das Modal mutiert nichts selbst,
sondern löst ein Ereignis aus, das die Seitenkomponente per `#[On(...)]`
auffängt — so bleibt die Berechtigungsprüfung an der einen Stelle, an der sie
schon steht.

- [ ] **Step 1: Den fehlschlagenden Test schreiben**

```php
<?php

use App\Livewire\Admin\Integrations;
use App\Models\Operator;
use App\Support\OperatingMode;
use Livewire\Livewire;

// Die Rollen heißen exakt so (Operator::OPERATOR_ROLES), und die Fabrik setzt
// sie über ->role(), nicht über eine ->owner()-Abkürzung. Das Testpasswort ist
// `passwort-fuer-tests` — OperatorFactory::definition().
it('refuses to switch without the secrets capability', function () {
    $admin = Operator::factory()->role('Admin')->create(); // hosts.manage, nicht secrets.manage

    Livewire::actingAs($admin)
        ->test(Integrations::class)
        ->call('switchMode', 'test')
        ->assertForbidden();

    expect(OperatingMode::current())->toBe(OperatingMode::Live);
});

it('switches when the owner has confirmed their password', function () {
    $owner = Operator::factory()->role('Owner')->create();

    Livewire::actingAs($owner)
        ->test(Integrations::class)
        ->call('confirmPassword', 'passwort-fuer-tests')
        ->call('switchMode', 'test');

    expect(OperatingMode::current())->toBe(OperatingMode::Test);
});

it('refuses an unknown mode', function () {
    // Ein String aus dem Browser. `staging` gibt es nicht, und from() würde
    // hier eine Ausnahme werfen statt still nichts zu tun.
    $owner = Operator::factory()->role('Owner')->create();

    Livewire::actingAs($owner)
        ->test(Integrations::class)
        ->call('confirmPassword', 'passwort-fuer-tests')
        ->call('switchMode', 'staging');

    expect(OperatingMode::current())->toBe(OperatingMode::Live);
});
```

Dazu ein Blade-Wächtertest, der R23 für die neue Datei mitnimmt — er existiert
bereits als `tests/Feature/ConfirmInModalTest.php` und läuft über das ganze
Repo, also **muss** hier nichts ergänzt werden. Er wird in Step 4 mitgeprüft.

- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen**

Run: `docker compose exec -T app php artisan test --filter=SwitchOperatingModeTest`
Expected: FAIL — `switchMode` existiert nicht

- [ ] **Step 3: Umschalter und Modal schreiben**

In `Integrations`:

```php
/**
 * Den Betriebsmodus umlegen.
 *
 * Hinter derselben Sperre wie der Tresor — `secrets.manage` plus bestätigtes
 * Passwort — weil der Wechsel auf Live der Moment ist, ab dem echtes Geld
 * fließt. Das ist keine Umschaltfläche zum Danebenklicken.
 */
#[On('mode-switch-confirmed')]
public function switchMode(string $mode): void
{
    $this->guardSecrets();

    // Ein String aus dem Browser. tryFrom, nicht from.
    $target = OperatingMode::tryFrom($mode);

    if ($target === null) {
        return;
    }

    OperatingMode::set($target);

    $this->dispatch('notify', message: __('integrations.mode_switched', [
        'mode' => __('readiness.mode.'.$target->value),
    ]));
}
```

Das Modal `ConfirmSwitchMode` bekommt den Zielmodus als Argument, zeigt einen
Satz, der beim Wechsel auf Live ausdrücklich sagt, dass ab dann echtes Geld
bewegt wird, und löst beim Bestätigen `mode-switch-confirmed` aus.

In `layouts/admin.blade.php` die Plakette, solange der Modus `test` ist —
Icon nach R18 `size-4`, einzeilig:

```blade
@if (\App\Support\OperatingMode::current()->isTest())
    <span class="inline-flex items-center gap-1.5 rounded-full bg-amber-100 px-2.5 py-1 text-xs font-medium text-amber-900 dark:bg-amber-900/40 dark:text-amber-200">
        <x-ui.icon name="beaker" class="size-4" />
        {{ __('readiness.mode.test') }}
    </span>
@endif
```

- [ ] **Step 4: Tests laufen lassen, Erfolg bestätigen**

Run: `docker compose exec -T app php artisan test --filter=SwitchOperatingModeTest`
Expected: PASS, 3 Tests

Run: `docker compose exec -T app php artisan test --filter="ConfirmInModal|IconLayout|ModalHeight"`
Expected: PASS — die Regelwächter R23, R18 und R24 nehmen die neuen Dateien mit

- [ ] **Step 5: Committen**

```bash
git add -- app/Livewire/Admin/ConfirmSwitchMode.php app/Livewire/Admin/Integrations.php resources/views/livewire/admin/confirm-switch-mode.blade.php resources/views/livewire/admin/integrations.blade.php resources/views/layouts/admin.blade.php lang/de/integrations.php tests/Feature/SwitchOperatingModeTest.php
git commit -F - -- app/Livewire/Admin/ConfirmSwitchMode.php app/Livewire/Admin/Integrations.php resources/views/livewire/admin/confirm-switch-mode.blade.php resources/views/livewire/admin/integrations.blade.php resources/views/layouts/admin.blade.php lang/de/integrations.php tests/Feature/SwitchOperatingModeTest.php
```

Nachricht:

```
Put the switch where it governs, and say when it is thrown

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
```

---

### Task 12: Die Bereitschaftsseite — und der Wächter, der sie vollständig hält

**Files:**
- Create: `app/Livewire/Admin/Readiness.php`
- Create: `resources/views/livewire/admin/readiness.blade.php`
- Modify: `routes/web.php` (Route `/admin/readiness`)
- Modify: `resources/views/layouts/admin.blade.php` (Navigationseintrag)
- Modify: `app/Livewire/Admin/Overview.php` und dessen Blade (Hinweis)
- Test: `tests/Feature/ReadinessPageTest.php`

**Interfaces:**
- Consumes: `Readiness::byGroup()`, `Readiness::blocking()`, `Readiness::isReady()`.
- Produces: benannte Route `admin.readiness`.

- [ ] **Step 1: Den fehlschlagenden Test schreiben**

```php
<?php

use App\Livewire\Admin\Readiness as ReadinessPage;
use App\Models\Operator;
use App\Services\Secrets\SecretVault;
use App\Support\Readiness;
use Livewire\Livewire;

it('is closed to operators without either capability', function () {
    $this->actingAs(Operator::factory()->role('Read-only')->create(), 'operator')
        ->get(route('admin.readiness'))
        ->assertForbidden();
});

it('lists every check, satisfied or not', function () {
    Livewire::actingAs(Operator::factory()->role('Owner')->create())
        ->test(ReadinessPage::class)
        ->assertSee(__('readiness.billing.company_details'))
        ->assertSee(__('readiness.provisioning.usable_host'));
});

it('says what breaks, not only that something is missing', function () {
    Livewire::actingAs(Operator::factory()->role('Owner')->create())
        ->test(ReadinessPage::class)
        ->assertSee(__('readiness.delivery.mailer_not_log_breaks'));
});

it('runs an active check on demand and shows its answer', function () {
    // Die drei Prüfungen aus Task 9 sind nur dann etwas wert, wenn jemand sie
    // auslösen kann. Sie laufen NICHT bei jedem Seitenaufruf mit: eine davon
    // schreibt einen TXT-Eintrag in die echte Zone.
    Livewire::actingAs(Operator::factory()->role('Owner')->create())
        ->test(ReadinessPage::class)
        ->call('runCheck', 'provisioning.dns_token')
        ->assertSet('results.provisioning.dns_token.ok', false);
});

it('refuses to run a check that is not on the list', function () {
    // Ein Schlüssel aus dem Browser. Ohne diese Prüfung wäre das ein Weg,
    // beliebige Klassen aufzurufen.
    Livewire::actingAs(Operator::factory()->role('Owner')->create())
        ->test(ReadinessPage::class)
        ->call('runCheck', 'etwas.erfundenes')
        ->assertSet('results', []);
});

/**
 * Der Wächter dieser Spec.
 *
 * Ein künftig hinzugefügtes Zugangsdatum darf nicht still an der Übersicht
 * vorbeigehen. Genau das war der Befund vom 2026-07-30: eine grüne Testsuite
 * bei einer Installation, die keinen Host durchinstallieren konnte, weil
 * niemand die Liste geführt hat.
 */
it('covers every entry in the secret registry', function () {
    $covered = collect(Readiness::all())->pluck('key')->implode(' ');

    foreach (array_keys(SecretVault::REGISTRY) as $secret) {
        // `stripe.secret` → irgendein Prüfschlüssel, der `stripe_secret` enthält.
        $needle = str_replace('.', '_', $secret);

        expect($covered)->toContain(
            $needle,
            "Der Tresor-Eintrag [{$secret}] taucht auf der Bereitschaftsseite nicht auf."
        );
    }
});
```

- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen**

Run: `docker compose exec -T app php artisan test --filter=ReadinessPageTest`
Expected: FAIL — Route und Komponente existieren nicht

- [ ] **Step 3: Seite, Route, Navigation und Hinweis schreiben**

Komponente `App\Livewire\Admin\Readiness` mit `#[Layout('layouts.admin')]`,
`mount()` prüft `Gate::any(['hosts.manage', 'secrets.manage'])` — dieselbe
Sperre wie die Integrationsseite, weil beide Hälften hier zusammenkommen.

Das Blade zeigt oben die eine Zeile, auf die es ankommt — *Bereit für
Testbetrieb* bzw. *Bereit für Livebetrieb* — und darunter die Gruppen. Jeder
Eintrag: Zustand, Beschriftung, der `breaks`-Satz, und ein Link auf
`route('admin.integrations', ['tab' => $check->tab])`.

Icons nach R18 (`size-4` in der Liste). Zeiten der Herzschläge nach R19 durch
`->local()`.

Die drei Prüfungen aus Task 9 bekommen einen Knopf — und laufen **nur** auf
Knopfdruck. `DnsTokenCheck` schreibt einen TXT-Eintrag in die echte Zone; das
bei jedem Seitenaufruf zu tun, wäre eine Prüfung, die selbst ein Risiko ist:

```php
/** Prüfschlüssel → Klasse. Die Liste IST die Erlaubnis. */
private const RUNNABLE = [
    'provisioning.dns_token' => DnsTokenCheck::class,
    'onboarding.wg_hub' => WireguardEndpointCheck::class,
    'provisioning.vm_template' => VmTemplateCheck::class,
    'billing.stripe_secret' => StripeCheck::class,
];

/** @var array<string, array<string, mixed>> */
public array $results = [];

public function runCheck(string $key): void
{
    $this->authorize('secrets.manage');

    // Der Schlüssel kommt aus dem Browser. Ohne diese Zeile wäre das ein Weg,
    // beliebige Klassen aufzurufen.
    $class = self::RUNNABLE[$key] ?? null;

    if ($class === null) {
        return;
    }

    $this->results[$key] = app($class)->run();
}
```

In `Overview` ein Hinweis, sobald `Readiness::blocking()` nicht leer ist, mit
der Anzahl und einem Link auf die Seite.

- [ ] **Step 4: Tests laufen lassen, Erfolg bestätigen**

Run: `docker compose exec -T app php artisan test --filter=ReadinessPageTest`
Expected: PASS, 6 Tests

- [ ] **Step 5: Die ganze Suite laufen lassen**

Run: `docker compose exec -T app php artisan test`
Expected: PASS. Bricht alles auf einmal zusammen, ist das fast immer eine Datei
der anderen Session mitten im Schreiben — einmal neu laufen lassen, bevor man
sucht.

- [ ] **Step 6: `pint` nur auf die eigenen Pfade**

```bash
docker compose exec -T app ./vendor/bin/pint app/Support/Readiness.php app/Support/Readiness/ app/Livewire/Admin/Readiness.php
```

**Nie `--dirty`** — das Repo ist unter der Standardvorgabe nicht durchgängig
formatiert und würde fremde Dateien umschreiben.

- [ ] **Step 7: Committen**

```bash
git add -- app/Livewire/Admin/Readiness.php resources/views/livewire/admin/readiness.blade.php routes/web.php resources/views/layouts/admin.blade.php app/Livewire/Admin/Overview.php resources/views/livewire/admin/overview.blade.php lang/de/readiness.php tests/Feature/ReadinessPageTest.php
git commit -F - -- app/Livewire/Admin/Readiness.php resources/views/livewire/admin/readiness.blade.php routes/web.php resources/views/layouts/admin.blade.php app/Livewire/Admin/Overview.php resources/views/livewire/admin/overview.blade.php lang/de/readiness.php tests/Feature/ReadinessPageTest.php
```

Nachricht:

```
Put every missing field on one page before a customer pays

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
```

---

## Abschluss

- [ ] **`VERSION` anheben und taggen.** Ein Update auf dem Liveserver greift nur bei einem `v*`-Tag.

```bash
git add -- VERSION
git commit -F - -- VERSION <<'EOF'
Release 1.4.0

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
git tag v1.4.0
```

- [ ] **Push mit Token** (`GIT_ACCESS_TOKEN` aus der `.env`; ein einfaches `git push` scheitert):

```bash
git push "https://x-access-token:$TOKEN@git.bave.dev/boban/CluPilotCloud.git" main --tags
```

- [ ] **Auf dem Liveserver die Bereitschaftsseite durchgehen**, bis oben *Bereit für Livebetrieb* steht. Das ist der eigentliche Abnahmetest dieses Vorhabens — nicht die grüne Suite.

---

## Was danach offen bleibt

- **Teil C** — Testdaten markieren, eigene Belegserie, Ausschluss aus dem Umsatz, Aufräumen der ganzen Kette inkl. VM und DNS. Beschlossen, eigener Entwurf. `EndInstanceService` nimmt Route und DNS-Eintrag weg, rührt die VM aber ausdrücklich nicht an; `ProxmoxClient::deleteVm()` existiert, der Aufrufer fehlt.
- **Die zwei Phantom-Hosts** (`pve-fsn-1`, `pve-hel-1`) aus dem Pool nehmen. Die Bereitschaftsseite macht sie sichtbar, entfernt sie aber nicht.
- **Blöcke A–D** aus dem Handoff: goldene Vorlage, `vmbr0`, Traefik als Systemdienst.
