Files
portal/docs/superpowers/plans/2026-05-22-audit-operational.md
T
Дмитрий 8e8f06ebe7 chore: working tree cleanup pre-llm-first-router merge
Три группы накопившихся auto-правок (НЕ ручные):

1. markdownlint --fix auto-format (~25 .md в docs/superpowers/, docs/security/marketing-vet.md, docs/adr/015, docs/deploy/lkomega-runbook): MD031/MD032 (blank lines around fence/list) + MD004 (bullet markers `+`→`-`). Содержательных текстовых правок 3: ADR-015 bullet, sprint5d-cleanup bullet, router-discipline trailing space.

2. lefthook 2.1.6 → 2.1.8 (package.json + lock): patch-bump, авто-резолвил npm.

3. Observer runtime (docs/observer/): episodes-2026-05.jsonl +420 строк (текущая активность мозга), STATUS.md regen, .pii-counters / .read-counter тики, +2026-05-24-brain-retro.md note.

Цель — разблокировать merge feat/llm-first-router → main (этап 0 плана постановки в боевой). Содержание ветки не трогает.
2026-05-25 14:23:11 +03:00

28 KiB
Raw Blame History

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_, 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.phpregenerate → запись.
  • app/app/Http/Controllers/Api/WebhookSettingsController.phpupdate → запись.
  • app/app/Http/Controllers/Api/AdminSupplierIntegrationController.phpsetExportMode, manualQueueResolve, projectsDestroysaas_admin_audit_log.
  • app/app/Http/Controllers/Api/SupplierWebhookController.phpreceive пишет 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)

-- =============================================================================
-- 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).
-- db/migrations/2026_05_22_001_tenant_operations_log.sql
-- (содержимое = блок CREATE TABLE + INDEX + RLS + TRIGGERS выше)
  • Step 3: накатить на dev и проверить
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-тест
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
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).

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

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

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

// 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 кейса)

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); вызвать на каждой выходной ветке.
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 кейса)

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 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
// app/routes/console.php — добавить в конец:
\Illuminate\Support\Facades\Schedule::command('incidents:watch-failures')
    ->everyTenMinutes()
    ->timezone('Europe/Moscow');
  • Step 6: commit
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 «полный сценарий»

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 прогон
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.