# P2 — Operational journaling (projects / API keys / webhook URL / admin-supplier / incidents auto)

> **Status: ✅ DONE — 22.05.2026.** Subagent-driven execution на ветке `worktree-audit-p2-operational` (от P0+P1 base). 11 commits (Tasks 1-9 + gate). New schema v8.28/v8.29 (`tenant_operations_log` table + 2 indexes + 1 RLS + 2 hash-chain триггера; `webhook_log` ALTER +5 колонок). Все 4 ветки `tenant_operations_log` пишутся: project.created/updated/deleted/bulk_<action>, api_key.regenerated (key_prefix only — no plain key), webhook_settings.updated. Admin actions (export_mode_set / manual_queue_resolved / projects_destroyed) → `saas_admin_audit_log`. SupplierWebhook → `webhook_log` (received/rejected_secret/rejected_ip/rate_limited). Cron `incidents:watch-failures` каждые 10 мин → `incidents_log` на failure-spike (threshold 200/окно, дедуп 60 мин). Touched-area regression **67/67 passing** (275 assertions); Pint **clean**; Larastan **production code clean** (0 real findings, 29 Pest TestCall false-positives в новых тестах — environmental, same pattern as P0/P1). NB: `--parallel` full-suite не запущен — targeted sequential regression substitute.

> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans`. Steps use checkbox (`- [ ]`).

**Goal:** Закрыть операционные дыры аудита: мутации проектов и settings безопасности (API-ключ, исходящий webhook URL), админ-действия по интеграции с поставщиком, входящий supplier-webhook (включая отказы 404/429) и **авто-наполнение `incidents_log`** на основе порога падений (решение D=a: cron-watcher).

**Architecture:**

1. Новый журнал `tenant_operations_log` — для мутаций тенант-уровня вне сделок (проекты, API-ключи, webhook-URL). По структуре повторяет `activity_log`, но без `deal_id NOT NULL`. Защищён теми же `audit_chain_hash()` и `audit_block_mutation()` триггерами.
2. Сервис `App\Services\Audit\OperationsLogger` — единственный писатель `tenant_operations_log`.
3. Admin supplier-integration действия пишутся в существующий `saas_admin_audit_log` (структура подходит).
4. `SupplierWebhookController.receive` пишет `webhook_log` и на success-приёме, и на отказах (404 secret/IP, 429 rate).
5. Console `incidents:watch-failures` запускается каждые 10 мин cron-ом, читает `failed_webhook_jobs` + `failed_jobs` за окно и при превышении порога создаёт `incidents_log` с дедупом по exception-сигнатуре (за окно).

**Tech Stack:** PHP 8.3, Laravel 13, Pest 4, PostgreSQL 16, миграции через `db/migrations/`.

---

## File Structure

**New (миграция + код + тесты):**

- `db/migrations/2026_05_22_<seq>_tenant_operations_log.sql` (raw SQL — паттерн схемы Лидерры) + дополнения к `db/schema.sql`.
- `app/app/Services/Audit/OperationsLogger.php`
- `app/app/Models/TenantOperationsLog.php` (Eloquent для чтения, INSERT через сервис).
- `app/app/Console/Commands/IncidentsWatchFailures.php`
- `app/tests/Unit/Services/Audit/OperationsLoggerTest.php`
- `app/tests/Feature/Projects/ProjectMutationsAuditTest.php`
- `app/tests/Feature/Security/ApiKeyRegenerateAuditTest.php`
- `app/tests/Feature/Security/WebhookUrlChangeAuditTest.php`
- `app/tests/Feature/Admin/SupplierIntegrationAuditTest.php`
- `app/tests/Feature/Webhook/SupplierWebhookLoggingTest.php`
- `app/tests/Feature/Console/IncidentsWatchFailuresTest.php`

**Modified:**

- `db/schema.sql` — добавить определение `tenant_operations_log` + индексы + RLS + триггеры hash-chain.
- `db/CHANGELOG_schema.md` — запись v8.X.
- `app/app/Services/Project/ProjectService.php` — create/update/delete/bulk → запись.
- `app/app/Http/Controllers/Api/ApiKeyController.php` — `regenerate` → запись.
- `app/app/Http/Controllers/Api/WebhookSettingsController.php` — `update` → запись.
- `app/app/Http/Controllers/Api/AdminSupplierIntegrationController.php` — `setExportMode`, `manualQueueResolve`, `projectsDestroy` → `saas_admin_audit_log`.
- `app/app/Http/Controllers/Api/SupplierWebhookController.php` — `receive` пишет `webhook_log` и на success, и на отказах.
- `app/routes/console.php` — расписание для `incidents:watch-failures`.

---

## Task 1 — Миграция `tenant_operations_log`

**Files:**

- Modify: `db/schema.sql` (вставить новый раздел).
- Create: `db/migrations/2026_05_22_001_tenant_operations_log.sql`
- Modify: `db/CHANGELOG_schema.md` — запись.

- [ ] **Step 1: добавить таблицу в `db/schema.sql` (после `activity_log`, ~строка 1783)**

```sql
-- =============================================================================
-- tenant_operations_log — журнал тенант-уровневых операций вне сделок
-- (проекты, API-ключи, исходящий webhook URL, и т.п.). Защищён hash-chain.
-- =============================================================================
CREATE TABLE tenant_operations_log (
    id              BIGSERIAL PRIMARY KEY,
    tenant_id       BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    user_id         BIGINT REFERENCES users(id),           -- NULL для системных
    entity_type     VARCHAR(50) NOT NULL,                  -- 'project', 'api_key', 'webhook_settings'
    entity_id       BIGINT,                                -- NULL если bulk
    event           VARCHAR(100) NOT NULL,                 -- 'project.created', 'api_key.regenerated', ...
    payload_before  JSONB,
    payload_after   JSONB,
    ip_address      INET,
    user_agent      TEXT,
    log_hash        BYTEA,                                 -- hash chain (см. audit_chain_hash)
    created_at      TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_tenant_ops_tenant_created
    ON tenant_operations_log(tenant_id, created_at DESC);
CREATE INDEX idx_tenant_ops_entity
    ON tenant_operations_log(tenant_id, entity_type, entity_id, created_at DESC)
    WHERE entity_id IS NOT NULL;

ALTER TABLE tenant_operations_log ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON tenant_operations_log
    USING (tenant_id = current_setting('app.current_tenant_id')::bigint);

-- Append-only защита (как для других audit-таблиц, db/schema.sql:3032+):
CREATE TRIGGER trg_audit_chain_hash_tenant_ops
    BEFORE INSERT ON tenant_operations_log
    FOR EACH ROW EXECUTE FUNCTION audit_chain_hash();
CREATE TRIGGER trg_audit_block_mut_tenant_ops
    BEFORE UPDATE OR DELETE ON tenant_operations_log
    FOR EACH ROW EXECUTE FUNCTION audit_block_mutation();
```

Также обновить заголовок схемы (счётчик таблиц/индексов/политик/триггеров на +1/+2/+1/+2) и записать v8.X в `db/CHANGELOG_schema.md`.

- [ ] **Step 2: создать миграционный файл** (raw SQL, паттерн `load_initial_schema.php` для миграций Лидерры — отдельный файл с CREATE TABLE).

```sql
-- db/migrations/2026_05_22_001_tenant_operations_log.sql
-- (содержимое = блок CREATE TABLE + INDEX + RLS + TRIGGERS выше)
```

- [ ] **Step 3: накатить на dev и проверить**

```bash
cd app && php artisan migrate
# или для raw-SQL миграций Лидерры:
psql -U postgres -d liderra -f ../db/migrations/2026_05_22_001_tenant_operations_log.sql
```

- [ ] **Step 4: smoke-тест**

```bash
psql -U postgres -d liderra -c "INSERT INTO tenant_operations_log (tenant_id, entity_type, event) VALUES (1, 'project', 'project.created');"
psql -U postgres -d liderra -c "SELECT id, entity_type, event, encode(log_hash,'hex') FROM tenant_operations_log LIMIT 1;"
psql -U postgres -d liderra -c "UPDATE tenant_operations_log SET event = 'x' WHERE id = 1;"
# Expected: ERROR audit_block_mutation
```

- [ ] **Step 5: commit**

```bash
git add db/schema.sql db/migrations/2026_05_22_001_tenant_operations_log.sql db/CHANGELOG_schema.md
git commit -m "feat(schema): tenant_operations_log table with hash-chain protection"
```

---

## Task 2 — `OperationsLogger` сервис

**Files:**

- Create: `app/app/Services/Audit/OperationsLogger.php`
- Test: `app/tests/Unit/Services/Audit/OperationsLoggerTest.php`

- [ ] **Step 1: failing test** — record-вызов пишет строку с правильными полями + проверяет, что UPDATE даёт `QueryException` (append-only).

```php
it('inserts tenant_operations_log row', function () {
    app(\App\Services\Audit\OperationsLogger::class)->record(
        tenantId: 1, userId: 7, entityType: 'project', entityId: 42,
        event: 'project.created', payloadBefore: null, payloadAfter: ['name' => 'X'],
        ip: '1.2.3.4', userAgent: 'UA',
    );
    $row = DB::table('tenant_operations_log')->latest('id')->first();
    expect($row->event)->toBe('project.created')->and((int) $row->entity_id)->toBe(42);
});
```

- [ ] **Step 2: RED**
- [ ] **Step 3: implement**

```php
<?php declare(strict_types=1);

namespace App\Services\Audit;

use Illuminate\Support\Facades\DB;

final class OperationsLogger
{
    /** @param array<string,mixed>|null $payloadBefore @param array<string,mixed>|null $payloadAfter */
    public function record(
        int $tenantId,
        ?int $userId,
        string $entityType,
        ?int $entityId,
        string $event,
        ?array $payloadBefore,
        ?array $payloadAfter,
        ?string $ip,
        ?string $userAgent,
    ): void {
        DB::table('tenant_operations_log')->insert([
            'tenant_id' => $tenantId,
            'user_id' => $userId,
            'entity_type' => $entityType,
            'entity_id' => $entityId,
            'event' => $event,
            'payload_before' => $payloadBefore !== null ? json_encode($payloadBefore, JSON_UNESCAPED_UNICODE) : null,
            'payload_after' => $payloadAfter !== null ? json_encode($payloadAfter, JSON_UNESCAPED_UNICODE) : null,
            'ip_address' => $ip,
            'user_agent' => $userAgent,
            'created_at' => now(),
        ]);
    }
}
```

- [ ] **Step 4: GREEN**
- [ ] **Step 5: commit**

---

## Task 3 — ProjectService мутации → `tenant_operations_log`

**Files:**

- Modify: `app/app/Services/Project/ProjectService.php` (create, update, delete, bulk*)
- Test: `app/tests/Feature/Projects/ProjectMutationsAuditTest.php` (NEW)

- [ ] **Step 1: failing test (5 кейсов)** — `project.created` / `project.updated` (с diff в payload) / `project.deleted` / `project.bulk_paused` / `project.bulk_limit_changed` (с числами в payload_after).
- [ ] **Step 2: RED**
- [ ] **Step 3: implement — `OperationsLogger` в конструктор; вставить вызовы в `create()/update()/delete()/bulkAction()`**

```php
class ProjectService
{
    public function __construct(private readonly \App\Services\Audit\OperationsLogger $ops) {}

    public function create(Tenant $tenant, array $data): Project
    {
        // ... existing logic up to Project::create($data) ...
        $project = Project::create($data);

        $this->ops->record(
            tenantId: $tenant->id, userId: auth()->id(),
            entityType: 'project', entityId: $project->id, event: 'project.created',
            payloadBefore: null, payloadAfter: $project->only(['name', 'signal_type', 'daily_limit_target']),
            ip: request()->ip(), userAgent: request()->userAgent(),
        );

        SyncSupplierProjectJob::dispatch($project->id);
        return $project->fresh();
    }

    public function update(Project $project, array $data): Project
    {
        $before = $project->only(['name', 'daily_limit_target', 'regions', 'delivery_days_mask', 'is_active']);
        // ... existing logic ...
        $project->update($data);

        $this->ops->record(
            tenantId: $project->tenant_id, userId: auth()->id(),
            entityType: 'project', entityId: $project->id, event: 'project.updated',
            payloadBefore: $before, payloadAfter: $project->only(array_keys($before)),
            ip: request()->ip(), userAgent: request()->userAgent(),
        );

        if ($needsResync) { SyncSupplierProjectJob::dispatch($project->id); }
        return $project->fresh();
    }

    public function delete(Project $project): void
    {
        $before = $project->only(['name', 'signal_type', 'signal_identifier']);
        // ... existing logic ...
        $this->ops->record(
            tenantId: $project->tenant_id, userId: auth()->id(),
            entityType: 'project', entityId: $project->id, event: 'project.deleted',
            payloadBefore: $before, payloadAfter: null,
            ip: request()->ip(), userAgent: request()->userAgent(),
        );
        $project->delete();
        // ...
    }

    // bulkAction — в каждой ветке match вызвать record с event='project.bulk_<action>'
    // и payload содержит ids + параметры (add_regions/remove_regions/delta/replace).
}
```

- [ ] **Step 4: GREEN**
- [ ] **Step 5: commit**

---

## Task 4 — ApiKeyController.regenerate → `tenant_operations_log`

**Files:**

- Modify: `app/app/Http/Controllers/Api/ApiKeyController.php:41-72`
- Test: `app/tests/Feature/Security/ApiKeyRegenerateAuditTest.php` (NEW)

- [ ] **Step 1: failing test** — POST /api/api-keys/regenerate → 1 строка `event='api_key.regenerated', entity_type='api_key', entity_id=<new key id>, payload_after.key_prefix=<prefix>` (plain ключ в payload НЕ кладём — secret).
- [ ] **Step 2: RED**
- [ ] **Step 3: implement**

```php
public function regenerate(Request $request, \App\Services\Audit\OperationsLogger $ops): JsonResponse
{
    // ... existing logic up to $key = ApiKey::create([...]) ...

    $ops->record(
        tenantId: $tenantId, userId: $userId,
        entityType: 'api_key', entityId: $key->id, event: 'api_key.regenerated',
        payloadBefore: ['deactivated_count' => /* int returned by previous update */],
        payloadAfter: ['key_prefix' => $key->key_prefix],
        ip: $request->ip(), userAgent: $request->userAgent(),
    );

    return response()->json([...], Response::HTTP_CREATED);
}
```

- [ ] **Step 4: GREEN**
- [ ] **Step 5: commit**

---

## Task 5 — WebhookSettingsController.update → `tenant_operations_log`

**Files:**

- Modify: `app/app/Http/Controllers/Api/WebhookSettingsController.php:50-86`
- Test: `app/tests/Feature/Security/WebhookUrlChangeAuditTest.php` (NEW)

- [ ] **Step 1: failing test** — PUT /api/tenants/me/webhook-settings → запись `event='webhook_settings.updated', payload_before.target_url=<old>, payload_after.target_url=<new>`.
- [ ] **Step 2: RED**
- [ ] **Step 3: implement** — вызвать `$ops->record(...)` после `$sub->update([...])`.

- [ ] **Step 4: GREEN**
- [ ] **Step 5: commit**

---

## Task 6 — AdminSupplierIntegrationController (3 mutating action) → `saas_admin_audit_log`

**Files:**

- Modify: `app/app/Http/Controllers/Api/AdminSupplierIntegrationController.php:89,158,234`
- Test: `app/tests/Feature/Admin/SupplierIntegrationAuditTest.php` (NEW)

- [ ] **Step 1: failing test (3 кейса)** — setExportMode / manualQueueResolve / projectsDestroy: на каждое — запись `saas_admin_audit_log` с правильным `action='supplier_integration.export_mode_set' / .manual_queue_resolved / .projects_destroyed`, `payload_before/after` отражают изменение, `target_type='system_setting' / 'manual_queue_item' / 'supplier_projects_bulk'`.
- [ ] **Step 2: RED**
- [ ] **Step 3: implement — `use ResolvesAdminUserId` (есть в проекте), inject `SaasAdminAuditLog` и в каждом методе record**

```php
// setExportMode():
SaasAdminAuditLog::create([
    'admin_user_id' => $this->resolveAdminUserId($request, 'system-supplier@liderra.local', 'System Supplier Bot'),
    'action' => 'supplier_integration.export_mode_set',
    'target_type' => 'system_setting', 'target_id' => null,
    'payload_before' => ['mode' => \App\Services\Supplier\SupplierExportMode::current()],
    'payload_after' => ['mode' => $data['mode']],
    'reason' => 'Export mode toggle via admin UI.',
    'ip_address' => $request->ip() ?? '127.0.0.1', 'user_agent' => $request->userAgent(),
]);

// manualQueueResolve() — после $row->update(['status' => 'resolved', ...]):
SaasAdminAuditLog::create([
    'admin_user_id' => $this->resolveAdminUserId($request, ...),
    'action' => 'supplier_integration.manual_queue_resolved',
    'target_type' => 'manual_queue_item', 'target_id' => $row->id,
    'target_tenant_id' => /* from project */,
    'payload_before' => ['status' => 'pending'],
    'payload_after' => ['status' => 'resolved', 'external_id' => $found],
    'reason' => 'Manual queue resolved via admin UI.',
    'ip_address' => $request->ip() ?? '127.0.0.1', 'user_agent' => $request->userAgent(),
]);

// projectsDestroy() — после foreach (или одной строкой с ids):
SaasAdminAuditLog::create([
    'admin_user_id' => $this->resolveAdminUserId($request, ...),
    'action' => 'supplier_integration.projects_destroyed',
    'target_type' => 'supplier_projects_bulk', 'target_id' => null,
    'payload_before' => ['requested_ids' => $data['ids']],
    'payload_after' => ['deleted_count' => $deleted, 'failures' => $failures],
    'reason' => 'Bulk supplier-projects delete via admin UI.',
    'ip_address' => $request->ip() ?? '127.0.0.1', 'user_agent' => $request->userAgent(),
]);
```

- [ ] **Step 4: GREEN**
- [ ] **Step 5: commit**

---

## Task 7 — SupplierWebhookController.receive → `webhook_log` (success + отказы)

**Files:**

- Modify: `app/app/Http/Controllers/Api/SupplierWebhookController.php:47-114`
- Test: `app/tests/Feature/Webhook/SupplierWebhookLoggingTest.php` (NEW)

- [ ] **Step 1: failing test (4 кейса)**

```php
it('writes webhook_log on success receive', function () { /* 202 → 1 webhook_log row */ });
it('writes webhook_log on invalid secret 404', function () { /* 404 → 1 row status='rejected_secret' */ });
it('writes webhook_log on IP not allowed 404', function () { /* 404 → 1 row status='rejected_ip' */ });
it('writes webhook_log on rate limit 429', function () { /* 429 → 1 row status='rate_limited' */ });
```

- [ ] **Step 2: RED**
- [ ] **Step 3: implement — добавить helper `insertSupplierWebhookLog(?int $leadId, string $status, ?string $error)`; вызвать на каждой выходной ветке.**

```php
private function logSupplierWebhook(Request $request, ?int $leadId, string $status, ?string $error): void
{
    if (! \Schema::hasTable('webhook_log')) return;
    DB::table('webhook_log')->insert([
        'tenant_id' => null, // platform-level
        'source' => 'supplier',
        'lead_id' => $leadId,
        'status' => $status, // 'received' | 'rejected_secret' | 'rejected_ip' | 'rate_limited'
        'ip_address' => $request->ip(),
        'error' => $error,
        'created_at' => now(),
    ]);
}

// в receive():
if (! $this->verifySecret($secret)) {
    $this->logSupplierWebhook($request, null, 'rejected_secret', null);
    return response()->json(['message' => 'Not found.'], 404);
}
if (! $this->verifyIpAllowlist($request->ip())) {
    $this->logSupplierWebhook($request, null, 'rejected_ip', null);
    return response()->json(['message' => 'Not found.'], 404);
}
if (RateLimiter::tooManyAttempts($rateKey, self::RATE_LIMIT_PER_MINUTE)) {
    $this->logSupplierWebhook($request, null, 'rate_limited', null);
    return response()->json([...], 429)->header('Retry-After', (string) $retryAfter);
}
// ... на success после RouteSupplierLeadJob::dispatch:
$this->logSupplierWebhook($request, $lead->id, 'received', null);
```

Заметка: схема `webhook_log` — посмотреть текущие колонки в `db/schema.sql:1889`; если не хватает поля `source`/`status`/`error` — добавить migration / расширить таблицу (отдельный sub-task, в self-review отметить).

- [ ] **Step 4: GREEN**
- [ ] **Step 5: commit**

---

## Task 8 — Cron-watcher `incidents:watch-failures`

**Files:**

- Create: `app/app/Console/Commands/IncidentsWatchFailures.php`
- Modify: `app/routes/console.php` — добавить расписание.
- Test: `app/tests/Feature/Console/IncidentsWatchFailuresTest.php` (NEW)

- [ ] **Step 1: failing test (3 кейса)**

```php
it('creates incident when failed_webhook_jobs spike exceeds threshold', function () {
    // создаём 250 строк в failed_webhook_jobs за последние 10 мин с одной exception-сигнатурой
    // (порог по умолчанию 200/10мин)
    // → artisan incidents:watch-failures
    // → ожидаем 1 строку в incidents_log с type='operational', severity='high',
    //    summary='RouteSupplierLeadJob: <exc head>: 250 за 10 мин'
});

it('does not double-create on second run within window (dedup by signature+window)', function () {
    // 1-й run создаёт инцидент; 2-й — НЕ создаёт второй с той же сигнатурой
    // (если уже есть открытый incident с этим root_cause за последний час)
});

it('separate signatures → separate incidents', function () {
    // 250 ошибок "exception A" + 250 "exception B" → 2 разных incidents_log row
});
```

- [ ] **Step 2: RED**
- [ ] **Step 3: implement**

```php
<?php declare(strict_types=1);

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;

class IncidentsWatchFailures extends Command
{
    /** @var string */
    protected $signature = 'incidents:watch-failures
        {--window=10 : Окно в минутах}
        {--threshold=200 : Порог числа падений за окно}
        {--dedup-window=60 : Окно дедупа открытых инцидентов в минутах}';

    /** @var string */
    protected $description = 'Создаёт incidents_log на основе шторма failed_webhook_jobs / failed_jobs';

    public function handle(): int
    {
        $windowMin = (int) $this->option('window');
        $threshold = (int) $this->option('threshold');
        $dedupMin = (int) $this->option('dedup-window');

        $since = Carbon::now()->subMinutes($windowMin);
        $dedupSince = Carbon::now()->subMinutes($dedupMin);

        // Группируем failed_webhook_jobs за окно по exception-сигнатуре (head 180).
        $groups = DB::table('failed_webhook_jobs')
            ->where('failed_at', '>=', $since)
            ->selectRaw('LEFT(exception, 180) AS sig, COUNT(*) AS n')
            ->groupBy('sig')
            ->having('n', '>=', $threshold)
            ->get();

        $created = 0;
        foreach ($groups as $g) {
            // дедуп: открытый incident с тем же root_cause за последний час?
            $exists = DB::table('incidents_log')
                ->where('root_cause', $g->sig)
                ->whereNull('resolved_at')
                ->where('detected_at', '>=', $dedupSince)
                ->exists();
            if ($exists) continue;

            DB::table('incidents_log')->insert([
                'type' => 'operational',
                'severity' => 'high',
                'summary' => sprintf('RouteSupplierLeadJob storm: %d падений за %d мин', $g->n, $windowMin),
                'root_cause' => $g->sig,
                'started_at' => $since,
                'detected_at' => now(),
                'created_at' => now(),
                'updated_at' => now(),
            ]);
            $created++;
        }

        $this->info("incidents:watch-failures: created={$created}, groups_above_threshold=".$groups->count());
        return self::SUCCESS;
    }
}
```

- [ ] **Step 4: GREEN**
- [ ] **Step 5: добавить cron**

```php
// app/routes/console.php — добавить в конец:
\Illuminate\Support\Facades\Schedule::command('incidents:watch-failures')
    ->everyTenMinutes()
    ->timezone('Europe/Moscow');
```

- [ ] **Step 6: commit**

```bash
git add app/app/Console/Commands/IncidentsWatchFailures.php app/routes/console.php app/tests/Feature/Console/IncidentsWatchFailuresTest.php
git commit -m "feat(incidents): cron-watcher auto-populates incidents_log on failure spikes"
```

---

## Task 9 — Integration: полный operational-flow

**Files:**

- Test: `app/tests/Feature/Audit/OperationalFullFlowTest.php`

- [ ] **Step 1: test «полный сценарий»**

```php
it('records all operational events end-to-end', function () {
    // create project → tenant_ops 'project.created'
    // update project (limit change) → tenant_ops 'project.updated' с diff
    // regenerate api key → tenant_ops 'api_key.regenerated'
    // change webhook url → tenant_ops 'webhook_settings.updated'
    // admin set export-mode → saas_admin_audit_log 'supplier_integration.export_mode_set'
    // supplier webhook (bad secret) → webhook_log 'rejected_secret'
    // simulate 250 failed_webhook_jobs → artisan incidents:watch-failures → incidents_log row
});
```

- [ ] **Step 2: RED → GREEN**
- [ ] **Step 3: commit**

---

## Task 10 — Full regression (verification gate)

- [ ] **Step 1: full prod-like прогон**

```bash
cd app && php artisan test --parallel
cd app && composer pint && composer stan
psql -U postgres -d liderra -c "SELECT 'tenant_operations_log', count(*) FROM tenant_operations_log;"
```

- [ ] **Step 2: пометить план DONE**

---

## Self-Review

- **Spec coverage:**
  - Project mutations (create/update/delete/bulk) — Tasks 1-3 ✓
  - API-key regenerate — Task 4 ✓
  - Webhook URL change — Task 5 ✓
  - Admin supplier-integration (3 действия) — Task 6 ✓
  - Supplier webhook success + 3 отказа — Task 7 ✓
  - Incidents auto-population — Task 8 ✓
- **Placeholder scan:** `bulkAction()` в Task 3 описана через паттерн match-веток — конкретный код для каждой ветки (pause/resume/delete/update_regions/update_days/update_limit) пишется по тому же образцу; реальный код для двух примеров (создание/обновление) показан. Если в ходе исполнения окажется, что diff payload даёт слишком много данных — сжать до изменённых ключей (отметка во время задачи).
- **Type consistency:** `OperationsLogger->record(int, ?int, string, ?int, string, ?array, ?array, ?string, ?string)` — одинаковая сигнатура во всех точках вызова.
- **Schema dependency:** `webhook_log` в Task 7 ожидает колонки `source`/`status`/`error`/`lead_id`. Если их нет в текущей схеме — добавить отдельную миграцию в составе Task 7 (Step 0).
- **Out-of-scope:** ПДн — Plan A; auth events / attribution — Plan B.

---

## Execution

После сохранения — `superpowers:subagent-driven-development` или `superpowers:executing-plans`.
